Skip to main content

merman_export/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Bounded binary export for SVG that has passed Merman's terminal compatibility validation.
4//!
5//! This crate deliberately accepts [`ResvgCompatibleSvg`] rather than Mermaid source or an
6//! arbitrary SVG string. Parsing, semantic construction, layout, SVG production, and terminal
7//! SVG validation stay owned by `merman`; this crate only owns allocation-aware encoding.
8
9#[cfg(any(feature = "png", feature = "jpeg"))]
10use cssparser::{Delimiter, Parser, Token};
11#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
12use merman_core::{
13    OperationCancelled, OperationControl, OperationLedgerError, OperationPhase,
14    OperationResourceDomain, OperationResourceLimitExceeded, OperationResourceProvenance,
15};
16#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
17use merman_render::svg::ResvgCompatibleSvg;
18#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
19use std::sync::{Arc, OnceLock};
20
21#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
22#[derive(Debug, thiserror::Error)]
23pub enum ExportError {
24    #[error(transparent)]
25    Cancelled(#[from] OperationCancelled),
26    #[error("failed to parse SVG")]
27    SvgParse,
28    #[error("failed to set SVG Document size from tree")]
29    SvgDocSize,
30    #[error("failed to allocate pixmap for raster rendering")]
31    PixmapAlloc,
32    #[error("invalid raster scale; expected a finite positive number")]
33    InvalidScale,
34    #[error("invalid raster sizing option: {0}")]
35    InvalidSizing(&'static str),
36    #[error("failed to encode PNG")]
37    PngEncode,
38    #[error("invalid background color for JPG rendering")]
39    JpegBackground,
40    #[error("JPG rendering requires an opaque background color (e.g. white)")]
41    JpegOpaqueBackgroundRequired,
42    #[error("failed to encode JPG")]
43    JpegEncode,
44    #[error("JPG dimensions exceed the 65535-pixel encoder limit")]
45    JpegDimensionLimit,
46    #[error("failed to convert SVG to PDF")]
47    PdfConvert,
48    #[error("embedded image resource limit exceeded: {limit_name} is {actual}, maximum is {max}")]
49    EmbeddedImageLimit {
50        limit_name: &'static str,
51        actual: u64,
52        max: u64,
53    },
54    #[error("SVG conversion resource limit exceeded: {limit_name} is {actual}, maximum is {max}")]
55    SvgConversionLimit {
56        limit_name: &'static str,
57        actual: u64,
58        max: u64,
59    },
60    #[cfg(feature = "pdf")]
61    #[error(
62        "PDF filter image resource limit exceeded: requested pixels are {actual}, maximum is {max}"
63    )]
64    PdfFilterImageLimit { actual: u64, max: u64 },
65    #[error(
66        "operation resource limit `{limit_id}` exceeded during {phase}: actual={actual} maximum={max}"
67    )]
68    ResourceLimitTerminal {
69        limit_id: &'static str,
70        phase: &'static str,
71        actual: u64,
72        max: u64,
73    },
74    #[error(
75        "operation resource `{limit_id}` arithmetic overflow during {phase}: actual={actual} maximum={max}"
76    )]
77    ResourceArithmeticOverflow {
78        limit_id: &'static str,
79        phase: &'static str,
80        actual: u64,
81        max: u64,
82    },
83    #[doc(hidden)]
84    #[error(transparent)]
85    OperationResourceTerminal(OperationLedgerError),
86    #[error("failed to start the recursive SVG backend worker")]
87    BackendWorkerSpawn,
88    #[error("the recursive SVG backend worker panicked")]
89    BackendWorkerPanic,
90}
91
92/// Stable resource metadata for an export failure.
93#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95#[non_exhaustive]
96pub struct ExportResourceLimitDetails {
97    pub limit_id: &'static str,
98    pub phase: &'static str,
99    pub actual: u64,
100    pub max: u64,
101    pub cause: ExportResourceLimitCause,
102}
103
104/// Stable reason for an export resource rejection.
105#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107#[non_exhaustive]
108pub enum ExportResourceLimitCause {
109    Ceiling,
110    ArithmeticOverflow,
111}
112
113#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
114impl ExportError {
115    /// Returns transport-neutral resource metadata without exposing exporter-internal field names.
116    #[must_use]
117    pub fn resource_limit_details(&self) -> Option<ExportResourceLimitDetails> {
118        let (limit_id, phase, actual, max, cause) = match self {
119            Self::EmbeddedImageLimit {
120                limit_name,
121                actual,
122                max,
123            } => {
124                let limit_id = match *limit_name {
125                    "max_bytes_per_image" => MAX_EMBEDDED_IMAGE_BYTES_RESOURCE_LIMIT_ID,
126                    "max_total_bytes" => MAX_TOTAL_EMBEDDED_IMAGE_BYTES_RESOURCE_LIMIT_ID,
127                    "max_pixels_per_image" => MAX_EMBEDDED_IMAGE_PIXELS_RESOURCE_LIMIT_ID,
128                    "max_total_pixels" => MAX_TOTAL_EMBEDDED_IMAGE_PIXELS_RESOURCE_LIMIT_ID,
129                    _ => return None,
130                };
131                (
132                    limit_id,
133                    "embedded_image_decode",
134                    *actual,
135                    *max,
136                    ExportResourceLimitCause::Ceiling,
137                )
138            }
139            Self::SvgConversionLimit {
140                limit_name,
141                actual,
142                max,
143            } => {
144                let (limit_id, phase) = match *limit_name {
145                    "max_isolation_depth" => (
146                        MAX_SVG_CONVERSION_ISOLATION_DEPTH_RESOURCE_LIMIT_ID,
147                        "svg_conversion",
148                    ),
149                    "max_filter_primitives_per_filter" => (
150                        MAX_SVG_CONVERSION_FILTER_PRIMITIVES_PER_FILTER_RESOURCE_LIMIT_ID,
151                        "svg_conversion",
152                    ),
153                    "max_total_filter_primitives" => (
154                        MAX_TOTAL_SVG_CONVERSION_FILTER_PRIMITIVES_RESOURCE_LIMIT_ID,
155                        "svg_conversion",
156                    ),
157                    "max_subroots" => (
158                        MAX_SVG_CONVERSION_SUBROOTS_RESOURCE_LIMIT_ID,
159                        "svg_conversion",
160                    ),
161                    "max_nested_svg_images" => {
162                        (MAX_NESTED_SVG_IMAGES_RESOURCE_LIMIT_ID, "svg_conversion")
163                    }
164                    limit_id @ merman_render::resources::SVG_BACKEND_TREE_NODES_HARD_CAP_ID => (
165                        limit_id,
166                        merman_render::resources::ResourceLimitPhase::SvgPostprocess.as_str(),
167                    ),
168                    limit_id @ merman_render::resources::SVG_BACKEND_TREE_DEPTH_HARD_CAP_ID => (
169                        limit_id,
170                        merman_render::resources::ResourceLimitPhase::SvgPostprocess.as_str(),
171                    ),
172                    _ => return None,
173                };
174                (
175                    limit_id,
176                    phase,
177                    *actual,
178                    *max,
179                    ExportResourceLimitCause::Ceiling,
180                )
181            }
182            #[cfg(feature = "pdf")]
183            Self::PdfFilterImageLimit { actual, max } => (
184                MAX_PDF_FILTER_IMAGE_PIXELS_RESOURCE_LIMIT_ID,
185                "pdf_filter_rasterization",
186                *actual,
187                *max,
188                ExportResourceLimitCause::Ceiling,
189            ),
190            Self::ResourceLimitTerminal {
191                limit_id,
192                phase,
193                actual,
194                max,
195            } => (
196                *limit_id,
197                *phase,
198                *actual,
199                *max,
200                ExportResourceLimitCause::Ceiling,
201            ),
202            Self::ResourceArithmeticOverflow {
203                limit_id,
204                phase,
205                actual,
206                max,
207            } => (
208                *limit_id,
209                *phase,
210                *actual,
211                *max,
212                ExportResourceLimitCause::ArithmeticOverflow,
213            ),
214            Self::OperationResourceTerminal(error) => {
215                return operation_resource_limit_details(error);
216            }
217            _ => return None,
218        };
219        Some(ExportResourceLimitDetails {
220            limit_id,
221            phase,
222            actual,
223            max,
224            cause,
225        })
226    }
227
228    /// Returns the resource owner recorded by the originating adapter.
229    #[must_use]
230    pub fn resource_limit_provenance(&self) -> Option<OperationResourceProvenance> {
231        match self {
232            Self::OperationResourceTerminal(OperationLedgerError::Cancelled(_)) => None,
233            Self::OperationResourceTerminal(OperationLedgerError::ResourceLimitExceeded(error)) => {
234                Some(error.provenance.clone())
235            }
236            Self::OperationResourceTerminal(OperationLedgerError::ArithmeticOverflow {
237                provenance,
238                ..
239            }) => Some(provenance.clone()),
240            _ => self
241                .resource_limit_details()
242                .map(|details| export_operation_resource_provenance(details.limit_id)),
243        }
244    }
245}
246
247#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
248fn operation_resource_limit_details(
249    error: &OperationLedgerError,
250) -> Option<ExportResourceLimitDetails> {
251    match error {
252        OperationLedgerError::Cancelled(_) => None,
253        OperationLedgerError::ResourceLimitExceeded(error) => Some(ExportResourceLimitDetails {
254            limit_id: error.id,
255            phase: error.resource_phase,
256            actual: error.consumed.saturating_add(error.requested),
257            max: error.limit,
258            cause: ExportResourceLimitCause::Ceiling,
259        }),
260        OperationLedgerError::ArithmeticOverflow {
261            id,
262            resource_phase,
263            actual,
264            maximum,
265            ..
266        } => Some(ExportResourceLimitDetails {
267            limit_id: id,
268            phase: resource_phase,
269            actual: *actual,
270            max: *maximum,
271            cause: ExportResourceLimitCause::ArithmeticOverflow,
272        }),
273    }
274}
275
276#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
277pub type Result<T> = std::result::Result<T, ExportError>;
278
279#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
280fn export_checkpoint(control: &OperationControl) -> Result<()> {
281    control
282        .terminal_checkpoint_at(OperationPhase::Export)
283        .map_err(export_terminal_error)
284}
285
286#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
287fn terminate_export_resource_error(control: &OperationControl, error: ExportError) -> ExportError {
288    let Some(details) = error.resource_limit_details() else {
289        return error;
290    };
291    let Some(provenance) = error.resource_limit_provenance() else {
292        return error;
293    };
294    if let Err(terminal) = control.terminal_checkpoint_at(OperationPhase::Export) {
295        return export_terminal_error(terminal);
296    }
297    let (terminal, expected) = match details.cause {
298        ExportResourceLimitCause::Ceiling => {
299            let error = OperationResourceLimitExceeded {
300                id: details.limit_id,
301                phase: OperationPhase::Export,
302                resource_phase: details.phase,
303                limit: details.max,
304                consumed: 0,
305                requested: details.actual,
306                provenance: provenance.clone(),
307            };
308            (
309                control.terminate_resource_limit(error.clone()),
310                OperationLedgerError::ResourceLimitExceeded(error),
311            )
312        }
313        ExportResourceLimitCause::ArithmeticOverflow => {
314            let expected = OperationLedgerError::ArithmeticOverflow {
315                id: details.limit_id,
316                phase: OperationPhase::Export,
317                resource_phase: details.phase,
318                actual: details.actual,
319                maximum: details.max,
320                provenance: provenance.clone(),
321            };
322            (
323                control.terminate_resource_overflow(
324                    details.limit_id,
325                    OperationPhase::Export,
326                    details.phase,
327                    details.actual,
328                    details.max,
329                    provenance,
330                ),
331                expected,
332            )
333        }
334    };
335    if terminal == expected {
336        error
337    } else {
338        export_terminal_error(terminal)
339    }
340}
341
342#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
343fn export_operation_resource_provenance(limit_id: &str) -> OperationResourceProvenance {
344    let domain = match limit_id {
345        merman_render::resources::SVG_BACKEND_TREE_NODES_HARD_CAP_ID
346        | merman_render::resources::SVG_BACKEND_TREE_DEPTH_HARD_CAP_ID => {
347            OperationResourceDomain::Render
348        }
349        _ => OperationResourceDomain::Export,
350    };
351    OperationResourceProvenance::new(domain, None, [])
352}
353
354#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
355fn settle_export_result<T>(control: &OperationControl, result: Result<T>) -> Result<T> {
356    let result = result.map_err(|error| terminate_export_resource_error(control, error));
357    observe_after_export_result(control, result)
358}
359
360#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
361fn observe_after_export_result<T>(control: &OperationControl, result: Result<T>) -> Result<T> {
362    match result {
363        Err(error) if error.resource_limit_details().is_some() => Err(error),
364        result => {
365            export_checkpoint(control)?;
366            result
367        }
368    }
369}
370
371#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
372fn export_terminal_error(error: OperationLedgerError) -> ExportError {
373    match error {
374        OperationLedgerError::Cancelled(error) => ExportError::Cancelled(error),
375        terminal @ (OperationLedgerError::ResourceLimitExceeded(_)
376        | OperationLedgerError::ArithmeticOverflow { .. }) => {
377            ExportError::OperationResourceTerminal(terminal)
378        }
379    }
380}
381
382#[cfg(any(feature = "png", feature = "jpeg"))]
383pub const DEFAULT_MAX_RASTER_SIDE_LENGTH: u32 = 4096;
384#[cfg(any(feature = "png", feature = "jpeg"))]
385pub const DEFAULT_MAX_RASTER_PIXELS: u64 =
386    (DEFAULT_MAX_RASTER_SIDE_LENGTH as u64) * (DEFAULT_MAX_RASTER_SIDE_LENGTH as u64);
387/// Aggregate pixels retained as localized PDF filter images before sampling is reduced.
388#[cfg(feature = "pdf")]
389pub const DEFAULT_MAX_PDF_FILTER_IMAGE_PIXELS: u64 = 32 * 1024 * 1024;
390/// Maximum intrinsic pixels accepted for one embedded raster image by default.
391#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
392pub const DEFAULT_MAX_DECODED_IMAGE_PIXELS: u64 = 16 * 1024 * 1024;
393/// Maximum aggregate intrinsic pixels accepted across embedded raster images by default.
394#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
395pub const DEFAULT_MAX_TOTAL_DECODED_IMAGE_PIXELS: u64 = 32 * 1024 * 1024;
396/// Maximum decoded data-URL bytes accepted for one embedded image before usvg parsing.
397#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
398pub const DEFAULT_MAX_EMBEDDED_IMAGE_BYTES: u64 = 16 * 1024 * 1024;
399/// Maximum aggregate decoded data-URL bytes accepted before usvg parsing.
400#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
401pub const DEFAULT_MAX_TOTAL_EMBEDDED_IMAGE_BYTES: u64 = 32 * 1024 * 1024;
402#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
403pub const DEFAULT_MAX_SVG_ISOLATION_DEPTH: usize = 8;
404#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
405pub const DEFAULT_MAX_FILTER_PRIMITIVES_PER_FILTER: usize = 8;
406#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
407pub const DEFAULT_MAX_TOTAL_FILTER_PRIMITIVES: usize = 128;
408#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
409pub const DEFAULT_MAX_SVG_SUBROOTS: usize = 4096;
410#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
411pub const DEFAULT_MAX_NESTED_SVG_IMAGES: usize = 64;
412
413/// Stable binding metadata for native export limits owned by this crate.
414#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
415#[derive(Debug, Clone, Copy, PartialEq, Eq)]
416#[non_exhaustive]
417pub struct ExportResourceLimitDescriptor {
418    pub stable_id: &'static str,
419    pub phase: &'static str,
420    pub description: &'static str,
421    pub overridable: bool,
422    pub hard_cap: bool,
423    pub minimum_value: usize,
424}
425
426#[cfg(any(feature = "png", feature = "jpeg"))]
427pub const MAX_RASTER_WIDTH_RESOURCE_LIMIT_ID: &str = "max_raster_width";
428#[cfg(any(feature = "png", feature = "jpeg"))]
429pub const MAX_RASTER_HEIGHT_RESOURCE_LIMIT_ID: &str = "max_raster_height";
430#[cfg(any(feature = "png", feature = "jpeg"))]
431pub const MAX_RASTER_PIXELS_RESOURCE_LIMIT_ID: &str = "max_raster_pixels";
432#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
433pub const MAX_EMBEDDED_IMAGE_BYTES_RESOURCE_LIMIT_ID: &str = "max_embedded_image_bytes";
434#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
435pub const MAX_TOTAL_EMBEDDED_IMAGE_BYTES_RESOURCE_LIMIT_ID: &str = "max_total_embedded_image_bytes";
436#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
437pub const MAX_EMBEDDED_IMAGE_PIXELS_RESOURCE_LIMIT_ID: &str = "max_embedded_image_pixels";
438#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
439pub const MAX_TOTAL_EMBEDDED_IMAGE_PIXELS_RESOURCE_LIMIT_ID: &str =
440    "max_total_embedded_image_pixels";
441#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
442pub const MAX_SVG_CONVERSION_ISOLATION_DEPTH_RESOURCE_LIMIT_ID: &str =
443    "max_svg_conversion_isolation_depth";
444#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
445pub const MAX_SVG_CONVERSION_FILTER_PRIMITIVES_PER_FILTER_RESOURCE_LIMIT_ID: &str =
446    "max_svg_conversion_filter_primitives_per_filter";
447#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
448pub const MAX_TOTAL_SVG_CONVERSION_FILTER_PRIMITIVES_RESOURCE_LIMIT_ID: &str =
449    "max_total_svg_conversion_filter_primitives";
450#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
451pub const MAX_SVG_CONVERSION_SUBROOTS_RESOURCE_LIMIT_ID: &str = "max_svg_conversion_subroots";
452#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
453pub const MAX_NESTED_SVG_IMAGES_RESOURCE_LIMIT_ID: &str = "max_nested_svg_images";
454#[cfg(feature = "pdf")]
455pub const MAX_PDF_FILTER_IMAGE_PIXELS_RESOURCE_LIMIT_ID: &str = "max_pdf_filter_image_pixels";
456#[cfg(any(feature = "png", feature = "jpeg"))]
457const RASTER_RESOURCE_LIMIT_DESCRIPTORS: [ExportResourceLimitDescriptor; 3] = [
458    ExportResourceLimitDescriptor {
459        stable_id: MAX_RASTER_WIDTH_RESOURCE_LIMIT_ID,
460        phase: "raster_allocation",
461        description: "Maximum final PNG or JPEG width in pixels",
462        overridable: true,
463        hard_cap: false,
464        minimum_value: 1,
465    },
466    ExportResourceLimitDescriptor {
467        stable_id: MAX_RASTER_HEIGHT_RESOURCE_LIMIT_ID,
468        phase: "raster_allocation",
469        description: "Maximum final PNG or JPEG height in pixels",
470        overridable: true,
471        hard_cap: false,
472        minimum_value: 1,
473    },
474    ExportResourceLimitDescriptor {
475        stable_id: MAX_RASTER_PIXELS_RESOURCE_LIMIT_ID,
476        phase: "raster_allocation",
477        description: "Maximum final PNG or JPEG pixel count",
478        overridable: true,
479        hard_cap: false,
480        minimum_value: 1,
481    },
482];
483
484#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
485const EMBEDDED_IMAGE_RESOURCE_LIMIT_DESCRIPTORS: [ExportResourceLimitDescriptor; 4] = [
486    ExportResourceLimitDescriptor {
487        stable_id: MAX_EMBEDDED_IMAGE_BYTES_RESOURCE_LIMIT_ID,
488        phase: "embedded_image_decode",
489        description: "Maximum decoded data-URL bytes for one embedded image",
490        overridable: true,
491        hard_cap: false,
492        minimum_value: 1,
493    },
494    ExportResourceLimitDescriptor {
495        stable_id: MAX_TOTAL_EMBEDDED_IMAGE_BYTES_RESOURCE_LIMIT_ID,
496        phase: "embedded_image_decode",
497        description: "Maximum aggregate decoded data-URL bytes across embedded images",
498        overridable: true,
499        hard_cap: false,
500        minimum_value: 1,
501    },
502    ExportResourceLimitDescriptor {
503        stable_id: MAX_EMBEDDED_IMAGE_PIXELS_RESOURCE_LIMIT_ID,
504        phase: "embedded_image_decode",
505        description: "Maximum intrinsic pixels for one embedded raster image",
506        overridable: true,
507        hard_cap: false,
508        minimum_value: 1,
509    },
510    ExportResourceLimitDescriptor {
511        stable_id: MAX_TOTAL_EMBEDDED_IMAGE_PIXELS_RESOURCE_LIMIT_ID,
512        phase: "embedded_image_decode",
513        description: "Maximum aggregate intrinsic pixels across embedded raster images",
514        overridable: true,
515        hard_cap: false,
516        minimum_value: 1,
517    },
518];
519
520#[cfg(feature = "pdf")]
521const PDF_RESOURCE_LIMIT_DESCRIPTORS: [ExportResourceLimitDescriptor; 1] =
522    [ExportResourceLimitDescriptor {
523        stable_id: MAX_PDF_FILTER_IMAGE_PIXELS_RESOURCE_LIMIT_ID,
524        phase: "pdf_filter_rasterization",
525        description: "Maximum aggregate pixels retained for localized PDF filter images",
526        overridable: true,
527        hard_cap: false,
528        minimum_value: 1,
529    }];
530
531// These are backend recursion guards, not caller policy knobs. They remain active for the
532// trusted-input profile and are exposed only so resource failures have discoverable stable IDs.
533#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
534const SVG_CONVERSION_HARD_CAP_DESCRIPTORS: [ExportResourceLimitDescriptor; 5] = [
535    ExportResourceLimitDescriptor {
536        stable_id: MAX_SVG_CONVERSION_ISOLATION_DEPTH_RESOURCE_LIMIT_ID,
537        phase: "svg_conversion",
538        description: "Maximum nested SVG isolation depth accepted by native export",
539        overridable: false,
540        hard_cap: true,
541        minimum_value: 1,
542    },
543    ExportResourceLimitDescriptor {
544        stable_id: MAX_SVG_CONVERSION_FILTER_PRIMITIVES_PER_FILTER_RESOURCE_LIMIT_ID,
545        phase: "svg_conversion",
546        description: "Maximum primitives accepted in one SVG filter",
547        overridable: false,
548        hard_cap: true,
549        minimum_value: 1,
550    },
551    ExportResourceLimitDescriptor {
552        stable_id: MAX_TOTAL_SVG_CONVERSION_FILTER_PRIMITIVES_RESOURCE_LIMIT_ID,
553        phase: "svg_conversion",
554        description: "Maximum aggregate SVG filter primitives accepted by native export",
555        overridable: false,
556        hard_cap: true,
557        minimum_value: 1,
558    },
559    ExportResourceLimitDescriptor {
560        stable_id: MAX_SVG_CONVERSION_SUBROOTS_RESOURCE_LIMIT_ID,
561        phase: "svg_conversion",
562        description: "Maximum resolved SVG subroots accepted by native export",
563        overridable: false,
564        hard_cap: true,
565        minimum_value: 1,
566    },
567    ExportResourceLimitDescriptor {
568        stable_id: MAX_NESTED_SVG_IMAGES_RESOURCE_LIMIT_ID,
569        phase: "svg_conversion",
570        description: "Maximum nested SVG images accepted by native export",
571        overridable: false,
572        hard_cap: true,
573        minimum_value: 1,
574    },
575];
576
577/// Returns the export limits compiled into the concrete feature closure.
578#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
579#[must_use]
580pub fn export_resource_limit_descriptors() -> Vec<ExportResourceLimitDescriptor> {
581    let mut descriptors = Vec::new();
582    #[cfg(any(feature = "png", feature = "jpeg"))]
583    descriptors.extend_from_slice(&RASTER_RESOURCE_LIMIT_DESCRIPTORS);
584    descriptors.extend_from_slice(&EMBEDDED_IMAGE_RESOURCE_LIMIT_DESCRIPTORS);
585    #[cfg(feature = "pdf")]
586    descriptors.extend_from_slice(&PDF_RESOURCE_LIMIT_DESCRIPTORS);
587    descriptors.extend_from_slice(&SVG_CONVERSION_HARD_CAP_DESCRIPTORS);
588    descriptors
589}
590
591/// Returns the outputs that can enforce one export-owned resource limit.
592#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
593#[must_use]
594pub fn export_resource_limit_output_ids(stable_id: &str) -> Option<&'static [&'static str]> {
595    #[cfg(any(feature = "png", feature = "jpeg"))]
596    if RASTER_RESOURCE_LIMIT_DESCRIPTORS
597        .iter()
598        .any(|descriptor| descriptor.stable_id == stable_id)
599    {
600        return Some(&["png", "jpeg"]);
601    }
602    if EMBEDDED_IMAGE_RESOURCE_LIMIT_DESCRIPTORS
603        .iter()
604        .any(|descriptor| descriptor.stable_id == stable_id)
605    {
606        return Some(&["png", "jpeg", "pdf"]);
607    }
608    #[cfg(feature = "pdf")]
609    if PDF_RESOURCE_LIMIT_DESCRIPTORS
610        .iter()
611        .any(|descriptor| descriptor.stable_id == stable_id)
612    {
613        return Some(&["pdf"]);
614    }
615    if SVG_CONVERSION_HARD_CAP_DESCRIPTORS
616        .iter()
617        .any(|descriptor| descriptor.stable_id == stable_id)
618    {
619        return Some(&["png", "jpeg", "pdf"]);
620    }
621    None
622}
623
624/// Returns the profile value for an export-owned resource limit.
625#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
626#[must_use]
627pub fn export_resource_profile_value(
628    profile: merman_render::resources::RenderResourceProfile,
629    stable_id: &str,
630) -> Option<Option<usize>> {
631    let finite_value = match stable_id {
632        #[cfg(any(feature = "png", feature = "jpeg"))]
633        MAX_RASTER_WIDTH_RESOURCE_LIMIT_ID | MAX_RASTER_HEIGHT_RESOURCE_LIMIT_ID => {
634            DEFAULT_MAX_RASTER_SIDE_LENGTH as usize
635        }
636        #[cfg(any(feature = "png", feature = "jpeg"))]
637        MAX_RASTER_PIXELS_RESOURCE_LIMIT_ID => DEFAULT_MAX_RASTER_PIXELS as usize,
638        MAX_EMBEDDED_IMAGE_BYTES_RESOURCE_LIMIT_ID => DEFAULT_MAX_EMBEDDED_IMAGE_BYTES as usize,
639        MAX_TOTAL_EMBEDDED_IMAGE_BYTES_RESOURCE_LIMIT_ID => {
640            DEFAULT_MAX_TOTAL_EMBEDDED_IMAGE_BYTES as usize
641        }
642        MAX_EMBEDDED_IMAGE_PIXELS_RESOURCE_LIMIT_ID => DEFAULT_MAX_DECODED_IMAGE_PIXELS as usize,
643        MAX_TOTAL_EMBEDDED_IMAGE_PIXELS_RESOURCE_LIMIT_ID => {
644            DEFAULT_MAX_TOTAL_DECODED_IMAGE_PIXELS as usize
645        }
646        #[cfg(feature = "pdf")]
647        MAX_PDF_FILTER_IMAGE_PIXELS_RESOURCE_LIMIT_ID => {
648            DEFAULT_MAX_PDF_FILTER_IMAGE_PIXELS as usize
649        }
650        MAX_SVG_CONVERSION_ISOLATION_DEPTH_RESOURCE_LIMIT_ID => DEFAULT_MAX_SVG_ISOLATION_DEPTH,
651        MAX_SVG_CONVERSION_FILTER_PRIMITIVES_PER_FILTER_RESOURCE_LIMIT_ID => {
652            DEFAULT_MAX_FILTER_PRIMITIVES_PER_FILTER
653        }
654        MAX_TOTAL_SVG_CONVERSION_FILTER_PRIMITIVES_RESOURCE_LIMIT_ID => {
655            DEFAULT_MAX_TOTAL_FILTER_PRIMITIVES
656        }
657        MAX_SVG_CONVERSION_SUBROOTS_RESOURCE_LIMIT_ID => DEFAULT_MAX_SVG_SUBROOTS,
658        MAX_NESTED_SVG_IMAGES_RESOURCE_LIMIT_ID => DEFAULT_MAX_NESTED_SVG_IMAGES,
659        _ => return None,
660    };
661    if SVG_CONVERSION_HARD_CAP_DESCRIPTORS
662        .iter()
663        .any(|descriptor| descriptor.stable_id == stable_id)
664    {
665        return Some(Some(finite_value));
666    }
667    Some(match profile {
668        merman_render::resources::RenderResourceProfile::UnboundedForTrustedInput => None,
669        merman_render::resources::RenderResourceProfile::Interactive
670        | merman_render::resources::RenderResourceProfile::Constrained
671        | merman_render::resources::RenderResourceProfile::TrustedNative => Some(finite_value),
672    })
673}
674
675#[cfg(feature = "pdf")]
676const PDF_POINTS_PER_CSS_PIXEL: f32 = 72.0 / 96.0;
677#[cfg(feature = "pdf")]
678const KRILLA_MAX_FILTER_SIDE_PX: f64 = 5000.0;
679#[cfg(all(
680    any(feature = "png", feature = "jpeg", feature = "pdf"),
681    not(target_arch = "wasm32")
682))]
683const RECURSIVE_SVG_BACKEND_STACK_BYTES: usize = 8 * 1024 * 1024;
684
685#[cfg(all(
686    any(feature = "png", feature = "jpeg", feature = "pdf"),
687    target_arch = "wasm32"
688))]
689const RECURSIVE_SVG_BACKEND_STACK_BYTES: usize = 0;
690
691#[cfg(all(
692    any(feature = "png", feature = "jpeg", feature = "pdf"),
693    not(target_arch = "wasm32")
694))]
695fn run_recursive_svg_backend<T, F>(control: &OperationControl, job: F) -> Result<T>
696where
697    T: Send + 'static,
698    F: FnOnce(&OperationControl) -> Result<T> + Send + 'static,
699{
700    export_checkpoint(control)?;
701    let worker_control = control.clone();
702    let worker = std::thread::Builder::new()
703        .name("merman-svg-backend".to_string())
704        .stack_size(RECURSIVE_SVG_BACKEND_STACK_BYTES)
705        .spawn(move || {
706            export_checkpoint(&worker_control)?;
707            settle_export_result(&worker_control, job(&worker_control))
708        });
709    let worker = match worker {
710        Ok(worker) => worker,
711        Err(_) => {
712            export_checkpoint(control)?;
713            return Err(ExportError::BackendWorkerSpawn);
714        }
715    };
716    let result = worker
717        .join()
718        .unwrap_or(Err(ExportError::BackendWorkerPanic));
719    observe_after_export_result(control, result)
720}
721
722#[cfg(all(
723    any(feature = "png", feature = "jpeg", feature = "pdf"),
724    target_arch = "wasm32"
725))]
726fn run_recursive_svg_backend<T, F>(control: &OperationControl, job: F) -> Result<T>
727where
728    T: Send + 'static,
729    F: FnOnce(&OperationControl) -> Result<T> + Send + 'static,
730{
731    export_checkpoint(control)?;
732    settle_export_result(control, job(control))
733}
734
735/// Optional display box for target-aware rasterization.
736///
737/// Browser previews typically draw Mermaid SVG as vector content inside a container. A headless
738/// rasterizer has to allocate a full pixmap, so UI hosts should pass the visible container size
739/// here and use [`RasterOptions::scale`] for device-pixel ratio.
740#[cfg(any(feature = "png", feature = "jpeg"))]
741#[derive(Debug, Clone, Copy, PartialEq, Eq)]
742pub struct RasterFitBox {
743    pub width: Option<u32>,
744    pub height: Option<u32>,
745}
746
747#[cfg(any(feature = "png", feature = "jpeg"))]
748impl RasterFitBox {
749    pub const fn new(width: Option<u32>, height: Option<u32>) -> Self {
750        Self { width, height }
751    }
752
753    pub const fn width(width: u32) -> Self {
754        Self {
755            width: Some(width),
756            height: None,
757        }
758    }
759
760    pub const fn height(height: u32) -> Self {
761        Self {
762            width: None,
763            height: Some(height),
764        }
765    }
766
767    pub const fn contain(width: u32, height: u32) -> Self {
768        Self {
769            width: Some(width),
770            height: Some(height),
771        }
772    }
773}
774
775/// Resource budget applied before allocating the output pixmap.
776#[cfg(any(feature = "png", feature = "jpeg"))]
777#[derive(Debug, Clone, Copy, PartialEq, Eq)]
778pub struct RasterSizeLimit {
779    pub max_width: Option<u32>,
780    pub max_height: Option<u32>,
781    pub max_pixels: Option<u64>,
782}
783
784#[cfg(any(feature = "png", feature = "jpeg"))]
785impl RasterSizeLimit {
786    pub const fn new(
787        max_width: Option<u32>,
788        max_height: Option<u32>,
789        max_pixels: Option<u64>,
790    ) -> Self {
791        Self {
792            max_width,
793            max_height,
794            max_pixels,
795        }
796    }
797
798    pub const fn max_side_length(max_side_length: u32) -> Self {
799        Self {
800            max_width: Some(max_side_length),
801            max_height: Some(max_side_length),
802            max_pixels: None,
803        }
804    }
805
806    pub const fn default_safe() -> Self {
807        Self {
808            max_width: Some(DEFAULT_MAX_RASTER_SIDE_LENGTH),
809            max_height: Some(DEFAULT_MAX_RASTER_SIDE_LENGTH),
810            max_pixels: Some(DEFAULT_MAX_RASTER_PIXELS),
811        }
812    }
813
814    pub const fn unbounded() -> Self {
815        Self {
816            max_width: None,
817            max_height: None,
818            max_pixels: None,
819        }
820    }
821}
822
823#[cfg(any(feature = "png", feature = "jpeg"))]
824impl Default for RasterSizeLimit {
825    fn default() -> Self {
826        Self::default_safe()
827    }
828}
829
830#[cfg(any(feature = "png", feature = "jpeg"))]
831#[derive(Debug, Clone)]
832pub struct RasterOptions {
833    pub scale: f32,
834    pub background: Option<String>,
835    pub jpeg_quality: u8,
836    pub fit_to: Option<RasterFitBox>,
837    pub size_limit: RasterSizeLimit,
838    pub embedded_image_limit: EmbeddedImageLimit,
839    pub conversion_limits: SvgConversionLimits,
840}
841
842/// Resource budget checked before and after usvg resolves embedded images.
843#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
844#[derive(Debug, Clone, Copy, PartialEq, Eq)]
845pub struct EmbeddedImageLimit {
846    pub max_bytes_per_image: Option<u64>,
847    pub max_total_bytes: Option<u64>,
848    pub max_pixels_per_image: Option<u64>,
849    pub max_total_pixels: Option<u64>,
850}
851
852#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
853impl EmbeddedImageLimit {
854    pub const fn new(
855        max_bytes_per_image: Option<u64>,
856        max_total_bytes: Option<u64>,
857        max_pixels_per_image: Option<u64>,
858        max_total_pixels: Option<u64>,
859    ) -> Self {
860        Self {
861            max_bytes_per_image,
862            max_total_bytes,
863            max_pixels_per_image,
864            max_total_pixels,
865        }
866    }
867
868    pub const fn default_safe() -> Self {
869        Self {
870            max_bytes_per_image: Some(DEFAULT_MAX_EMBEDDED_IMAGE_BYTES),
871            max_total_bytes: Some(DEFAULT_MAX_TOTAL_EMBEDDED_IMAGE_BYTES),
872            max_pixels_per_image: Some(DEFAULT_MAX_DECODED_IMAGE_PIXELS),
873            max_total_pixels: Some(DEFAULT_MAX_TOTAL_DECODED_IMAGE_PIXELS),
874        }
875    }
876
877    pub const fn unbounded() -> Self {
878        Self {
879            max_bytes_per_image: None,
880            max_total_bytes: None,
881            max_pixels_per_image: None,
882            max_total_pixels: None,
883        }
884    }
885}
886
887#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
888impl Default for EmbeddedImageLimit {
889    fn default() -> Self {
890        Self::default_safe()
891    }
892}
893
894/// Host font behavior shared by native PNG, JPEG, and PDF exporters.
895#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
896#[derive(Debug, Clone, Copy, PartialEq, Eq)]
897#[non_exhaustive]
898pub struct SystemFontEnvironmentContract {
899    pub source_id: &'static str,
900    pub discovery: &'static str,
901    pub cache_scope: &'static str,
902    pub host_dependent: bool,
903    pub resource_bounded: bool,
904}
905
906/// Embedded-image behavior shared by the native PNG, JPEG, and PDF exporters.
907#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
908#[derive(Debug, Clone, Copy, PartialEq, Eq)]
909#[non_exhaustive]
910pub struct EmbeddedImageEnvironmentContract {
911    pub source_ids: &'static [&'static str],
912    pub filesystem_access: bool,
913    pub network_access: bool,
914    pub default_limits: EmbeddedImageLimit,
915}
916
917/// Runtime environment facts owned by a compiled export backend.
918#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
919#[derive(Debug, Clone, Copy, PartialEq, Eq)]
920#[non_exhaustive]
921pub struct ExportEnvironmentContract {
922    /// System font discovery used by this target, or `None` when the target cannot discover fonts.
923    pub system_fonts: Option<SystemFontEnvironmentContract>,
924    pub embedded_images: EmbeddedImageEnvironmentContract,
925}
926
927/// Returns the runtime environment contract for a compiled native export output.
928///
929/// SVG and ASCII do not pass through this exporter and therefore return `None`.
930#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
931#[must_use]
932pub fn output_environment_contract(output_id: &str) -> Option<ExportEnvironmentContract> {
933    let compiled = match output_id {
934        #[cfg(feature = "png")]
935        "png" => true,
936        #[cfg(feature = "jpeg")]
937        "jpeg" => true,
938        #[cfg(feature = "pdf")]
939        "pdf" => true,
940        _ => false,
941    };
942    compiled.then_some(ExportEnvironmentContract {
943        system_fonts: cfg!(not(target_arch = "wasm32")).then_some(SystemFontEnvironmentContract {
944            source_id: "host-system",
945            discovery: "first-use",
946            cache_scope: "process-global",
947            host_dependent: true,
948            resource_bounded: false,
949        }),
950        embedded_images: EmbeddedImageEnvironmentContract {
951            source_ids: &["data-url"],
952            filesystem_access: false,
953            network_access: false,
954            default_limits: EmbeddedImageLimit::default_safe(),
955        },
956    })
957}
958
959/// Structural limits checked on the parsed usvg tree before resvg or krilla-svg recurse through
960/// it. These limits complement output-pixel and embedded-image budgets; they do not claim to be a
961/// byte-exact model of third-party allocator behavior.
962#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
963#[derive(Debug, Clone, Copy, PartialEq, Eq)]
964pub struct SvgConversionLimits {
965    pub max_isolation_depth: Option<usize>,
966    pub max_filter_primitives_per_filter: Option<usize>,
967    pub max_total_filter_primitives: Option<usize>,
968    pub max_subroots: Option<usize>,
969    pub max_nested_svg_images: Option<usize>,
970}
971
972#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
973impl SvgConversionLimits {
974    pub const fn default_safe() -> Self {
975        Self {
976            max_isolation_depth: Some(DEFAULT_MAX_SVG_ISOLATION_DEPTH),
977            max_filter_primitives_per_filter: Some(DEFAULT_MAX_FILTER_PRIMITIVES_PER_FILTER),
978            max_total_filter_primitives: Some(DEFAULT_MAX_TOTAL_FILTER_PRIMITIVES),
979            max_subroots: Some(DEFAULT_MAX_SVG_SUBROOTS),
980            max_nested_svg_images: Some(DEFAULT_MAX_NESTED_SVG_IMAGES),
981        }
982    }
983
984    pub const fn unbounded() -> Self {
985        Self {
986            max_isolation_depth: None,
987            max_filter_primitives_per_filter: None,
988            max_total_filter_primitives: None,
989            max_subroots: None,
990            max_nested_svg_images: None,
991        }
992    }
993}
994
995#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
996impl Default for SvgConversionLimits {
997    fn default() -> Self {
998        Self::default_safe()
999    }
1000}
1001
1002/// Controls how vector content is placed on a PDF page.
1003#[cfg(feature = "pdf")]
1004#[derive(Debug, Clone, Copy, PartialEq, Default)]
1005pub enum PdfPagePolicy {
1006    /// Use the SVG's intrinsic dimensions as the PDF page dimensions.
1007    #[default]
1008    FitSvg,
1009    /// Scale the SVG uniformly to fit a fixed page and center it without cropping.
1010    Fixed { width_pt: f32, height_pt: f32 },
1011    /// Match browser PDF sizing: constrain responsive SVG width in CSS pixels, then convert
1012    /// CSS pixels to PDF points at 96 CSS pixels per inch.
1013    FitCssWidth { max_width_px: f32 },
1014}
1015
1016/// Aggregate pixel budget for localized filter images retained in a vector PDF.
1017#[cfg(feature = "pdf")]
1018#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1019pub struct PdfFilterImageLimit {
1020    pub max_total_pixels: Option<u64>,
1021}
1022
1023#[cfg(feature = "pdf")]
1024impl PdfFilterImageLimit {
1025    pub const fn new(max_total_pixels: Option<u64>) -> Self {
1026        Self { max_total_pixels }
1027    }
1028
1029    pub const fn default_safe() -> Self {
1030        Self {
1031            max_total_pixels: Some(DEFAULT_MAX_PDF_FILTER_IMAGE_PIXELS),
1032        }
1033    }
1034
1035    pub const fn unbounded() -> Self {
1036        Self {
1037            max_total_pixels: None,
1038        }
1039    }
1040}
1041
1042#[cfg(feature = "pdf")]
1043impl Default for PdfFilterImageLimit {
1044    fn default() -> Self {
1045        Self::default_safe()
1046    }
1047}
1048
1049/// Vector PDF conversion options, intentionally separate from pixel allocation limits.
1050#[cfg(feature = "pdf")]
1051#[derive(Debug, Clone, PartialEq)]
1052pub struct PdfOptions {
1053    /// PDF page sizing and content placement policy.
1054    pub page_policy: PdfPagePolicy,
1055    /// Optional page background color.
1056    pub background: Option<String>,
1057    /// Requested sampling scale for SVG filters embedded in the PDF.
1058    pub filter_scale: f32,
1059    /// Aggregate budget for localized filter images retained in the PDF.
1060    pub filter_image_limit: PdfFilterImageLimit,
1061    /// Header-derived pixel budget for embedded PNG/JPEG/GIF/WebP images.
1062    pub embedded_image_limit: EmbeddedImageLimit,
1063    /// Structural budget shared with PNG/JPEG conversion.
1064    pub conversion_limits: SvgConversionLimits,
1065}
1066
1067#[cfg(feature = "pdf")]
1068impl Default for PdfOptions {
1069    fn default() -> Self {
1070        Self {
1071            page_policy: PdfPagePolicy::FitSvg,
1072            background: None,
1073            filter_scale: 4.0,
1074            filter_image_limit: PdfFilterImageLimit::default(),
1075            embedded_image_limit: EmbeddedImageLimit::default(),
1076            conversion_limits: SvgConversionLimits::default(),
1077        }
1078    }
1079}
1080
1081#[cfg(feature = "pdf")]
1082impl PdfOptions {
1083    pub fn with_page_policy(mut self, page_policy: PdfPagePolicy) -> Self {
1084        self.page_policy = page_policy;
1085        self
1086    }
1087
1088    pub fn with_background(mut self, background: impl Into<String>) -> Self {
1089        self.background = Some(background.into());
1090        self
1091    }
1092
1093    pub fn with_filter_scale(mut self, filter_scale: f32) -> Self {
1094        self.filter_scale = filter_scale;
1095        self
1096    }
1097
1098    pub fn with_filter_image_limit(mut self, filter_image_limit: PdfFilterImageLimit) -> Self {
1099        self.filter_image_limit = filter_image_limit;
1100        self
1101    }
1102
1103    pub fn with_unbounded_filter_images(mut self) -> Self {
1104        self.filter_image_limit = PdfFilterImageLimit::unbounded();
1105        self
1106    }
1107
1108    pub fn with_embedded_image_limit(mut self, embedded_image_limit: EmbeddedImageLimit) -> Self {
1109        self.embedded_image_limit = embedded_image_limit;
1110        self
1111    }
1112
1113    pub fn with_conversion_limits(mut self, conversion_limits: SvgConversionLimits) -> Self {
1114        self.conversion_limits = conversion_limits;
1115        self
1116    }
1117}
1118
1119#[cfg(any(feature = "png", feature = "jpeg"))]
1120impl Default for RasterOptions {
1121    fn default() -> Self {
1122        Self {
1123            scale: 1.0,
1124            background: None,
1125            jpeg_quality: 90,
1126            fit_to: None,
1127            size_limit: RasterSizeLimit::default(),
1128            embedded_image_limit: EmbeddedImageLimit::default(),
1129            conversion_limits: SvgConversionLimits::default(),
1130        }
1131    }
1132}
1133
1134#[cfg(any(feature = "png", feature = "jpeg"))]
1135impl RasterOptions {
1136    pub fn with_scale(mut self, scale: f32) -> Self {
1137        self.scale = scale;
1138        self
1139    }
1140
1141    pub fn with_background(mut self, background: impl Into<String>) -> Self {
1142        self.background = Some(background.into());
1143        self
1144    }
1145
1146    pub fn with_fit_to(mut self, fit_to: RasterFitBox) -> Self {
1147        self.fit_to = Some(fit_to);
1148        self
1149    }
1150
1151    pub fn with_size_limit(mut self, size_limit: RasterSizeLimit) -> Self {
1152        self.size_limit = size_limit;
1153        self
1154    }
1155
1156    pub fn with_unbounded_size(mut self) -> Self {
1157        self.size_limit = RasterSizeLimit::unbounded();
1158        self
1159    }
1160
1161    pub fn with_embedded_image_limit(mut self, embedded_image_limit: EmbeddedImageLimit) -> Self {
1162        self.embedded_image_limit = embedded_image_limit;
1163        self
1164    }
1165
1166    pub fn with_conversion_limits(mut self, conversion_limits: SvgConversionLimits) -> Self {
1167        self.conversion_limits = conversion_limits;
1168        self
1169    }
1170}
1171
1172#[cfg(any(feature = "png", feature = "jpeg"))]
1173#[derive(Debug, Clone, Copy, PartialEq)]
1174pub struct RasterPlan {
1175    pub requested_width_px: f64,
1176    pub requested_height_px: f64,
1177    pub width_px: u32,
1178    pub height_px: u32,
1179    pub requested_scale: f64,
1180    pub effective_scale: f64,
1181    pub limited: bool,
1182}
1183
1184/// Plan for localized SVG filter images embedded in a vector PDF.
1185#[cfg(feature = "pdf")]
1186#[derive(Debug, Clone, Copy, PartialEq)]
1187pub struct PdfFilterImagePlan {
1188    pub filtered_groups: usize,
1189    pub requested_scale: f32,
1190    pub effective_scale: f32,
1191    pub requested_image_pixels: u64,
1192    pub effective_image_pixels: u64,
1193    pub limited: bool,
1194}
1195
1196/// Preflight facts for embedded image resources and decoded raster images.
1197#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
1198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1199pub struct EmbeddedImagePlan {
1200    pub data_resources: usize,
1201    pub raster_images: usize,
1202    pub largest_data_bytes: u64,
1203    pub total_data_bytes: u64,
1204    pub largest_raster_pixels: u64,
1205    pub total_pixels: u64,
1206}
1207
1208/// Structural work discovered in the usvg tree before a recursive backend is entered.
1209#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
1210#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1211pub struct SvgConversionPlan {
1212    /// Total resolved usvg nodes traversed before backend conversion.
1213    pub tree_nodes: usize,
1214    /// Maximum resolved `usvg` group depth observed before backend conversion.
1215    pub max_tree_depth: usize,
1216    pub max_isolation_depth: usize,
1217    pub filtered_groups: usize,
1218    pub filter_primitives: usize,
1219    pub subroots: usize,
1220    pub nested_svg_images: usize,
1221}
1222
1223/// Parsed raster input shared by sizing, memory scheduling, and image encoding.
1224#[cfg(any(feature = "png", feature = "jpeg"))]
1225pub struct PreparedRaster {
1226    tree: usvg::Tree,
1227    geometry: RasterGeometry,
1228    translate_min_to_origin: bool,
1229    plan: RasterPlan,
1230    embedded_image_plan: EmbeddedImagePlan,
1231    conversion_plan: SvgConversionPlan,
1232    options: RasterOptions,
1233    control: OperationControl,
1234}
1235
1236#[cfg(any(feature = "png", feature = "jpeg"))]
1237impl PreparedRaster {
1238    /// Returns the allocation plan computed before any output pixmap is created.
1239    pub const fn plan(&self) -> RasterPlan {
1240        self.plan
1241    }
1242
1243    /// Returns the embedded raster image plan computed from image headers.
1244    pub const fn embedded_image_plan(&self) -> EmbeddedImagePlan {
1245        self.embedded_image_plan
1246    }
1247
1248    pub const fn conversion_plan(&self) -> SvgConversionPlan {
1249        self.conversion_plan
1250    }
1251
1252    /// Returns an advisory weight for scheduling parallel PNG jobs.
1253    ///
1254    /// This is not a hard memory bound for resvg internals.
1255    pub fn png_scheduling_weight_bytes(&self) -> u64 {
1256        encoding_scheduling_weight_bytes(self.plan, self.embedded_image_plan, 8)
1257    }
1258
1259    /// Returns an advisory weight for scheduling parallel JPEG jobs.
1260    ///
1261    /// This is not a hard memory bound for resvg internals.
1262    pub fn jpeg_scheduling_weight_bytes(&self) -> u64 {
1263        encoding_scheduling_weight_bytes(self.plan, self.embedded_image_plan, 10)
1264    }
1265
1266    /// Allocates and encodes the prepared image as PNG.
1267    #[cfg(feature = "png")]
1268    pub fn encode_png(self) -> Result<Vec<u8>> {
1269        let control = self.control.clone();
1270        run_recursive_svg_backend(&control, move |control| {
1271            export_checkpoint(control)?;
1272            let pixmap = self.into_pixmap(control)?;
1273            export_checkpoint(control)?;
1274            let bytes = pixmap.encode_png().map_err(|_| ExportError::PngEncode);
1275            export_checkpoint(control)?;
1276            bytes
1277        })
1278    }
1279
1280    /// Allocates and encodes the prepared image as JPEG.
1281    #[cfg(feature = "jpeg")]
1282    pub fn encode_jpeg(mut self) -> Result<Vec<u8>> {
1283        let control = self.control.clone();
1284        run_recursive_svg_backend(&control, move |control| {
1285            export_checkpoint(control)?;
1286            if self.plan.width_px > u32::from(u16::MAX) || self.plan.height_px > u32::from(u16::MAX)
1287            {
1288                return Err(ExportError::JpegDimensionLimit);
1289            }
1290            let bg = self.options.background.as_deref().unwrap_or("white");
1291            let Some(color) = parse_tiny_skia_color(bg) else {
1292                return Err(ExportError::JpegBackground);
1293            };
1294            if color.alpha() != 1.0 {
1295                return Err(ExportError::JpegOpaqueBackgroundRequired);
1296            }
1297
1298            self.options.background = Some(bg.to_string());
1299            let quality = self.options.jpeg_quality;
1300            let pixmap = self.into_pixmap(control)?;
1301            export_checkpoint(control)?;
1302            let (w, h) = (pixmap.width(), pixmap.height());
1303            let rgba = pixmap.data();
1304            let mut rgb = vec![0u8; (w as usize) * (h as usize) * 3];
1305            for (src, dst) in rgba.chunks_exact(4).zip(rgb.chunks_exact_mut(3)) {
1306                export_checkpoint(control)?;
1307                dst[0] = src[0];
1308                dst[1] = src[1];
1309                dst[2] = src[2];
1310            }
1311
1312            let mut out = Vec::new();
1313            let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality);
1314            export_checkpoint(control)?;
1315            let encoded = enc
1316                .encode(&rgb, w, h, image::ExtendedColorType::Rgb8)
1317                .map_err(|_| ExportError::JpegEncode);
1318            export_checkpoint(control)?;
1319            encoded?;
1320            Ok(out)
1321        })
1322    }
1323
1324    fn into_pixmap(self, control: &OperationControl) -> Result<tiny_skia::Pixmap> {
1325        export_checkpoint(control)?;
1326        let mut pixmap = tiny_skia::Pixmap::new(self.plan.width_px, self.plan.height_px)
1327            .ok_or(ExportError::PixmapAlloc)?;
1328
1329        if let Some(bg) = self.options.background.as_deref()
1330            && let Some(color) = parse_tiny_skia_color(bg)
1331        {
1332            pixmap.fill(color);
1333        }
1334
1335        let scale = self.plan.effective_scale as f32;
1336        let transform = if self.translate_min_to_origin {
1337            tiny_skia::Transform::from_row(
1338                scale,
1339                0.0,
1340                0.0,
1341                scale,
1342                -self.geometry.min_x * scale,
1343                -self.geometry.min_y * scale,
1344            )
1345        } else {
1346            tiny_skia::Transform::from_scale(scale, scale)
1347        };
1348
1349        // resvg is one opaque synchronous backend call. Cooperative cancellation is observed at
1350        // its boundaries; hosts that need hard preemption must isolate the worker or process.
1351        export_checkpoint(control)?;
1352        resvg::render(&self.tree, transform, &mut pixmap.as_mut());
1353        export_checkpoint(control)?;
1354        Ok(pixmap)
1355    }
1356}
1357
1358/// Parsed vector PDF input shared by page planning, memory scheduling, and encoding.
1359#[cfg(feature = "pdf")]
1360pub struct PreparedPdf {
1361    tree: usvg::Tree,
1362    options: PdfOptions,
1363    filter_plan: PdfFilterImagePlan,
1364    embedded_image_plan: EmbeddedImagePlan,
1365    conversion_plan: SvgConversionPlan,
1366    control: OperationControl,
1367}
1368
1369#[cfg(feature = "pdf")]
1370impl PreparedPdf {
1371    /// Returns the localized filter allocation plan computed before PDF encoding.
1372    pub const fn filter_plan(&self) -> PdfFilterImagePlan {
1373        self.filter_plan
1374    }
1375
1376    /// Returns the embedded raster image plan computed from image headers.
1377    pub const fn embedded_image_plan(&self) -> EmbeddedImagePlan {
1378        self.embedded_image_plan
1379    }
1380
1381    pub const fn conversion_plan(&self) -> SvgConversionPlan {
1382        self.conversion_plan
1383    }
1384
1385    /// Returns an advisory weight for scheduling parallel PDF jobs.
1386    ///
1387    /// This is not a hard memory bound for krilla-svg or resvg internals.
1388    pub fn scheduling_weight_bytes(&self) -> u64 {
1389        const PDF_ENCODER_OVERHEAD_BYTES: u64 = 1024 * 1024;
1390        self.filter_plan
1391            .effective_image_pixels
1392            .saturating_mul(8)
1393            .saturating_add(self.embedded_image_plan.total_pixels.saturating_mul(8))
1394            .saturating_add(PDF_ENCODER_OVERHEAD_BYTES)
1395            .saturating_add(RECURSIVE_SVG_BACKEND_STACK_BYTES as u64)
1396    }
1397
1398    /// Encodes the prepared tree as vector PDF, rasterizing only SVG filter regions.
1399    pub fn encode(self) -> Result<Vec<u8>> {
1400        let control = self.control.clone();
1401        run_recursive_svg_backend(&control, move |control| {
1402            svg_tree_to_pdf(&self.tree, &self.options, control)
1403        })
1404    }
1405}
1406
1407/// Parses a sealed SVG once and prepares its bounded raster allocation plan.
1408#[cfg(any(feature = "png", feature = "jpeg"))]
1409pub fn prepare_raster(svg: &ResvgCompatibleSvg, options: &RasterOptions) -> Result<PreparedRaster> {
1410    prepare_raster_controlled(svg, options, OperationControl::new())
1411}
1412
1413/// Parses a sealed SVG using caller-owned cooperative cancellation/deadline state.
1414#[cfg(any(feature = "png", feature = "jpeg"))]
1415pub fn prepare_raster_controlled(
1416    svg: &ResvgCompatibleSvg,
1417    options: &RasterOptions,
1418    control: OperationControl,
1419) -> Result<PreparedRaster> {
1420    export_checkpoint(&control)?;
1421    let source = svg.as_str().to_owned();
1422    let reference_plan = svg.reference_plan().clone();
1423    let options = options.clone();
1424    let backend_control = control.clone();
1425    run_recursive_svg_backend(&control, move |control| {
1426        prepare_raster_on_backend_stack(
1427            &source,
1428            reference_plan.raw_element_occurrences(),
1429            &options,
1430            control,
1431        )
1432        .map(|mut prepared| {
1433            prepared.control = backend_control;
1434            prepared
1435        })
1436    })
1437}
1438
1439#[cfg(any(feature = "png", feature = "jpeg"))]
1440fn prepare_raster_on_backend_stack(
1441    source: &str,
1442    raw_element_occurrences: &[usize],
1443    options: &RasterOptions,
1444    control: &OperationControl,
1445) -> Result<PreparedRaster> {
1446    export_checkpoint(control)?;
1447    let root_metadata = parse_root_svg_metadata(source, control)?;
1448    let mut usvg_options = usvg::Options::default();
1449    configure_usvg_options_for_raster(&mut usvg_options, root_metadata);
1450    let data_plan = plan_embedded_data_resources_with_occurrences(
1451        source,
1452        raw_element_occurrences,
1453        options.embedded_image_limit,
1454        control,
1455    )?;
1456    export_checkpoint(control)?;
1457    let tree = usvg::Tree::from_str(source, &usvg_options).map_err(|_| ExportError::SvgParse);
1458    export_checkpoint(control)?;
1459    let tree = tree?;
1460    let conversion_plan = plan_svg_conversion(&tree, options.conversion_limits, control)?;
1461    let embedded_image_plan =
1462        plan_embedded_images(&tree, options.embedded_image_limit, data_plan, control)?;
1463    let (geometry, translate_min_to_origin) = raster_geometry_for_svg(root_metadata, &tree);
1464    let plan = raster_plan_for_geometry(geometry, options, control)?;
1465    export_checkpoint(control)?;
1466
1467    Ok(PreparedRaster {
1468        tree,
1469        geometry,
1470        translate_min_to_origin,
1471        plan,
1472        embedded_image_plan,
1473        conversion_plan,
1474        options: options.clone(),
1475        control: control.clone(),
1476    })
1477}
1478
1479/// Parses a sealed SVG once and prepares vector PDF page and filter allocation policy.
1480#[cfg(feature = "pdf")]
1481pub fn prepare_pdf(svg: &ResvgCompatibleSvg, options: &PdfOptions) -> Result<PreparedPdf> {
1482    prepare_pdf_controlled(svg, options, OperationControl::new())
1483}
1484
1485/// Parses a sealed SVG for PDF using caller-owned cooperative cancellation/deadline state.
1486#[cfg(feature = "pdf")]
1487pub fn prepare_pdf_controlled(
1488    svg: &ResvgCompatibleSvg,
1489    options: &PdfOptions,
1490    control: OperationControl,
1491) -> Result<PreparedPdf> {
1492    export_checkpoint(&control)?;
1493    let source = svg.as_str().to_owned();
1494    let reference_plan = svg.reference_plan().clone();
1495    let options = options.clone();
1496    let backend_control = control.clone();
1497    run_recursive_svg_backend(&control, move |control| {
1498        prepare_pdf_on_backend_stack(
1499            &source,
1500            reference_plan.raw_element_occurrences(),
1501            &options,
1502            control,
1503        )
1504        .map(|mut prepared| {
1505            prepared.control = backend_control;
1506            prepared
1507        })
1508    })
1509}
1510
1511#[cfg(feature = "pdf")]
1512fn prepare_pdf_on_backend_stack(
1513    source: &str,
1514    raw_element_occurrences: &[usize],
1515    options: &PdfOptions,
1516    control: &OperationControl,
1517) -> Result<PreparedPdf> {
1518    export_checkpoint(control)?;
1519    validate_pdf_options(options)?;
1520    let data_plan = plan_embedded_data_resources_with_occurrences(
1521        source,
1522        raw_element_occurrences,
1523        options.embedded_image_limit,
1524        control,
1525    )?;
1526    let tree = parse_pdf_tree(source, control)?;
1527    let conversion_plan = plan_svg_conversion(&tree, options.conversion_limits, control)?;
1528    let embedded_image_plan =
1529        plan_embedded_images(&tree, options.embedded_image_limit, data_plan, control)?;
1530    let svg_size = pdf_svg_size(&tree)?;
1531    let layout = pdf_page_layout(svg_size, options.page_policy)?;
1532    let filter_plan = plan_pdf_filter_images(
1533        &tree,
1534        layout.drawing_size.width() / svg_size.width(),
1535        options.filter_scale,
1536        options.filter_image_limit,
1537        control,
1538    )?;
1539    let mut effective_options = options.clone();
1540    effective_options.filter_scale = filter_plan.effective_scale;
1541
1542    Ok(PreparedPdf {
1543        tree,
1544        options: effective_options,
1545        filter_plan,
1546        embedded_image_plan,
1547        conversion_plan,
1548        control: control.clone(),
1549    })
1550}
1551
1552#[cfg(feature = "pdf")]
1553fn validate_pdf_options(options: &PdfOptions) -> Result<()> {
1554    if !(options.filter_scale.is_finite() && options.filter_scale > 0.0) {
1555        return Err(ExportError::InvalidSizing(
1556            "PDF filter_scale must be finite and positive",
1557        ));
1558    }
1559    if options.filter_image_limit.max_total_pixels == Some(0) {
1560        return Err(ExportError::InvalidSizing(
1561            "PDF filter max_total_pixels must be positive",
1562        ));
1563    }
1564    validate_embedded_image_limit(options.embedded_image_limit)?;
1565    Ok(())
1566}
1567
1568#[cfg(any(feature = "png", feature = "jpeg"))]
1569fn encoding_scheduling_weight_bytes(
1570    plan: RasterPlan,
1571    embedded_images: EmbeddedImagePlan,
1572    bytes_per_pixel: u64,
1573) -> u64 {
1574    const ENCODER_OVERHEAD_BYTES: u64 = 1024 * 1024;
1575    u64::from(plan.width_px)
1576        .saturating_mul(u64::from(plan.height_px))
1577        .saturating_mul(bytes_per_pixel)
1578        .saturating_add(embedded_images.total_pixels.saturating_mul(8))
1579        .saturating_add(ENCODER_OVERHEAD_BYTES)
1580        .saturating_add(RECURSIVE_SVG_BACKEND_STACK_BYTES as u64)
1581}
1582
1583#[cfg(feature = "png")]
1584pub fn svg_to_png(svg: &ResvgCompatibleSvg, options: &RasterOptions) -> Result<Vec<u8>> {
1585    svg_to_png_controlled(svg, options, OperationControl::new())
1586}
1587
1588/// Encodes a sealed SVG as PNG using caller-owned cooperative cancellation/deadline state.
1589#[cfg(feature = "png")]
1590pub fn svg_to_png_controlled(
1591    svg: &ResvgCompatibleSvg,
1592    options: &RasterOptions,
1593    control: OperationControl,
1594) -> Result<Vec<u8>> {
1595    prepare_raster_controlled(svg, options, control)?.encode_png()
1596}
1597
1598/// Encodes a sealed SVG as PNG and returns the allocation plan used for the output pixmap.
1599#[cfg(feature = "png")]
1600pub fn svg_to_png_with_plan_controlled(
1601    svg: &ResvgCompatibleSvg,
1602    options: &RasterOptions,
1603    control: OperationControl,
1604) -> Result<(Vec<u8>, RasterPlan)> {
1605    let prepared = prepare_raster_controlled(svg, options, control)?;
1606    let plan = prepared.plan();
1607    let bytes = prepared.encode_png()?;
1608    Ok((bytes, plan))
1609}
1610
1611/// Encodes a sealed SVG as PNG and returns its allocation plan using a fresh control.
1612#[cfg(feature = "png")]
1613pub fn svg_to_png_with_plan(
1614    svg: &ResvgCompatibleSvg,
1615    options: &RasterOptions,
1616) -> Result<(Vec<u8>, RasterPlan)> {
1617    svg_to_png_with_plan_controlled(svg, options, OperationControl::new())
1618}
1619
1620#[cfg(feature = "jpeg")]
1621pub fn svg_to_jpeg(svg: &ResvgCompatibleSvg, options: &RasterOptions) -> Result<Vec<u8>> {
1622    svg_to_jpeg_controlled(svg, options, OperationControl::new())
1623}
1624
1625/// Encodes a sealed SVG as JPEG using caller-owned cooperative cancellation/deadline state.
1626#[cfg(feature = "jpeg")]
1627pub fn svg_to_jpeg_controlled(
1628    svg: &ResvgCompatibleSvg,
1629    options: &RasterOptions,
1630    control: OperationControl,
1631) -> Result<Vec<u8>> {
1632    prepare_raster_controlled(svg, options, control)?.encode_jpeg()
1633}
1634
1635/// Encodes a sealed SVG as JPEG and returns the allocation plan used for the output pixmap.
1636#[cfg(feature = "jpeg")]
1637pub fn svg_to_jpeg_with_plan_controlled(
1638    svg: &ResvgCompatibleSvg,
1639    options: &RasterOptions,
1640    control: OperationControl,
1641) -> Result<(Vec<u8>, RasterPlan)> {
1642    let prepared = prepare_raster_controlled(svg, options, control)?;
1643    let plan = prepared.plan();
1644    let bytes = prepared.encode_jpeg()?;
1645    Ok((bytes, plan))
1646}
1647
1648/// Encodes a sealed SVG as JPEG and returns its allocation plan using a fresh control.
1649#[cfg(feature = "jpeg")]
1650pub fn svg_to_jpeg_with_plan(
1651    svg: &ResvgCompatibleSvg,
1652    options: &RasterOptions,
1653) -> Result<(Vec<u8>, RasterPlan)> {
1654    svg_to_jpeg_with_plan_controlled(svg, options, OperationControl::new())
1655}
1656
1657#[cfg(any(feature = "png", feature = "jpeg"))]
1658pub fn svg_raster_plan(svg: &ResvgCompatibleSvg, options: &RasterOptions) -> Result<RasterPlan> {
1659    svg_raster_plan_controlled(svg, options, OperationControl::new())
1660}
1661
1662/// Computes a sealed SVG raster plan using caller-owned cooperative cancellation/deadline state.
1663#[cfg(any(feature = "png", feature = "jpeg"))]
1664pub fn svg_raster_plan_controlled(
1665    svg: &ResvgCompatibleSvg,
1666    options: &RasterOptions,
1667    control: OperationControl,
1668) -> Result<RasterPlan> {
1669    Ok(prepare_raster_controlled(svg, options, control)?.plan())
1670}
1671
1672#[cfg(feature = "pdf")]
1673pub fn svg_to_pdf(svg: &ResvgCompatibleSvg) -> Result<Vec<u8>> {
1674    svg_to_pdf_controlled(svg, &PdfOptions::default(), OperationControl::new())
1675}
1676
1677#[cfg(feature = "pdf")]
1678pub fn svg_to_pdf_with_options(svg: &ResvgCompatibleSvg, options: &PdfOptions) -> Result<Vec<u8>> {
1679    svg_to_pdf_controlled(svg, options, OperationControl::new())
1680}
1681
1682/// Encodes a sealed SVG as PDF using caller-owned cooperative cancellation/deadline state.
1683#[cfg(feature = "pdf")]
1684pub fn svg_to_pdf_controlled(
1685    svg: &ResvgCompatibleSvg,
1686    options: &PdfOptions,
1687    control: OperationControl,
1688) -> Result<Vec<u8>> {
1689    prepare_pdf_controlled(svg, options, control)?.encode()
1690}
1691
1692/// Encodes a sealed SVG as PDF and returns the filter-image plan used by the encoder.
1693#[cfg(feature = "pdf")]
1694pub fn svg_to_pdf_with_plan_controlled(
1695    svg: &ResvgCompatibleSvg,
1696    options: &PdfOptions,
1697    control: OperationControl,
1698) -> Result<(Vec<u8>, PdfFilterImagePlan)> {
1699    let prepared = prepare_pdf_controlled(svg, options, control)?;
1700    let plan = prepared.filter_plan();
1701    let bytes = prepared.encode()?;
1702    Ok((bytes, plan))
1703}
1704
1705/// Encodes a sealed SVG as PDF and returns its filter-image plan using a fresh control.
1706#[cfg(feature = "pdf")]
1707pub fn svg_to_pdf_with_plan(
1708    svg: &ResvgCompatibleSvg,
1709    options: &PdfOptions,
1710) -> Result<(Vec<u8>, PdfFilterImagePlan)> {
1711    svg_to_pdf_with_plan_controlled(svg, options, OperationControl::new())
1712}
1713
1714#[cfg(feature = "pdf")]
1715fn parse_pdf_tree(svg: &str, control: &OperationControl) -> Result<usvg::Tree> {
1716    export_checkpoint(control)?;
1717    let mut opts = usvg::Options::default();
1718    configure_usvg_options_for_pdf(&mut opts);
1719    let tree = usvg::Tree::from_str(svg, &opts).map_err(|_| ExportError::SvgParse);
1720    export_checkpoint(control)?;
1721    tree
1722}
1723
1724#[cfg(feature = "pdf")]
1725fn svg_tree_to_pdf(
1726    svg_tree: &usvg::Tree,
1727    options: &PdfOptions,
1728    control: &OperationControl,
1729) -> Result<Vec<u8>> {
1730    use krilla_svg::SurfaceExt;
1731
1732    export_checkpoint(control)?;
1733    let svg_size = pdf_svg_size(svg_tree)?;
1734    let layout = pdf_page_layout(svg_size, options.page_policy)?;
1735
1736    let mut document = krilla::Document::new();
1737    let mut page = document.start_page_with(krilla::page::PageSettings::new(layout.page_size));
1738    let mut surface = page.surface();
1739    draw_pdf_background(
1740        &mut surface,
1741        layout.page_size,
1742        options.background.as_deref(),
1743    );
1744    if layout.offset.0 != 0.0 || layout.offset.1 != 0.0 {
1745        surface.push_transform(&krilla::geom::Transform::from_translate(
1746            layout.offset.0,
1747            layout.offset.1,
1748        ));
1749    }
1750    // krilla-svg performs one opaque synchronous draw. Cooperative cancellation is observed at
1751    // the call boundaries; hard interruption requires host-level worker/process isolation.
1752    export_checkpoint(control)?;
1753    surface.draw_svg(
1754        svg_tree,
1755        layout.drawing_size,
1756        krilla_svg::SvgSettings {
1757            filter_scale: options.filter_scale,
1758            ..krilla_svg::SvgSettings::default()
1759        },
1760    );
1761    export_checkpoint(control)?;
1762    if layout.offset.0 != 0.0 || layout.offset.1 != 0.0 {
1763        surface.pop();
1764    }
1765    surface.finish();
1766    page.finish();
1767
1768    export_checkpoint(control)?;
1769    let pdf = document.finish().map_err(|_| ExportError::PdfConvert);
1770    export_checkpoint(control)?;
1771    pdf
1772}
1773
1774#[cfg(feature = "pdf")]
1775fn pdf_svg_size(svg_tree: &usvg::Tree) -> Result<krilla::geom::Size> {
1776    krilla::geom::Size::from_wh(svg_tree.size().width(), svg_tree.size().height())
1777        .ok_or(ExportError::SvgDocSize)
1778}
1779
1780#[cfg(feature = "pdf")]
1781#[derive(Clone, Copy)]
1782struct PdfPageLayout {
1783    page_size: krilla::geom::Size,
1784    drawing_size: krilla::geom::Size,
1785    offset: (f32, f32),
1786}
1787
1788#[cfg(feature = "pdf")]
1789fn pdf_page_layout(
1790    svg_size: krilla::geom::Size,
1791    page_policy: PdfPagePolicy,
1792) -> Result<PdfPageLayout> {
1793    match page_policy {
1794        PdfPagePolicy::FitSvg => Ok(PdfPageLayout {
1795            page_size: svg_size,
1796            drawing_size: svg_size,
1797            offset: (0.0, 0.0),
1798        }),
1799        PdfPagePolicy::Fixed {
1800            width_pt,
1801            height_pt,
1802        } => {
1803            let Some(page_size) = krilla::geom::Size::from_wh(width_pt, height_pt) else {
1804                return Err(ExportError::InvalidSizing(
1805                    "fixed PDF page dimensions must be finite and positive",
1806                ));
1807            };
1808            let scale = (width_pt / svg_size.width()).min(height_pt / svg_size.height());
1809            let Some(drawing_size) =
1810                krilla::geom::Size::from_wh(svg_size.width() * scale, svg_size.height() * scale)
1811            else {
1812                return Err(ExportError::SvgDocSize);
1813            };
1814            let offset = (
1815                (width_pt - drawing_size.width()) / 2.0,
1816                (height_pt - drawing_size.height()) / 2.0,
1817            );
1818            Ok(PdfPageLayout {
1819                page_size,
1820                drawing_size,
1821                offset,
1822            })
1823        }
1824        PdfPagePolicy::FitCssWidth { max_width_px } => {
1825            if !(max_width_px.is_finite() && max_width_px > 0.0) {
1826                return Err(ExportError::InvalidSizing(
1827                    "PDF CSS viewport width must be finite and positive",
1828                ));
1829            }
1830            let displayed_width_px = svg_size.width().min(max_width_px);
1831            let page_width_pt = displayed_width_px * PDF_POINTS_PER_CSS_PIXEL;
1832            let page_height_pt = svg_size.height() / svg_size.width() * page_width_pt;
1833            let Some(page_size) = krilla::geom::Size::from_wh(page_width_pt, page_height_pt) else {
1834                return Err(ExportError::SvgDocSize);
1835            };
1836            Ok(PdfPageLayout {
1837                page_size,
1838                drawing_size: page_size,
1839                offset: (0.0, 0.0),
1840            })
1841        }
1842    }
1843}
1844
1845#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
1846fn plan_svg_conversion(
1847    tree: &usvg::Tree,
1848    limits: SvgConversionLimits,
1849    control: &OperationControl,
1850) -> Result<SvgConversionPlan> {
1851    let mut plan = SvgConversionPlan::default();
1852    plan_svg_conversion_group(tree.root(), 0, 0, limits, &mut plan, control)?;
1853    Ok(plan)
1854}
1855
1856#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
1857fn plan_svg_conversion_group(
1858    root: &usvg::Group,
1859    parent_isolation_depth: usize,
1860    tree_depth: usize,
1861    limits: SvgConversionLimits,
1862    plan: &mut SvgConversionPlan,
1863    control: &OperationControl,
1864) -> Result<()> {
1865    let mut stack = vec![(root, parent_isolation_depth, tree_depth)];
1866    while let Some((group, parent_depth, tree_depth)) = stack.pop() {
1867        export_checkpoint(control)?;
1868        charge_svg_conversion_tree_node(plan)?;
1869        plan.max_tree_depth = plan.max_tree_depth.max(tree_depth);
1870        check_svg_conversion_limit(
1871            merman_render::resources::SVG_BACKEND_TREE_DEPTH_HARD_CAP_ID,
1872            plan.max_tree_depth,
1873            Some(merman_render::resources::MAX_RESVG_TREE_DEPTH),
1874        )?;
1875
1876        let isolation_depth = parent_depth.saturating_add(usize::from(group.should_isolate()));
1877        plan.max_isolation_depth = plan.max_isolation_depth.max(isolation_depth);
1878        check_svg_conversion_limit(
1879            "max_isolation_depth",
1880            plan.max_isolation_depth,
1881            limits.max_isolation_depth,
1882        )?;
1883
1884        if !group.filters().is_empty() {
1885            plan.filtered_groups = plan.filtered_groups.saturating_add(1);
1886        }
1887        for filter in group.filters() {
1888            export_checkpoint(control)?;
1889            let primitives = filter.primitives().len();
1890            check_svg_conversion_limit(
1891                "max_filter_primitives_per_filter",
1892                primitives,
1893                limits.max_filter_primitives_per_filter,
1894            )?;
1895            plan.filter_primitives = plan.filter_primitives.saturating_add(primitives);
1896            check_svg_conversion_limit(
1897                "max_total_filter_primitives",
1898                plan.filter_primitives,
1899                limits.max_total_filter_primitives,
1900            )?;
1901        }
1902
1903        for node in group.children() {
1904            export_checkpoint(control)?;
1905            if let usvg::Node::Group(child) = node {
1906                stack.push((child, isolation_depth, tree_depth.saturating_add(1)));
1907            } else {
1908                charge_svg_conversion_tree_node(plan)?;
1909            }
1910            if let usvg::Node::Image(image) = node
1911                && matches!(image.kind(), usvg::ImageKind::SVG(_))
1912            {
1913                plan.nested_svg_images = plan.nested_svg_images.saturating_add(1);
1914                check_svg_conversion_limit(
1915                    "max_nested_svg_images",
1916                    plan.nested_svg_images,
1917                    limits.max_nested_svg_images,
1918                )?;
1919            }
1920
1921            let mut subroot_result = Ok(());
1922            node.subroots(|subroot| {
1923                if subroot_result.is_err() {
1924                    return;
1925                }
1926                plan.subroots = plan.subroots.saturating_add(1);
1927                subroot_result =
1928                    check_svg_conversion_limit("max_subroots", plan.subroots, limits.max_subroots);
1929                if subroot_result.is_ok() {
1930                    subroot_result = plan_svg_conversion_group(
1931                        subroot,
1932                        isolation_depth.saturating_add(1),
1933                        tree_depth.saturating_add(1),
1934                        limits,
1935                        plan,
1936                        control,
1937                    );
1938                }
1939            });
1940            subroot_result?;
1941        }
1942    }
1943    Ok(())
1944}
1945
1946#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
1947fn charge_svg_conversion_tree_node(plan: &mut SvgConversionPlan) -> Result<()> {
1948    plan.tree_nodes = plan.tree_nodes.saturating_add(1);
1949    check_svg_conversion_limit(
1950        merman_render::resources::SVG_BACKEND_TREE_NODES_HARD_CAP_ID,
1951        plan.tree_nodes,
1952        Some(merman_render::resources::MAX_RESVG_TREE_NODES),
1953    )
1954}
1955
1956#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
1957fn check_svg_conversion_limit(
1958    limit_name: &'static str,
1959    actual: usize,
1960    max: Option<usize>,
1961) -> Result<()> {
1962    let Some(max) = max else {
1963        return Ok(());
1964    };
1965    if actual <= max {
1966        return Ok(());
1967    }
1968    Err(ExportError::SvgConversionLimit {
1969        limit_name,
1970        actual: actual as u64,
1971        max: max as u64,
1972    })
1973}
1974
1975#[cfg(feature = "pdf")]
1976fn plan_pdf_filter_images(
1977    tree: &usvg::Tree,
1978    page_scale: f32,
1979    requested_scale: f32,
1980    limit: PdfFilterImageLimit,
1981    control: &OperationControl,
1982) -> Result<PdfFilterImagePlan> {
1983    let filtered_groups = pdf_filtered_group_bounds(tree, control)?;
1984    let requested_pixels =
1985        pdf_filter_pixels(&filtered_groups, page_scale, requested_scale, control)?;
1986    let Some(max_pixels) = limit.max_total_pixels else {
1987        return Ok(PdfFilterImagePlan {
1988            filtered_groups: filtered_groups.len(),
1989            requested_scale,
1990            effective_scale: requested_scale,
1991            requested_image_pixels: requested_pixels,
1992            effective_image_pixels: requested_pixels,
1993            limited: false,
1994        });
1995    };
1996    if requested_pixels <= max_pixels {
1997        return Ok(PdfFilterImagePlan {
1998            filtered_groups: filtered_groups.len(),
1999            requested_scale,
2000            effective_scale: requested_scale,
2001            requested_image_pixels: requested_pixels,
2002            effective_image_pixels: requested_pixels,
2003            limited: false,
2004        });
2005    }
2006
2007    let mut accepted = 0.0_f32;
2008    let mut rejected = requested_scale;
2009    for _ in 0..48 {
2010        let candidate = accepted + (rejected - accepted) / 2.0;
2011        export_checkpoint(control)?;
2012        if pdf_filter_pixels(&filtered_groups, page_scale, candidate, control)? <= max_pixels {
2013            accepted = candidate;
2014        } else {
2015            rejected = candidate;
2016        }
2017    }
2018    if !(accepted.is_finite() && accepted > 0.0) {
2019        return Err(ExportError::PdfFilterImageLimit {
2020            actual: requested_pixels,
2021            max: max_pixels,
2022        });
2023    }
2024    let effective_pixels = pdf_filter_pixels(&filtered_groups, page_scale, accepted, control)?;
2025    Ok(PdfFilterImagePlan {
2026        filtered_groups: filtered_groups.len(),
2027        requested_scale,
2028        effective_scale: accepted,
2029        requested_image_pixels: requested_pixels,
2030        effective_image_pixels: effective_pixels,
2031        limited: true,
2032    })
2033}
2034
2035#[cfg(feature = "pdf")]
2036fn pdf_filtered_group_bounds(
2037    tree: &usvg::Tree,
2038    control: &OperationControl,
2039) -> Result<Vec<(f64, f64)>> {
2040    let mut bounds = Vec::new();
2041    let mut stack = vec![(tree.root(), 1.0_f64)];
2042    while let Some((group, coordinate_scale)) = stack.pop() {
2043        export_checkpoint(control)?;
2044        if !group.filters().is_empty() {
2045            let bbox = group.abs_layer_bounding_box();
2046            bounds.push((
2047                f64::from(bbox.width()) * coordinate_scale,
2048                f64::from(bbox.height()) * coordinate_scale,
2049            ));
2050            // krilla-svg rasterizes this whole group through resvg and returns. Descendant
2051            // filters contribute to that localized render, but they do not allocate a second
2052            // krilla-owned PDF image and must not be counted as independent top-level groups.
2053            continue;
2054        }
2055        for node in group.children() {
2056            export_checkpoint(control)?;
2057            match node {
2058                usvg::Node::Group(child) => stack.push((child, coordinate_scale)),
2059                usvg::Node::Image(image) => {
2060                    if let usvg::ImageKind::SVG(nested) = image.kind() {
2061                        let (scale_x, scale_y) = image.abs_transform().get_scale();
2062                        let image_scale = f64::from(scale_x.abs().max(scale_y.abs()));
2063                        stack.push((nested.root(), coordinate_scale * image_scale));
2064                    }
2065                }
2066                _ => {}
2067            }
2068        }
2069    }
2070    Ok(bounds)
2071}
2072
2073#[cfg(feature = "pdf")]
2074fn pdf_filter_pixels(
2075    bounds: &[(f64, f64)],
2076    page_scale: f32,
2077    filter_scale: f32,
2078    control: &OperationControl,
2079) -> Result<u64> {
2080    let scale = f64::from(page_scale) * f64::from(filter_scale);
2081    let mut total = 0_u64;
2082    for &(width, height) in bounds {
2083        export_checkpoint(control)?;
2084        let requested_width = width * scale;
2085        let requested_height = height * scale;
2086        let cap = (KRILLA_MAX_FILTER_SIDE_PX / requested_width)
2087            .min(KRILLA_MAX_FILTER_SIDE_PX / requested_height)
2088            .min(1.0);
2089        let width_px = (requested_width * cap).round().clamp(0.0, 5000.0) as u64;
2090        let height_px = (requested_height * cap).round().clamp(0.0, 5000.0) as u64;
2091        total = total.saturating_add(width_px.saturating_mul(height_px));
2092    }
2093    Ok(total)
2094}
2095
2096#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
2097#[derive(Debug, Clone, Copy, Default)]
2098struct EmbeddedDataPlan {
2099    resources: usize,
2100    largest_bytes: u64,
2101    total_bytes: u64,
2102}
2103
2104#[cfg(all(test, any(feature = "png", feature = "jpeg", feature = "pdf")))]
2105fn plan_embedded_data_resources(svg: &str, limit: EmbeddedImageLimit) -> Result<EmbeddedDataPlan> {
2106    plan_embedded_data_resources_with_occurrences(svg, &[], limit, &OperationControl::new())
2107}
2108
2109#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
2110fn plan_embedded_data_resources_with_occurrences(
2111    svg: &str,
2112    raw_element_occurrences: &[usize],
2113    limit: EmbeddedImageLimit,
2114    control: &OperationControl,
2115) -> Result<EmbeddedDataPlan> {
2116    use quick_xml::XmlVersion;
2117    use quick_xml::events::Event;
2118
2119    validate_embedded_image_limit(limit)?;
2120    let mut reader = quick_xml::Reader::from_str(svg);
2121    let mut plan = EmbeddedDataPlan::default();
2122    let mut element_index = 0usize;
2123    loop {
2124        export_checkpoint(control)?;
2125        let event = reader.read_event().map_err(|_| ExportError::SvgParse)?;
2126        let (element, occurrences) = match event {
2127            Event::Start(element) | Event::Empty(element) => {
2128                let occurrences = if raw_element_occurrences.is_empty() {
2129                    1
2130                } else {
2131                    raw_element_occurrences
2132                        .get(element_index)
2133                        .copied()
2134                        .ok_or(ExportError::SvgParse)?
2135                };
2136                element_index = element_index.saturating_add(1);
2137                if is_embedded_image_element(element.local_name().as_ref()) {
2138                    (element, occurrences)
2139                } else {
2140                    continue;
2141                }
2142            }
2143            Event::Eof => break,
2144            _ => continue,
2145        };
2146
2147        for attribute in element.attributes() {
2148            export_checkpoint(control)?;
2149            let attribute = attribute.map_err(|_| ExportError::SvgParse)?;
2150            if !attribute
2151                .key
2152                .local_name()
2153                .as_ref()
2154                .eq_ignore_ascii_case("href")
2155            {
2156                continue;
2157            }
2158            let value = attribute
2159                .normalized_value(XmlVersion::Implicit1_0)
2160                .map_err(|_| ExportError::SvgParse)?;
2161            let Ok(data_url) = data_url::DataUrl::process(value.as_ref()) else {
2162                continue;
2163            };
2164
2165            let mut resource_bytes = 0_u64;
2166            let mut limit_error = None;
2167            let occurrences = occurrences as u64;
2168            let _ = data_url.decode(|chunk| {
2169                if let Err(error) = export_checkpoint(control) {
2170                    limit_error = Some(error);
2171                    return Err(());
2172                }
2173                resource_bytes = resource_bytes.saturating_add(chunk.len() as u64);
2174                let aggregate = plan
2175                    .total_bytes
2176                    .saturating_add(resource_bytes.saturating_mul(occurrences));
2177                if let Some(max) = limit.max_bytes_per_image
2178                    && resource_bytes > max
2179                {
2180                    limit_error = Some(ExportError::EmbeddedImageLimit {
2181                        limit_name: "max_bytes_per_image",
2182                        actual: resource_bytes,
2183                        max,
2184                    });
2185                    return Err(());
2186                }
2187                if let Some(max) = limit.max_total_bytes
2188                    && aggregate > max
2189                {
2190                    limit_error = Some(ExportError::EmbeddedImageLimit {
2191                        limit_name: "max_total_bytes",
2192                        actual: aggregate,
2193                        max,
2194                    });
2195                    return Err(());
2196                }
2197                Ok(())
2198            });
2199            if let Some(error) = limit_error {
2200                return Err(error);
2201            }
2202
2203            plan.resources = plan.resources.saturating_add(occurrences as usize);
2204            plan.largest_bytes = plan.largest_bytes.max(resource_bytes);
2205            plan.total_bytes = plan
2206                .total_bytes
2207                .saturating_add(resource_bytes.saturating_mul(occurrences));
2208        }
2209    }
2210    if !raw_element_occurrences.is_empty() && element_index != raw_element_occurrences.len() {
2211        return Err(ExportError::SvgParse);
2212    }
2213    Ok(plan)
2214}
2215
2216#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
2217fn is_embedded_image_element(local_name: &str) -> bool {
2218    // usvg resolves both elements through the same image resolver. Match qualified-name aliases
2219    // conservatively so namespace spelling cannot turn the preflight into a false-negative gate.
2220    local_name.eq_ignore_ascii_case("image") || local_name.eq_ignore_ascii_case("feImage")
2221}
2222
2223#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
2224fn plan_embedded_images(
2225    tree: &usvg::Tree,
2226    limit: EmbeddedImageLimit,
2227    data: EmbeddedDataPlan,
2228    control: &OperationControl,
2229) -> Result<EmbeddedImagePlan> {
2230    validate_embedded_image_limit(limit)?;
2231    let mut plan = EmbeddedImagePlan {
2232        data_resources: data.resources,
2233        raster_images: 0,
2234        largest_data_bytes: data.largest_bytes,
2235        total_data_bytes: data.total_bytes,
2236        largest_raster_pixels: 0,
2237        total_pixels: 0,
2238    };
2239    plan_embedded_images_in_group(tree.root(), limit, &mut plan, control)?;
2240    Ok(plan)
2241}
2242
2243#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
2244fn plan_embedded_images_in_group(
2245    root: &usvg::Group,
2246    limit: EmbeddedImageLimit,
2247    plan: &mut EmbeddedImagePlan,
2248    control: &OperationControl,
2249) -> Result<()> {
2250    let mut stack = vec![root];
2251    while let Some(group) = stack.pop() {
2252        export_checkpoint(control)?;
2253        for node in group.children() {
2254            export_checkpoint(control)?;
2255            match node {
2256                usvg::Node::Group(child) => stack.push(child),
2257                usvg::Node::Image(image) => match image.kind() {
2258                    usvg::ImageKind::SVG(_) => {}
2259                    usvg::ImageKind::JPEG(_)
2260                    | usvg::ImageKind::PNG(_)
2261                    | usvg::ImageKind::GIF(_)
2262                    | usvg::ImageKind::WEBP(_) => {
2263                        let pixels = intrinsic_image_pixels(image.size())?;
2264                        plan.raster_images = plan.raster_images.saturating_add(1);
2265                        plan.largest_raster_pixels = plan.largest_raster_pixels.max(pixels);
2266                        plan.total_pixels = plan.total_pixels.saturating_add(pixels);
2267                        if let Some(max) = limit.max_pixels_per_image
2268                            && pixels > max
2269                        {
2270                            return Err(ExportError::EmbeddedImageLimit {
2271                                limit_name: "max_pixels_per_image",
2272                                actual: pixels,
2273                                max,
2274                            });
2275                        }
2276                        if let Some(max) = limit.max_total_pixels
2277                            && plan.total_pixels > max
2278                        {
2279                            return Err(ExportError::EmbeddedImageLimit {
2280                                limit_name: "max_total_pixels",
2281                                actual: plan.total_pixels,
2282                                max,
2283                            });
2284                        }
2285                    }
2286                },
2287                _ => {}
2288            }
2289
2290            // SVG images, clip paths, masks, patterns, and filter image primitives can own
2291            // additional renderable trees. Walk those subroots as well so a hidden definition
2292            // cannot bypass the decode budget.
2293            let mut subroot_result = Ok(());
2294            node.subroots(|subroot| {
2295                if subroot_result.is_ok() {
2296                    subroot_result = plan_embedded_images_in_group(subroot, limit, plan, control);
2297                }
2298            });
2299            subroot_result?;
2300        }
2301    }
2302    Ok(())
2303}
2304
2305#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
2306fn intrinsic_image_pixels(size: usvg::Size) -> Result<u64> {
2307    let width = f64::from(size.width()).ceil();
2308    let height = f64::from(size.height()).ceil();
2309    if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) {
2310        return Err(ExportError::InvalidSizing(
2311            "embedded image dimensions must be finite and positive",
2312        ));
2313    }
2314    if width > u64::MAX as f64 || height > u64::MAX as f64 {
2315        return Err(ExportError::InvalidSizing(
2316            "embedded image dimensions exceed the planner capability",
2317        ));
2318    }
2319    Ok((width as u64).saturating_mul(height as u64))
2320}
2321
2322#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
2323fn validate_embedded_image_limit(limit: EmbeddedImageLimit) -> Result<()> {
2324    if limit.max_bytes_per_image == Some(0)
2325        || limit.max_total_bytes == Some(0)
2326        || limit.max_pixels_per_image == Some(0)
2327        || limit.max_total_pixels == Some(0)
2328    {
2329        return Err(ExportError::InvalidSizing(
2330            "embedded image byte and pixel limits must be positive",
2331        ));
2332    }
2333    Ok(())
2334}
2335
2336#[cfg(feature = "pdf")]
2337fn draw_pdf_background(
2338    surface: &mut krilla::surface::Surface<'_>,
2339    page_size: krilla::geom::Size,
2340    background: Option<&str>,
2341) {
2342    let Some(background) = background.filter(|value| !value.eq_ignore_ascii_case("transparent"))
2343    else {
2344        return;
2345    };
2346    let Some(color) = parse_rgba_color(background) else {
2347        return;
2348    };
2349    let Some(opacity) = krilla::num::NormalizedF32::new(f32::from(color.alpha) / 255.0) else {
2350        return;
2351    };
2352    let mut path = krilla::geom::PathBuilder::new();
2353    let Some(rect) = krilla::geom::Rect::from_xywh(0.0, 0.0, page_size.width(), page_size.height())
2354    else {
2355        return;
2356    };
2357    path.push_rect(rect);
2358    let Some(path) = path.finish() else {
2359        return;
2360    };
2361    surface.set_fill(Some(krilla::paint::Fill {
2362        paint: krilla::color::rgb::Color::new(color.red, color.green, color.blue).into(),
2363        opacity,
2364        rule: Default::default(),
2365    }));
2366    surface.draw_path(&path);
2367    surface.set_fill(None);
2368}
2369
2370#[cfg(any(feature = "png", feature = "jpeg"))]
2371#[derive(Debug, Clone, Copy, Default)]
2372struct RootSvgMetadata {
2373    has_view_box: bool,
2374    max_width_px: Option<f32>,
2375}
2376
2377#[cfg(any(feature = "png", feature = "jpeg"))]
2378fn parse_root_svg_metadata(svg: &str, control: &OperationControl) -> Result<RootSvgMetadata> {
2379    use quick_xml::{XmlVersion, events::Event, name::ResolveResult, reader::NsReader};
2380
2381    let mut reader = NsReader::from_str(svg);
2382    reader.config_mut().enable_all_checks(true);
2383    loop {
2384        export_checkpoint(control)?;
2385        let event = reader.read_event().map_err(|_| ExportError::SvgParse)?;
2386        let element = match event {
2387            Event::Start(element) | Event::Empty(element) => element,
2388            Event::Eof => return Err(ExportError::SvgParse),
2389            _ => continue,
2390        };
2391
2392        let (namespace, local_name) = reader.resolver().resolve_element(element.name());
2393        let is_svg_namespace = matches!(namespace, ResolveResult::Unbound)
2394            || matches!(
2395                namespace,
2396                ResolveResult::Bound(namespace)
2397                    if namespace.as_ref() == "http://www.w3.org/2000/svg"
2398            );
2399        if !is_svg_namespace || local_name.as_ref() != "svg" {
2400            return Err(ExportError::SvgParse);
2401        }
2402
2403        let mut metadata = RootSvgMetadata::default();
2404        let mut view_box_seen = false;
2405        let mut style_seen = false;
2406        for attribute in element.attributes() {
2407            export_checkpoint(control)?;
2408            let attribute = attribute.map_err(|_| ExportError::SvgParse)?;
2409            if attribute.key.as_namespace_binding().is_some() {
2410                continue;
2411            }
2412            let (namespace, local_name) = reader.resolver().resolve_attribute(attribute.key);
2413            let is_unbound_attribute = matches!(&namespace, ResolveResult::Unbound);
2414            let consumed_by_usvg = match namespace {
2415                ResolveResult::Unknown(_) => return Err(ExportError::SvgParse),
2416                ResolveResult::Unbound => true,
2417                ResolveResult::Bound(namespace) => matches!(
2418                    namespace.as_ref(),
2419                    "http://www.w3.org/2000/svg"
2420                        | "http://www.w3.org/1999/xlink"
2421                        | "http://www.w3.org/XML/1998/namespace"
2422                ),
2423            };
2424            if !consumed_by_usvg {
2425                continue;
2426            }
2427
2428            let value = attribute
2429                .normalized_value(XmlVersion::Implicit1_0)
2430                .map_err(|_| ExportError::SvgParse)?;
2431            match local_name.as_ref() {
2432                "viewBox" if !view_box_seen => {
2433                    view_box_seen = true;
2434                    metadata.has_view_box = has_valid_svg_view_box(value.as_ref());
2435                }
2436                // usvg projects namespaced presentation attributes by local name, but parses the
2437                // `style` declaration list only from the unbound XML attribute.
2438                "style" if is_unbound_attribute && !style_seen => {
2439                    style_seen = true;
2440                    metadata.max_width_px = parse_inline_max_width_px(value.as_ref(), control)?;
2441                }
2442                _ => {}
2443            }
2444        }
2445        return Ok(metadata);
2446    }
2447}
2448
2449#[cfg(any(feature = "png", feature = "jpeg"))]
2450fn has_valid_svg_view_box(value: &str) -> bool {
2451    let Ok(view_box) = value.parse::<svgtypes::ViewBox>() else {
2452        return false;
2453    };
2454    usvg::NonZeroRect::from_xywh(
2455        view_box.x as f32,
2456        view_box.y as f32,
2457        view_box.w as f32,
2458        view_box.h as f32,
2459    )
2460    .is_some()
2461}
2462
2463#[cfg(any(feature = "png", feature = "jpeg"))]
2464fn parse_inline_max_width_px(style: &str, control: &OperationControl) -> Result<Option<f32>> {
2465    let mut parser = Parser::new(style);
2466    let mut max_width = None;
2467
2468    while !parser.is_exhausted() {
2469        export_checkpoint(control)?;
2470        let declaration = parser.parse_until_after(Delimiter::Semicolon, |declaration| {
2471            let property = declaration.expect_ident_cloned()?;
2472            declaration.expect_colon()?;
2473
2474            if !property.eq_ignore_ascii_case("max-width") {
2475                declaration.expect_no_error_token()?;
2476                return Ok::<_, cssparser::ParseError<()>>(None);
2477            }
2478
2479            let token = declaration.next()?.clone();
2480            declaration.expect_exhausted()?;
2481            let Token::Dimension { value, unit, .. } = token else {
2482                return Ok(None);
2483            };
2484            if unit.eq_ignore_ascii_case("px") && value.is_finite() && value > 0.0 {
2485                Ok(Some(value))
2486            } else {
2487                Ok(None)
2488            }
2489        });
2490
2491        if let Ok(Some(value)) = declaration {
2492            max_width = Some(value);
2493        }
2494    }
2495    Ok(max_width)
2496}
2497
2498#[cfg(any(feature = "png", feature = "jpeg"))]
2499#[derive(Debug, Clone, Copy)]
2500struct RasterGeometry {
2501    min_x: f32,
2502    min_y: f32,
2503    width: f32,
2504    height: f32,
2505}
2506
2507#[cfg(any(feature = "png", feature = "jpeg"))]
2508fn raster_geometry_for_svg(metadata: RootSvgMetadata, tree: &usvg::Tree) -> (RasterGeometry, bool) {
2509    if metadata.has_view_box {
2510        // `usvg`/`resvg` already apply the root viewBox transform (including translating the
2511        // viewBox min corner to (0,0)) when building/rendering the tree. If we also translate
2512        // by `-min_x/-min_y` here, diagrams with negative viewBox mins (e.g. kanban, gitGraph)
2513        // get shifted fully out of the viewport and render as a blank/transparent pixmap.
2514        let size = tree.size();
2515        return (
2516            RasterGeometry {
2517                min_x: 0.0,
2518                min_y: 0.0,
2519                width: size.width(),
2520                height: size.height(),
2521            },
2522            false,
2523        );
2524    }
2525
2526    // Some Mermaid diagrams (e.g. `info`) don't emit a viewBox upstream.
2527    // For raster formats, fall back to the rendered content bounds as computed by usvg.
2528    let bbox = tree.root().abs_stroke_bounding_box();
2529    let w = bbox.width().max(1.0);
2530    let h = bbox.height().max(1.0);
2531    if w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0 {
2532        (
2533            RasterGeometry {
2534                min_x: bbox.x(),
2535                min_y: bbox.y(),
2536                width: w,
2537                height: h,
2538            },
2539            true,
2540        )
2541    } else {
2542        let size = tree.size();
2543        (
2544            RasterGeometry {
2545                min_x: 0.0,
2546                min_y: 0.0,
2547                width: size.width(),
2548                height: size.height(),
2549            },
2550            false,
2551        )
2552    }
2553}
2554
2555#[cfg(any(feature = "png", feature = "jpeg"))]
2556fn raster_plan_for_geometry(
2557    geo: RasterGeometry,
2558    options: &RasterOptions,
2559    control: &OperationControl,
2560) -> Result<RasterPlan> {
2561    export_checkpoint(control)?;
2562    if !(options.scale.is_finite() && options.scale > 0.0) {
2563        return Err(ExportError::InvalidScale);
2564    }
2565
2566    validate_fit_box(options.fit_to)?;
2567    validate_size_limit(options.size_limit)?;
2568
2569    // Make scaling more intuitive/stable: at scale=1 we already round up to whole pixels, so for
2570    // scale>1 prefer scaling the *rounded* base size. This avoids surprising off-by-one shrinkage
2571    // when the viewBox/bounds are fractional (e.g. 342.36 * 2 = 684.72 -> ceil = 685, while
2572    // ceil(342.36) * 2 = 686).
2573    let base_width_px = f64::from(geo.width).ceil().max(1.0);
2574    let base_height_px = f64::from(geo.height).ceil().max(1.0);
2575
2576    let fit_scale = fit_scale_for_base_size(base_width_px, base_height_px, options.fit_to);
2577    let requested_scale = fit_scale * f64::from(options.scale);
2578    let requested_width_px = requested_raster_dim_px(base_width_px * requested_scale)?;
2579    let requested_height_px = requested_raster_dim_px(base_height_px * requested_scale)?;
2580
2581    let limit_scale = size_limit_scale(
2582        base_width_px * requested_scale,
2583        base_height_px * requested_scale,
2584        options.size_limit,
2585    );
2586    let mut effective_scale = requested_scale * limit_scale;
2587    let (mut width_px, mut height_px) = raster_limited_dims(
2588        base_width_px,
2589        base_height_px,
2590        effective_scale,
2591        options.size_limit,
2592    )?;
2593
2594    if let Some(max_pixels) = options.size_limit.max_pixels {
2595        for _ in 0..8 {
2596            export_checkpoint(control)?;
2597            if u64::from(width_px) * u64::from(height_px) <= max_pixels {
2598                break;
2599            }
2600            let pixels = f64::from(width_px) * f64::from(height_px);
2601            let shrink = ((max_pixels as f64) / pixels).sqrt() * 0.999_999;
2602            effective_scale *= shrink;
2603            (width_px, height_px) = raster_limited_dims(
2604                base_width_px,
2605                base_height_px,
2606                effective_scale,
2607                options.size_limit,
2608            )?;
2609        }
2610    }
2611
2612    Ok(RasterPlan {
2613        requested_width_px,
2614        requested_height_px,
2615        width_px,
2616        height_px,
2617        requested_scale,
2618        effective_scale,
2619        limited: f64::from(width_px) != requested_width_px
2620            || f64::from(height_px) != requested_height_px,
2621    })
2622}
2623
2624#[cfg(any(feature = "png", feature = "jpeg"))]
2625fn validate_fit_box(fit: Option<RasterFitBox>) -> Result<()> {
2626    let Some(fit) = fit else {
2627        return Ok(());
2628    };
2629
2630    if fit.width.is_none() && fit.height.is_none() {
2631        return Err(ExportError::InvalidSizing(
2632            "fit_to must include a positive width or height",
2633        ));
2634    }
2635    if fit.width == Some(0) || fit.height == Some(0) {
2636        return Err(ExportError::InvalidSizing(
2637            "fit_to width and height must be positive",
2638        ));
2639    }
2640    Ok(())
2641}
2642
2643#[cfg(any(feature = "png", feature = "jpeg"))]
2644fn validate_size_limit(limit: RasterSizeLimit) -> Result<()> {
2645    if limit.max_width == Some(0) || limit.max_height == Some(0) {
2646        return Err(ExportError::InvalidSizing(
2647            "size_limit max_width and max_height must be positive",
2648        ));
2649    }
2650    if limit.max_pixels == Some(0) {
2651        return Err(ExportError::InvalidSizing(
2652            "size_limit max_pixels must be positive",
2653        ));
2654    }
2655    Ok(())
2656}
2657
2658#[cfg(any(feature = "png", feature = "jpeg"))]
2659fn fit_scale_for_base_size(width: f64, height: f64, fit: Option<RasterFitBox>) -> f64 {
2660    let Some(fit) = fit else {
2661        return 1.0;
2662    };
2663
2664    let mut scale: f64 = 1.0;
2665    if let Some(target_width) = fit.width {
2666        scale = scale.min(f64::from(target_width) / width);
2667    }
2668    if let Some(target_height) = fit.height {
2669        scale = scale.min(f64::from(target_height) / height);
2670    }
2671    if scale.is_nan() {
2672        1.0
2673    } else {
2674        scale.clamp(0.0, 1.0)
2675    }
2676}
2677
2678#[cfg(any(feature = "png", feature = "jpeg"))]
2679fn size_limit_scale(width: f64, height: f64, limit: RasterSizeLimit) -> f64 {
2680    let mut scale: f64 = 1.0;
2681    if let Some(max_width) = limit.max_width {
2682        scale = scale.min(f64::from(max_width) / width);
2683    }
2684    if let Some(max_height) = limit.max_height {
2685        scale = scale.min(f64::from(max_height) / height);
2686    }
2687    if let Some(max_pixels) = limit.max_pixels {
2688        let pixels = width * height * scale * scale;
2689        if pixels > max_pixels as f64 {
2690            scale *= ((max_pixels as f64) / pixels).sqrt();
2691        }
2692    }
2693    if scale.is_nan() {
2694        1.0
2695    } else {
2696        scale.clamp(0.0, 1.0)
2697    }
2698}
2699
2700#[cfg(any(feature = "png", feature = "jpeg"))]
2701fn raster_limited_dims(
2702    base_width_px: f64,
2703    base_height_px: f64,
2704    scale: f64,
2705    limit: RasterSizeLimit,
2706) -> Result<(u32, u32)> {
2707    Ok((
2708        raster_dim_px(base_width_px * scale, limit.max_width)?,
2709        raster_dim_px(base_height_px * scale, limit.max_height)?,
2710    ))
2711}
2712
2713#[cfg(any(feature = "png", feature = "jpeg"))]
2714fn raster_dim_px(value: f64, max: Option<u32>) -> Result<u32> {
2715    let value = requested_raster_dim_px(value)?;
2716    let value = max.map_or(value, |max| value.min(f64::from(max)));
2717    if value > f64::from(u32::MAX) {
2718        return Err(ExportError::InvalidSizing(
2719            "final raster dimension exceeds the u32 encoder capability",
2720        ));
2721    }
2722    Ok(value as u32)
2723}
2724
2725#[cfg(any(feature = "png", feature = "jpeg"))]
2726fn requested_raster_dim_px(value: f64) -> Result<f64> {
2727    if !(value.is_finite() && value > 0.0) {
2728        return Err(ExportError::InvalidSizing(
2729            "computed raster dimension must be finite and positive",
2730        ));
2731    }
2732    Ok(value.ceil().max(1.0))
2733}
2734
2735#[cfg(any(feature = "png", feature = "jpeg"))]
2736fn configure_usvg_options_for_raster(opt: &mut usvg::Options<'_>, metadata: RootSvgMetadata) {
2737    opt.fontdb = shared_system_fontdb();
2738
2739    if !metadata.has_view_box
2740        && let Some(max_width) = metadata.max_width_px
2741        && max_width.is_finite()
2742        && max_width > 0.0
2743        && let Some(size) = usvg::Size::from_wh(max_width, opt.default_size.height())
2744    {
2745        opt.default_size = size;
2746    }
2747
2748    opt.font_family =
2749        raster_default_font_family(opt.fontdb.as_ref()).unwrap_or_else(|| "Arial".to_string());
2750    opt.font_resolver = browser_like_font_resolver();
2751    opt.image_href_resolver = data_url_only_image_href_resolver();
2752}
2753
2754#[cfg(feature = "pdf")]
2755fn configure_usvg_options_for_pdf(opt: &mut usvg::Options<'_>) {
2756    opt.fontdb = shared_system_fontdb();
2757    opt.font_family =
2758        raster_default_font_family(opt.fontdb.as_ref()).unwrap_or_else(|| "Arial".to_string());
2759    opt.font_resolver = browser_like_pdf_font_resolver();
2760    opt.image_href_resolver = data_url_only_image_href_resolver();
2761}
2762
2763#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
2764fn shared_system_fontdb() -> Arc<usvg::fontdb::Database> {
2765    static FONTDB: OnceLock<Arc<usvg::fontdb::Database>> = OnceLock::new();
2766    Arc::clone(FONTDB.get_or_init(|| {
2767        let mut fontdb = usvg::fontdb::Database::new();
2768        fontdb.load_system_fonts();
2769        configure_fontdb_generic_families(&mut fontdb);
2770        Arc::new(fontdb)
2771    }))
2772}
2773
2774#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
2775fn data_url_only_image_href_resolver() -> usvg::ImageHrefResolver<'static> {
2776    usvg::ImageHrefResolver {
2777        resolve_data: usvg::ImageHrefResolver::default_data_resolver(),
2778        resolve_string: Box::new(|_, _| None),
2779    }
2780}
2781
2782#[cfg(any(feature = "png", feature = "jpeg"))]
2783fn browser_like_font_resolver() -> usvg::FontResolver<'static> {
2784    usvg::FontResolver {
2785        select_font: Box::new(move |font, fontdb| {
2786            select_font_case_insensitively(font, fontdb.as_ref())
2787                .or_else(|| query_browser_like_fallback_font(font, fontdb.as_ref()))
2788                .or_else(|| fontdb.faces().next().map(|face| face.id))
2789        }),
2790        select_fallback: usvg::FontResolver::default_fallback_selector(),
2791    }
2792}
2793
2794#[cfg(feature = "pdf")]
2795fn browser_like_pdf_font_resolver() -> usvg::FontResolver<'static> {
2796    usvg::FontResolver {
2797        select_font: Box::new(move |font, fontdb| {
2798            select_font_case_insensitively(font, fontdb.as_ref())
2799                .or_else(|| query_browser_like_pdf_fallback_font(font, fontdb.as_ref()))
2800                .or_else(|| fontdb.faces().next().map(|face| face.id))
2801        }),
2802        select_fallback: usvg::FontResolver::default_fallback_selector(),
2803    }
2804}
2805
2806#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
2807fn select_font_case_insensitively(
2808    font: &usvg::Font,
2809    fontdb: &usvg::fontdb::Database,
2810) -> Option<usvg::fontdb::ID> {
2811    let weight = usvg::fontdb::Weight(font.weight());
2812    let stretch = font.stretch().into();
2813    let style = font.style().into();
2814
2815    for family in font.families() {
2816        let selected = match family {
2817            usvg::FontFamily::Named(name) => {
2818                query_named_font_family_case_insensitively(fontdb, name, weight, stretch, style)
2819            }
2820            usvg::FontFamily::Serif => {
2821                query_font_family(fontdb, usvg::fontdb::Family::Serif, weight, stretch, style)
2822            }
2823            usvg::FontFamily::SansSerif => query_font_family(
2824                fontdb,
2825                usvg::fontdb::Family::SansSerif,
2826                weight,
2827                stretch,
2828                style,
2829            ),
2830            usvg::FontFamily::Cursive => query_font_family(
2831                fontdb,
2832                usvg::fontdb::Family::Cursive,
2833                weight,
2834                stretch,
2835                style,
2836            ),
2837            usvg::FontFamily::Fantasy => query_font_family(
2838                fontdb,
2839                usvg::fontdb::Family::Fantasy,
2840                weight,
2841                stretch,
2842                style,
2843            ),
2844            usvg::FontFamily::Monospace => query_font_family(
2845                fontdb,
2846                usvg::fontdb::Family::Monospace,
2847                weight,
2848                stretch,
2849                style,
2850            ),
2851        };
2852        if selected.is_some() {
2853            return selected;
2854        }
2855    }
2856
2857    // Preserve usvg's default final family fallback after the complete requested CSS stack.
2858    query_font_family(fontdb, usvg::fontdb::Family::Serif, weight, stretch, style)
2859}
2860
2861#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
2862fn query_named_font_family_case_insensitively(
2863    fontdb: &usvg::fontdb::Database,
2864    requested_name: &str,
2865    weight: usvg::fontdb::Weight,
2866    stretch: usvg::fontdb::Stretch,
2867    style: usvg::fontdb::Style,
2868) -> Option<usvg::fontdb::ID> {
2869    query_font_family(
2870        fontdb,
2871        usvg::fontdb::Family::Name(requested_name),
2872        weight,
2873        stretch,
2874        style,
2875    )
2876    .or_else(|| {
2877        let canonical_name = fontdb
2878            .faces()
2879            .flat_map(|face| face.families.iter())
2880            .map(|(name, _)| name)
2881            .find(|name| unicase::eq(name.as_str(), requested_name))?;
2882
2883        query_font_family(
2884            fontdb,
2885            usvg::fontdb::Family::Name(canonical_name),
2886            weight,
2887            stretch,
2888            style,
2889        )
2890    })
2891}
2892
2893#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
2894fn query_font_family(
2895    fontdb: &usvg::fontdb::Database,
2896    family: usvg::fontdb::Family<'_>,
2897    weight: usvg::fontdb::Weight,
2898    stretch: usvg::fontdb::Stretch,
2899    style: usvg::fontdb::Style,
2900) -> Option<usvg::fontdb::ID> {
2901    let families = [family];
2902    fontdb.query(&usvg::fontdb::Query {
2903        families: &families,
2904        weight,
2905        stretch,
2906        style,
2907    })
2908}
2909
2910#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
2911fn configure_fontdb_generic_families(fontdb: &mut usvg::fontdb::Database) {
2912    let sans = first_font_family(fontdb, |face| !face.monospaced)
2913        .or_else(|| first_font_family(fontdb, |_| true));
2914    let mono = first_font_family(fontdb, |face| face.monospaced).or_else(|| sans.clone());
2915
2916    if query_normal_font_family(fontdb, usvg::fontdb::Family::SansSerif).is_none()
2917        && let Some(family) = sans.as_ref()
2918    {
2919        fontdb.set_sans_serif_family(family.clone());
2920    }
2921    if query_normal_font_family(fontdb, usvg::fontdb::Family::Serif).is_none()
2922        && let Some(family) = sans.as_ref()
2923    {
2924        fontdb.set_serif_family(family.clone());
2925    }
2926    if query_normal_font_family(fontdb, usvg::fontdb::Family::Monospace).is_none()
2927        && let Some(family) = mono.as_ref()
2928    {
2929        fontdb.set_monospace_family(family.clone());
2930    }
2931}
2932
2933#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
2934fn raster_default_font_family(fontdb: &usvg::fontdb::Database) -> Option<String> {
2935    query_normal_font_family(fontdb, usvg::fontdb::Family::SansSerif)
2936        .or_else(|| query_normal_font_family(fontdb, usvg::fontdb::Family::Serif))
2937        .or_else(|| first_font_family(fontdb, |_| true))
2938}
2939
2940#[cfg(any(feature = "png", feature = "jpeg"))]
2941fn query_browser_like_fallback_font(
2942    font: &usvg::Font,
2943    fontdb: &usvg::fontdb::Database,
2944) -> Option<usvg::fontdb::ID> {
2945    let mut families = Vec::with_capacity(3);
2946    if font_requests_monospace(font) {
2947        families.push(usvg::fontdb::Family::Monospace);
2948        families.push(usvg::fontdb::Family::SansSerif);
2949        families.push(usvg::fontdb::Family::Serif);
2950    } else {
2951        families.push(usvg::fontdb::Family::SansSerif);
2952        families.push(usvg::fontdb::Family::Serif);
2953        families.push(usvg::fontdb::Family::Monospace);
2954    }
2955
2956    let query = usvg::fontdb::Query {
2957        families: &families,
2958        weight: usvg::fontdb::Weight(font.weight()),
2959        stretch: font.stretch().into(),
2960        style: font.style().into(),
2961    };
2962    fontdb.query(&query)
2963}
2964
2965#[cfg(feature = "pdf")]
2966fn query_browser_like_pdf_fallback_font(
2967    font: &usvg::Font,
2968    fontdb: &usvg::fontdb::Database,
2969) -> Option<usvg::fontdb::ID> {
2970    let mut families = Vec::with_capacity(3);
2971    if pdf_font_requests_monospace(font) {
2972        families.push(usvg::fontdb::Family::Monospace);
2973        families.push(usvg::fontdb::Family::SansSerif);
2974        families.push(usvg::fontdb::Family::Serif);
2975    } else {
2976        families.push(usvg::fontdb::Family::SansSerif);
2977        families.push(usvg::fontdb::Family::Serif);
2978        families.push(usvg::fontdb::Family::Monospace);
2979    }
2980
2981    let query = usvg::fontdb::Query {
2982        families: &families,
2983        weight: usvg::fontdb::Weight(font.weight()),
2984        stretch: font.stretch().into(),
2985        style: font.style().into(),
2986    };
2987    fontdb.query(&query)
2988}
2989
2990#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
2991fn query_normal_font_family(
2992    fontdb: &usvg::fontdb::Database,
2993    family: usvg::fontdb::Family<'_>,
2994) -> Option<String> {
2995    let families = [family];
2996    let query = usvg::fontdb::Query {
2997        families: &families,
2998        weight: usvg::fontdb::Weight::NORMAL,
2999        stretch: usvg::fontdb::Stretch::Normal,
3000        style: usvg::fontdb::Style::Normal,
3001    };
3002    fontdb
3003        .query(&query)
3004        .and_then(|id| fontdb.face(id))
3005        .and_then(face_family_name)
3006}
3007
3008#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
3009fn first_font_family<F>(fontdb: &usvg::fontdb::Database, mut predicate: F) -> Option<String>
3010where
3011    F: FnMut(&usvg::fontdb::FaceInfo) -> bool,
3012{
3013    fontdb
3014        .faces()
3015        .find(|face| predicate(face))
3016        .and_then(face_family_name)
3017}
3018
3019#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
3020fn face_family_name(face: &usvg::fontdb::FaceInfo) -> Option<String> {
3021    face.families
3022        .iter()
3023        .find(|(_, lang)| *lang == usvg::fontdb::Language::English_UnitedStates)
3024        .or_else(|| face.families.first())
3025        .map(|(family, _)| family.clone())
3026}
3027
3028#[cfg(any(feature = "png", feature = "jpeg"))]
3029fn font_requests_monospace(font: &usvg::Font) -> bool {
3030    font.families().iter().any(|family| match family {
3031        usvg::FontFamily::Monospace => true,
3032        usvg::FontFamily::Named(name) => {
3033            let name = name.to_ascii_lowercase();
3034            name.contains("mono")
3035                || name.contains("courier")
3036                || name.contains("consolas")
3037                || name.contains("menlo")
3038        }
3039        _ => false,
3040    })
3041}
3042
3043#[cfg(feature = "pdf")]
3044fn pdf_font_requests_monospace(font: &usvg::Font) -> bool {
3045    font.families().iter().any(|family| match family {
3046        usvg::FontFamily::Monospace => true,
3047        usvg::FontFamily::Named(name) => {
3048            let name = name.to_ascii_lowercase();
3049            name.contains("mono")
3050                || name.contains("courier")
3051                || name.contains("consolas")
3052                || name.contains("menlo")
3053        }
3054        _ => false,
3055    })
3056}
3057
3058#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
3059#[derive(Debug, Clone, Copy)]
3060struct RgbaColor {
3061    red: u8,
3062    green: u8,
3063    blue: u8,
3064    alpha: u8,
3065}
3066
3067/// Returns whether the native exporters can interpret a background color.
3068#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
3069#[must_use]
3070pub fn is_valid_export_background_color(text: &str) -> bool {
3071    parse_rgba_color(text).is_some()
3072}
3073
3074#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
3075fn parse_rgba_color(text: &str) -> Option<RgbaColor> {
3076    let s = text.trim().to_ascii_lowercase();
3077    match s.as_str() {
3078        "transparent" => {
3079            return Some(RgbaColor {
3080                red: 0,
3081                green: 0,
3082                blue: 0,
3083                alpha: 0,
3084            });
3085        }
3086        "white" => {
3087            return Some(RgbaColor {
3088                red: 255,
3089                green: 255,
3090                blue: 255,
3091                alpha: 255,
3092            });
3093        }
3094        "black" => {
3095            return Some(RgbaColor {
3096                red: 0,
3097                green: 0,
3098                blue: 0,
3099                alpha: 255,
3100            });
3101        }
3102        _ => {}
3103    }
3104
3105    let hex = s.strip_prefix('#')?;
3106    fn hex2(b: &[u8]) -> Option<u8> {
3107        let hi = (*b.first()? as char).to_digit(16)? as u8;
3108        let lo = (*b.get(1)? as char).to_digit(16)? as u8;
3109        Some((hi << 4) | lo)
3110    }
3111    fn hex1(c: u8) -> Option<u8> {
3112        let v = (c as char).to_digit(16)? as u8;
3113        Some((v << 4) | v)
3114    }
3115
3116    let bytes = hex.as_bytes();
3117    match bytes.len() {
3118        3 => Some(RgbaColor {
3119            red: hex1(bytes[0])?,
3120            green: hex1(bytes[1])?,
3121            blue: hex1(bytes[2])?,
3122            alpha: 255,
3123        }),
3124        4 => Some(RgbaColor {
3125            red: hex1(bytes[0])?,
3126            green: hex1(bytes[1])?,
3127            blue: hex1(bytes[2])?,
3128            alpha: hex1(bytes[3])?,
3129        }),
3130        6 => Some(RgbaColor {
3131            red: hex2(&bytes[0..2])?,
3132            green: hex2(&bytes[2..4])?,
3133            blue: hex2(&bytes[4..6])?,
3134            alpha: 255,
3135        }),
3136        8 => Some(RgbaColor {
3137            red: hex2(&bytes[0..2])?,
3138            green: hex2(&bytes[2..4])?,
3139            blue: hex2(&bytes[4..6])?,
3140            alpha: hex2(&bytes[6..8])?,
3141        }),
3142        _ => None,
3143    }
3144}
3145
3146#[cfg(any(feature = "png", feature = "jpeg"))]
3147fn parse_tiny_skia_color(text: &str) -> Option<tiny_skia::Color> {
3148    let color = parse_rgba_color(text)?;
3149    Some(tiny_skia::Color::from_rgba8(
3150        color.red,
3151        color.green,
3152        color.blue,
3153        color.alpha,
3154    ))
3155}
3156
3157#[cfg(all(test, any(feature = "png", feature = "jpeg")))]
3158mod font_resolver_tests {
3159    use super::*;
3160    use std::sync::Mutex;
3161
3162    #[test]
3163    fn browser_font_resolver_matches_named_families_case_insensitively() {
3164        let (fontdb, expected_id) = controlled_fontdb();
3165
3166        assert_eq!(
3167            selected_font_id(Arc::clone(&fontdb), "'Maße Test Sans'"),
3168            expected_id,
3169            "the installed family spelling must select its named face"
3170        );
3171        assert_eq!(
3172            selected_font_id(Arc::clone(&fontdb), "'maße test sans'"),
3173            expected_id,
3174            "CSS family matching must ignore ASCII case"
3175        );
3176        assert_eq!(
3177            selected_font_id(Arc::clone(&fontdb), "'MASSE TEST SANS'"),
3178            expected_id,
3179            "CSS family matching must use Unicode default case folding"
3180        );
3181        assert_eq!(
3182            selected_font_id(
3183                Arc::clone(&fontdb),
3184                "'__merman_missing_font__', 'masse test sans'"
3185            ),
3186            expected_id,
3187            "case-insensitive matching must preserve CSS family stack order"
3188        );
3189    }
3190
3191    fn controlled_fontdb() -> (Arc<usvg::fontdb::Database>, usvg::fontdb::ID) {
3192        let source_face = shared_system_fontdb()
3193            .faces()
3194            .next()
3195            .cloned()
3196            .expect("native export tests require one valid system font face");
3197
3198        let mut fontdb = usvg::fontdb::Database::new();
3199        let mut fallback = source_face.clone();
3200        fallback.families = vec![(
3201            "Merman Fallback Serif".to_string(),
3202            usvg::fontdb::Language::English_UnitedStates,
3203        )];
3204        fallback.weight = usvg::fontdb::Weight::NORMAL;
3205        fallback.stretch = usvg::fontdb::Stretch::Normal;
3206        fallback.style = usvg::fontdb::Style::Normal;
3207
3208        let mut target = source_face;
3209        target.families = vec![(
3210            "Maße Test Sans".to_string(),
3211            usvg::fontdb::Language::English_UnitedStates,
3212        )];
3213        target.weight = usvg::fontdb::Weight::NORMAL;
3214        target.stretch = usvg::fontdb::Stretch::Normal;
3215        target.style = usvg::fontdb::Style::Normal;
3216
3217        fontdb.set_serif_family("Merman Fallback Serif");
3218        fontdb.set_sans_serif_family("Merman Fallback Serif");
3219        fontdb.set_monospace_family("Merman Fallback Serif");
3220        let _fallback_id = fontdb.push_face_info(fallback);
3221        let target_id = fontdb.push_face_info(target);
3222        (Arc::new(fontdb), target_id)
3223    }
3224
3225    fn selected_font_id(
3226        fontdb: Arc<usvg::fontdb::Database>,
3227        font_family: &str,
3228    ) -> usvg::fontdb::ID {
3229        let selected = Arc::new(Mutex::new(Vec::new()));
3230        let selected_for_resolver = Arc::clone(&selected);
3231        let resolver = browser_like_font_resolver();
3232        let select_font = resolver.select_font;
3233        let options = usvg::Options {
3234            fontdb,
3235            font_resolver: usvg::FontResolver {
3236                select_font: Box::new(move |font, fontdb| {
3237                    let id = select_font(font, fontdb);
3238                    if let Some(id) = id {
3239                        selected_for_resolver
3240                            .lock()
3241                            .expect("selected-font recorder lock")
3242                            .push(id);
3243                    }
3244                    id
3245                }),
3246                select_fallback: resolver.select_fallback,
3247            },
3248            ..Default::default()
3249        };
3250
3251        let svg = format!(
3252            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 60"><text x="10" y="40" font-size="24" style="font-family: {font_family};">A</text></svg>"#
3253        );
3254        usvg::Tree::from_str(&svg, &options).expect("parse test SVG with native font resolver");
3255
3256        selected
3257            .lock()
3258            .expect("selected-font recorder lock")
3259            .first()
3260            .copied()
3261            .expect("text parsing must select a primary font")
3262    }
3263}
3264
3265#[cfg(all(test, feature = "png"))]
3266mod png_feature_tests {
3267    use super::*;
3268
3269    fn compatible_svg() -> ResvgCompatibleSvg {
3270        let session = merman_render::environment::RenderEnvironment::deterministic()
3271            .begin_session()
3272            .expect("deterministic render session");
3273        merman_render::svg::finalize_resvg_svg(
3274            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="black"/></svg>"#,
3275            &session,
3276        )
3277        .expect("sealed SVG")
3278    }
3279
3280    #[test]
3281    fn png_leaf_encodes_a_sealed_svg() {
3282        let bytes = svg_to_png(&compatible_svg(), &RasterOptions::default())
3283            .expect("PNG export should be callable when its leaf is enabled");
3284
3285        assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"));
3286    }
3287
3288    #[test]
3289    fn controlled_png_preserves_successful_bytes() {
3290        let svg = compatible_svg();
3291        let legacy = svg_to_png(&svg, &RasterOptions::default()).expect("legacy PNG export");
3292        let controlled =
3293            svg_to_png_controlled(&svg, &RasterOptions::default(), OperationControl::new())
3294                .expect("controlled PNG export");
3295
3296        assert_eq!(controlled, legacy);
3297    }
3298
3299    #[test]
3300    fn pre_cancelled_png_returns_no_prepared_artifact_or_bytes() {
3301        let svg = compatible_svg();
3302        let control = OperationControl::new();
3303        control.cancel();
3304
3305        let prepared_error =
3306            match prepare_raster_controlled(&svg, &RasterOptions::default(), control.clone()) {
3307                Ok(_) => panic!("pre-cancelled preparation must fail"),
3308                Err(error) => error,
3309            };
3310        assert!(matches!(
3311            prepared_error,
3312            ExportError::Cancelled(OperationCancelled {
3313                phase: OperationPhase::Export,
3314                ..
3315            })
3316        ));
3317        assert!(prepared_error.resource_limit_details().is_none());
3318
3319        let bytes_error = svg_to_png_controlled(&svg, &RasterOptions::default(), control)
3320            .expect_err("pre-cancelled encoding must fail");
3321        assert!(matches!(
3322            bytes_error,
3323            ExportError::Cancelled(OperationCancelled {
3324                phase: OperationPhase::Export,
3325                ..
3326            })
3327        ));
3328    }
3329
3330    #[test]
3331    fn cancellation_after_preparation_returns_no_png_bytes() {
3332        let svg = compatible_svg();
3333        let control = OperationControl::new();
3334        let prepared = prepare_raster_controlled(&svg, &RasterOptions::default(), control.clone())
3335            .expect("preparation should succeed before cancellation");
3336        control.cancel();
3337
3338        let error = prepared
3339            .encode_png()
3340            .expect_err("cancelled encoding must not return bytes");
3341        assert!(matches!(
3342            error,
3343            ExportError::Cancelled(OperationCancelled {
3344                phase: OperationPhase::Export,
3345                ..
3346            })
3347        ));
3348        assert!(error.resource_limit_details().is_none());
3349    }
3350}
3351
3352#[cfg(all(test, feature = "jpeg"))]
3353mod jpeg_feature_tests {
3354    use super::*;
3355
3356    fn compatible_svg() -> ResvgCompatibleSvg {
3357        let session = merman_render::environment::RenderEnvironment::deterministic()
3358            .begin_session()
3359            .expect("deterministic render session");
3360        merman_render::svg::finalize_resvg_svg(
3361            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="black"/></svg>"#,
3362            &session,
3363        )
3364        .expect("sealed SVG")
3365    }
3366
3367    #[test]
3368    fn jpeg_leaf_encodes_a_sealed_svg() {
3369        let bytes = svg_to_jpeg(&compatible_svg(), &RasterOptions::default())
3370            .expect("JPEG export should be callable when its leaf is enabled");
3371
3372        assert!(bytes.starts_with(b"\xff\xd8\xff"));
3373    }
3374
3375    #[test]
3376    fn pre_cancelled_jpeg_returns_no_bytes() {
3377        let svg = compatible_svg();
3378        let control = OperationControl::new();
3379        control.cancel();
3380
3381        let error = svg_to_jpeg_controlled(&svg, &RasterOptions::default(), control)
3382            .expect_err("pre-cancelled JPEG encoding must fail");
3383        assert!(matches!(
3384            error,
3385            ExportError::Cancelled(OperationCancelled {
3386                phase: OperationPhase::Export,
3387                ..
3388            })
3389        ));
3390        assert!(error.resource_limit_details().is_none());
3391    }
3392}
3393
3394#[cfg(all(test, any(feature = "png", feature = "jpeg")))]
3395mod root_svg_metadata_tests {
3396    use super::*;
3397
3398    #[test]
3399    fn metadata_reads_only_the_root_svg_attributes() {
3400        let metadata = parse_root_svg_metadata(
3401            r#"<svg xmlns="http://www.w3.org/2000/svg" style="content: 'max-width: 9000px'; max-width: 400px"><g viewBox="0 0 9000 9000"/><text>viewBox=&quot;0 0 8000 8000&quot;</text></svg>"#,
3402            &OperationControl::new(),
3403        )
3404        .expect("root SVG metadata");
3405
3406        assert!(!metadata.has_view_box);
3407        assert_eq!(metadata.max_width_px, Some(400.0));
3408    }
3409
3410    #[test]
3411    fn metadata_uses_svg_number_and_css_token_grammar() {
3412        let metadata = parse_root_svg_metadata(
3413            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="-1.5-2,41.5,120 trailing" style="max-width: 400px nonsense"/>"#,
3414            &OperationControl::new(),
3415        )
3416        .expect("root SVG metadata");
3417
3418        assert!(metadata.has_view_box);
3419        assert_eq!(metadata.max_width_px, None);
3420    }
3421
3422    #[test]
3423    fn metadata_ignores_unknown_namespaces_and_uses_first_usvg_projection() {
3424        let metadata = parse_root_svg_metadata(
3425            r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:i="urn:ignored" xmlns:s="http://www.w3.org/2000/svg" i:viewBox="0 0 9000 9000" s:viewBox="invalid" viewBox="0 0 20 10" i:style="max-width: 9000px" s:style="max-width: 200px" style="max-width: 400px"/>"#,
3426            &OperationControl::new(),
3427        )
3428        .expect("root SVG metadata");
3429
3430        assert!(
3431            !metadata.has_view_box,
3432            "the first usvg-projected viewBox is invalid, so a later alias must not replace it"
3433        );
3434        assert_eq!(
3435            metadata.max_width_px,
3436            Some(400.0),
3437            "usvg consumes only the unbound style declaration list"
3438        );
3439    }
3440}
3441
3442#[cfg(all(test, feature = "pdf"))]
3443mod pdf_feature_tests {
3444    use super::*;
3445
3446    fn compatible_svg() -> ResvgCompatibleSvg {
3447        let session = merman_render::environment::RenderEnvironment::deterministic()
3448            .begin_session()
3449            .expect("deterministic render session");
3450        merman_render::svg::finalize_resvg_svg(
3451            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="black"/></svg>"#,
3452            &session,
3453        )
3454        .expect("sealed SVG")
3455    }
3456
3457    #[test]
3458    fn pdf_leaf_encodes_a_sealed_svg() {
3459        let bytes = svg_to_pdf(&compatible_svg())
3460            .expect("PDF export should be callable when its leaf is enabled");
3461
3462        assert!(bytes.starts_with(b"%PDF-"));
3463    }
3464
3465    #[test]
3466    fn pre_cancelled_pdf_returns_no_prepared_artifact_or_bytes() {
3467        let svg = compatible_svg();
3468        let control = OperationControl::new();
3469        control.cancel();
3470
3471        let prepared_error =
3472            match prepare_pdf_controlled(&svg, &PdfOptions::default(), control.clone()) {
3473                Ok(_) => panic!("pre-cancelled PDF preparation must fail"),
3474                Err(error) => error,
3475            };
3476        assert!(matches!(
3477            prepared_error,
3478            ExportError::Cancelled(OperationCancelled {
3479                phase: OperationPhase::Export,
3480                ..
3481            })
3482        ));
3483        assert!(prepared_error.resource_limit_details().is_none());
3484
3485        let bytes_error = svg_to_pdf_controlled(&svg, &PdfOptions::default(), control)
3486            .expect_err("pre-cancelled PDF encoding must fail");
3487        assert!(matches!(
3488            bytes_error,
3489            ExportError::Cancelled(OperationCancelled {
3490                phase: OperationPhase::Export,
3491                ..
3492            })
3493        ));
3494    }
3495}
3496
3497#[cfg(all(test, feature = "png", feature = "jpeg", feature = "pdf"))]
3498mod tests {
3499    use super::*;
3500    use base64::Engine as _;
3501
3502    #[test]
3503    fn backend_tree_rechecks_preserve_the_render_owned_resource_phase() {
3504        for limit_name in [
3505            merman_render::resources::SVG_BACKEND_TREE_NODES_HARD_CAP_ID,
3506            merman_render::resources::SVG_BACKEND_TREE_DEPTH_HARD_CAP_ID,
3507        ] {
3508            let details = ExportError::SvgConversionLimit {
3509                limit_name,
3510                actual: 2,
3511                max: 1,
3512            }
3513            .resource_limit_details()
3514            .expect("backend hard cap details");
3515
3516            assert_eq!(details.limit_id, limit_name);
3517            assert_eq!(details.phase, "svg_postprocess");
3518            assert_eq!(details.actual, 2);
3519            assert_eq!(details.max, 1);
3520            assert_eq!(
3521                ExportError::SvgConversionLimit {
3522                    limit_name,
3523                    actual: 2,
3524                    max: 1,
3525                }
3526                .resource_limit_provenance()
3527                .expect("backend hard cap provenance")
3528                .domain,
3529                OperationResourceDomain::Render
3530            );
3531            assert_eq!(export_resource_limit_output_ids(limit_name), None);
3532            assert_eq!(
3533                export_resource_profile_value(
3534                    merman_render::resources::RenderResourceProfile::Interactive,
3535                    limit_name,
3536                ),
3537                None
3538            );
3539        }
3540    }
3541
3542    fn compatible_svg(svg: &str) -> merman_render::svg::ResvgCompatibleSvg {
3543        let session = merman_render::environment::RenderEnvironment::deterministic()
3544            .begin_session()
3545            .unwrap();
3546        merman_render::svg::finalize_resvg_svg(svg, &session).unwrap()
3547    }
3548
3549    #[test]
3550    fn backend_failure_observes_cancellation_before_returning_the_backend_error() {
3551        let control = OperationControl::new();
3552        let job_control = control.clone();
3553        let error = run_recursive_svg_backend(&control, move |_| -> Result<()> {
3554            job_control.cancel();
3555            Err(ExportError::SvgParse)
3556        })
3557        .expect_err("the post-backend checkpoint must observe cancellation");
3558
3559        assert!(matches!(
3560            error,
3561            ExportError::Cancelled(OperationCancelled {
3562                phase: OperationPhase::Export,
3563                ..
3564            })
3565        ));
3566    }
3567
3568    #[test]
3569    fn controlled_export_replays_the_first_resource_terminal_after_later_cancellation() {
3570        let href = png_data_uri_with_declared_size(1, 1);
3571        let source = format!(
3572            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><image href="{href}" width="10" height="10"/></svg>"#
3573        );
3574        let svg = compatible_svg(&source);
3575        let options = RasterOptions {
3576            embedded_image_limit: EmbeddedImageLimit::new(Some(16), None, None, None),
3577            ..RasterOptions::default()
3578        };
3579        let control = OperationControl::new();
3580        let first = match super::prepare_raster_controlled(&svg, &options, control.clone()) {
3581            Ok(_) => panic!("the embedded-image byte limit must reject preparation"),
3582            Err(error) => error,
3583        };
3584        let first_details = first
3585            .resource_limit_details()
3586            .expect("the first rejection must expose resource metadata");
3587        let first_terminal = control
3588            .terminal_checkpoint_at(OperationPhase::Export)
3589            .expect_err("the first export resource rejection must latch the operation terminal");
3590        assert!(matches!(
3591            &first_terminal,
3592            OperationLedgerError::ResourceLimitExceeded(error)
3593                if error.id == MAX_EMBEDDED_IMAGE_BYTES_RESOURCE_LIMIT_ID
3594                    && error.resource_phase == "embedded_image_decode"
3595                    && error.limit == first_details.max
3596                    && error.consumed.saturating_add(error.requested) == first_details.actual
3597                    && error.provenance.domain == OperationResourceDomain::Export
3598                    && error.provenance.profile.is_none()
3599                    && error.provenance.explicit_overrides.is_empty()
3600        ));
3601
3602        control.cancel();
3603        let replay = match super::prepare_raster_controlled(
3604            &compatible_svg(
3605                r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><rect width="1" height="1"/></svg>"#,
3606            ),
3607            &RasterOptions::default(),
3608            control,
3609        ) {
3610            Ok(_) => panic!("the existing terminal must prevent a second prepared artifact"),
3611            Err(error) => error,
3612        };
3613
3614        assert_eq!(replay.resource_limit_details(), Some(first_details));
3615        assert!(matches!(
3616            replay,
3617            ExportError::OperationResourceTerminal(error) if error == first_terminal
3618        ));
3619        assert_eq!(
3620            first_details.limit_id,
3621            MAX_EMBEDDED_IMAGE_BYTES_RESOURCE_LIMIT_ID
3622        );
3623        assert_eq!(first_details.phase, "embedded_image_decode");
3624        assert_eq!(first_details.cause, ExportResourceLimitCause::Ceiling);
3625    }
3626
3627    #[test]
3628    fn export_checkpoint_preserves_foreign_resource_terminal_provenance() {
3629        let control = OperationControl::new();
3630        let provenance = OperationResourceProvenance::new(
3631            OperationResourceDomain::Render,
3632            Some(merman_core::resources::ResourceProfile::Constrained),
3633            [merman_core::OperationResourceOverride {
3634                id: "max_svg_bytes",
3635                value: 17,
3636            }],
3637        );
3638        let terminal = control.terminate_resource_overflow(
3639            "max_svg_bytes",
3640            OperationPhase::Postprocess,
3641            "svg_postprocess",
3642            u64::MAX,
3643            17,
3644            provenance,
3645        );
3646
3647        let error = export_checkpoint(&control)
3648            .expect_err("the export adapter must replay the foreign terminal");
3649
3650        assert!(matches!(
3651            error,
3652            ExportError::OperationResourceTerminal(actual) if actual == terminal
3653        ));
3654    }
3655
3656    #[test]
3657    fn exporter_image_resolver_never_reads_string_hrefs() {
3658        let resolver = data_url_only_image_href_resolver();
3659        let options = usvg::Options::default();
3660
3661        for href in [
3662            "/tmp/secret.png",
3663            "../secret.png",
3664            r"\\server\share\secret.png",
3665            r"C:\private\secret.png",
3666            "https://example.com/remote.png",
3667        ] {
3668            assert!(
3669                (resolver.resolve_string)(href, &options).is_none(),
3670                "string href unexpectedly resolved: {href}"
3671            );
3672        }
3673    }
3674
3675    #[test]
3676    fn export_environment_contract_matches_font_and_image_owners() {
3677        for output_id in ["jpeg", "pdf", "png"] {
3678            let contract = output_environment_contract(output_id)
3679                .expect("each compiled export must disclose its environment");
3680            let system_fonts = contract
3681                .system_fonts
3682                .expect("native exports must disclose host system fonts");
3683            assert_eq!(system_fonts.source_id, "host-system");
3684            assert_eq!(system_fonts.discovery, "first-use");
3685            assert_eq!(system_fonts.cache_scope, "process-global");
3686            assert!(system_fonts.host_dependent);
3687            assert!(!system_fonts.resource_bounded);
3688            assert_eq!(contract.embedded_images.source_ids, ["data-url"]);
3689            assert!(!contract.embedded_images.filesystem_access);
3690            assert!(!contract.embedded_images.network_access);
3691            assert_eq!(
3692                contract.embedded_images.default_limits,
3693                EmbeddedImageLimit::default()
3694            );
3695        }
3696        assert!(output_environment_contract("svg").is_none());
3697        assert!(output_environment_contract("ascii").is_none());
3698    }
3699
3700    #[test]
3701    fn system_font_database_is_process_cached() {
3702        let first = shared_system_fontdb();
3703        let second = shared_system_fontdb();
3704        assert!(Arc::ptr_eq(&first, &second));
3705    }
3706
3707    fn trusted_compatible_svg(svg: &str) -> merman_render::svg::ResvgCompatibleSvg {
3708        let session = merman_render::environment::RenderEnvironment::deterministic()
3709            .with_resource_policy(merman_render::resources::RenderResourcePolicy::trusted_native())
3710            .begin_session()
3711            .unwrap();
3712        merman_render::svg::finalize_resvg_svg(svg, &session).unwrap()
3713    }
3714
3715    fn svg_to_png(svg: &str, options: &RasterOptions) -> Result<Vec<u8>> {
3716        super::svg_to_png(&compatible_svg(svg), options)
3717    }
3718
3719    fn svg_to_jpeg(svg: &str, options: &RasterOptions) -> Result<Vec<u8>> {
3720        super::svg_to_jpeg(&compatible_svg(svg), options)
3721    }
3722
3723    fn svg_to_pdf(svg: &str) -> Result<Vec<u8>> {
3724        super::svg_to_pdf(&compatible_svg(svg))
3725    }
3726
3727    fn nested_group_svg(depth: usize) -> String {
3728        let mut svg =
3729            String::from(r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10">"#);
3730        svg.push_str(&r#"<g opacity="0.999">"#.repeat(depth - 1));
3731        svg.push_str(r#"<rect width="10" height="10" fill="black"/>"#);
3732        svg.push_str(&"</g>".repeat(depth - 1));
3733        svg.push_str("</svg>");
3734        svg
3735    }
3736
3737    fn expanded_use_chain_svg(depth: usize) -> String {
3738        let mut svg =
3739            String::from(r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><defs>"#);
3740        for index in 0..depth {
3741            if index + 1 == depth {
3742                svg.push_str(&format!(
3743                    r#"<g id="use-{index}" opacity="0.999"><rect width="10" height="10"/></g>"#
3744                ));
3745            } else {
3746                svg.push_str(&format!(
3747                    r##"<g id="use-{index}" opacity="0.999"><use href="#use-{}"/></g>"##,
3748                    index + 1
3749                ));
3750            }
3751        }
3752        svg.push_str(r##"</defs><use href="#use-0"/></svg>"##);
3753        svg
3754    }
3755
3756    fn branching_data_image_use_svg(levels: usize, href: &str) -> String {
3757        let mut svg =
3758            String::from(r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><defs>"#);
3759        svg.push_str(&format!(
3760            r#"<g id="leaf"><image href="{href}" width="1" height="1"/></g>"#
3761        ));
3762        for index in 0..levels {
3763            let target = if index + 1 == levels {
3764                "leaf".to_owned()
3765            } else {
3766                format!("use-{}", index + 1)
3767            };
3768            svg.push_str(&format!(
3769                r##"<g id="use-{index}"><use href="#{target}"/><use href="#{target}"/></g>"##
3770            ));
3771        }
3772        svg.push_str(r##"</defs><use href="#use-0"/></svg>"##);
3773        svg
3774    }
3775
3776    fn svg_to_pdf_with_options(svg: &str, options: &PdfOptions) -> Result<Vec<u8>> {
3777        super::svg_to_pdf_with_options(&compatible_svg(svg), options)
3778    }
3779
3780    fn prepare_pdf(svg: &str, options: &PdfOptions) -> Result<PreparedPdf> {
3781        super::prepare_pdf(&compatible_svg(svg), options)
3782    }
3783
3784    fn prepare_raster(svg: &str, options: &RasterOptions) -> Result<PreparedRaster> {
3785        super::prepare_raster(&compatible_svg(svg), options)
3786    }
3787
3788    fn svg_raster_plan(svg: &str, options: &RasterOptions) -> Result<RasterPlan> {
3789        super::svg_raster_plan(&compatible_svg(svg), options)
3790    }
3791
3792    #[test]
3793    fn svg_to_png_produces_png_signature() {
3794        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="black"/></svg>"#;
3795        let bytes = svg_to_png(svg, &RasterOptions::default()).unwrap();
3796        assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"));
3797    }
3798
3799    #[test]
3800    fn root_viewport_dimensions_drive_raster_planning_and_pixels() {
3801        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="0 0 10 10"><rect width="10" height="10" fill="black"/></svg>"#;
3802        let options = RasterOptions::default();
3803        let plan = svg_raster_plan(svg, &options).unwrap();
3804
3805        assert_eq!(plan.requested_width_px, 200.0);
3806        assert_eq!(plan.requested_height_px, 100.0);
3807        assert_eq!((plan.width_px, plan.height_px), (200, 100));
3808
3809        let bytes = svg_to_png(svg, &options).unwrap();
3810        assert_eq!(png_size(&bytes), (200, 100));
3811        let center = rgba_pixel(&bytes, 100, 50);
3812        let side = rgba_pixel(&bytes, 10, 50);
3813        assert!(
3814            center[0] < 8 && center[1] < 8 && center[2] < 8 && center[3] > 247,
3815            "expected the viewBox content at the viewport center, got {center:?}"
3816        );
3817        assert_eq!(
3818            side,
3819            [0, 0, 0, 0],
3820            "preserveAspectRatio should leave transparent side padding"
3821        );
3822    }
3823
3824    #[test]
3825    fn ignored_and_later_viewbox_aliases_do_not_change_usvg_geometry() {
3826        for svg in [
3827            r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:i="urn:ignored" i:viewBox="0 0 9000 9000"><rect width="12" height="8" fill="black"/></svg>"#,
3828            r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:s="http://www.w3.org/2000/svg" s:viewBox="invalid" viewBox="0 0 9000 9000"><rect width="12" height="8" fill="black"/></svg>"#,
3829        ] {
3830            let plan = svg_raster_plan(svg, &RasterOptions::default()).unwrap();
3831            assert_eq!(
3832                (plan.width_px, plan.height_px),
3833                (12, 8),
3834                "metadata and usvg must agree on the effective viewBox: {svg}"
3835            );
3836        }
3837    }
3838
3839    #[test]
3840    fn non_positive_root_dimensions_cannot_cross_the_sealed_raster_boundary() {
3841        let session = merman_render::environment::RenderEnvironment::deterministic()
3842            .begin_session()
3843            .unwrap();
3844
3845        for svg in [
3846            r#"<svg xmlns="http://www.w3.org/2000/svg" width="0" height="100" viewBox="0 0 10 10"/>"#,
3847            r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="-1" viewBox="0 0 10 10"/>"#,
3848        ] {
3849            let error = merman_render::svg::finalize_resvg_svg(svg, &session)
3850                .expect_err("invalid root dimensions must fail before raster preparation");
3851            assert!(
3852                error.to_string().contains("must be a positive length"),
3853                "{error}"
3854            );
3855        }
3856    }
3857
3858    #[test]
3859    #[cfg(not(target_arch = "wasm32"))]
3860    fn scheduling_weights_include_the_recursive_backend_stack() {
3861        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10"/></svg>"#;
3862        let raster = prepare_raster(svg, &RasterOptions::default()).unwrap();
3863        let pdf = prepare_pdf(svg, &PdfOptions::default()).unwrap();
3864
3865        assert!(raster.png_scheduling_weight_bytes() >= RECURSIVE_SVG_BACKEND_STACK_BYTES as u64);
3866        assert!(pdf.scheduling_weight_bytes() >= RECURSIVE_SVG_BACKEND_STACK_BYTES as u64);
3867    }
3868
3869    #[test]
3870    fn trusted_resvg_backend_handles_the_declared_tree_depth() {
3871        let depth = merman_render::resources::MAX_RESVG_TREE_DEPTH;
3872        let svg = trusted_compatible_svg(&nested_group_svg(depth));
3873        let options =
3874            RasterOptions::default().with_conversion_limits(SvgConversionLimits::unbounded());
3875        let prepared = super::prepare_raster(&svg, &options).unwrap();
3876        assert_eq!(prepared.conversion_plan().max_tree_depth, depth - 1);
3877
3878        let bytes = prepared.encode_png().unwrap();
3879        assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"));
3880    }
3881
3882    #[test]
3883    fn trusted_krilla_backend_handles_the_declared_tree_depth() {
3884        let depth = merman_render::resources::MAX_RESVG_TREE_DEPTH;
3885        let svg = trusted_compatible_svg(&nested_group_svg(depth));
3886        let options =
3887            PdfOptions::default().with_conversion_limits(SvgConversionLimits::unbounded());
3888        let prepared = super::prepare_pdf(&svg, &options).unwrap();
3889        assert_eq!(prepared.conversion_plan().max_tree_depth, depth - 1);
3890
3891        let bytes = prepared.encode().unwrap();
3892        assert!(bytes.starts_with(b"%PDF-"));
3893    }
3894
3895    #[test]
3896    fn expanded_use_tree_is_rejected_before_usvg_parsing() {
3897        let session = merman_render::environment::RenderEnvironment::deterministic()
3898            .with_resource_policy(merman_render::resources::RenderResourcePolicy::trusted_native())
3899            .begin_session()
3900            .unwrap();
3901        let error = merman_render::svg::finalize_resvg_svg(
3902            &expanded_use_chain_svg(merman_render::resources::MAX_RESVG_TREE_DEPTH + 2),
3903            &session,
3904        )
3905        .expect_err("the reference preflight must reject the expanded usvg depth");
3906
3907        assert!(
3908            error
3909                .to_string()
3910                .contains(merman_render::resources::SVG_BACKEND_TREE_DEPTH_HARD_CAP_ID),
3911            "{error}"
3912        );
3913    }
3914
3915    #[test]
3916    fn expanded_use_data_urls_count_toward_the_aggregate_before_usvg_parsing() {
3917        let href = png_data_uri_with_declared_size(1, 1);
3918        let svg = compatible_svg(&branching_data_image_use_svg(4, &href));
3919        let one_source_resource =
3920            plan_embedded_data_resources(svg.as_str(), EmbeddedImageLimit::unbounded())
3921                .unwrap()
3922                .total_bytes;
3923        let options = RasterOptions {
3924            embedded_image_limit: EmbeddedImageLimit::new(
3925                Some(one_source_resource),
3926                Some(one_source_resource),
3927                None,
3928                None,
3929            ),
3930            ..RasterOptions::default()
3931        };
3932
3933        let error = super::prepare_raster(&svg, &options)
3934            .err()
3935            .expect("expanded data URLs must exceed the aggregate preflight budget");
3936
3937        assert!(error.to_string().contains("max_total_bytes"), "{error}");
3938    }
3939
3940    #[test]
3941    fn filter_and_marker_subroots_count_repeated_data_urls_before_usvg_parsing() {
3942        let href = png_data_uri_with_declared_size(1, 1);
3943        let cases = [
3944            format!(
3945                r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><defs><image id="source" href="{href}" width="1" height="1"/><filter id="f"><feImage href="#source"/></filter></defs><rect width="10" height="10" filter="url(#f)"/></svg>"##
3946            ),
3947            format!(
3948                r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><defs><marker id="m"><image href="{href}" width="1" height="1"/></marker></defs><path d="M0 0L1 1L2 2L3 3" marker-mid="url(#m)"/></svg>"##
3949            ),
3950        ];
3951
3952        for raw in cases {
3953            let one_source_resource =
3954                plan_embedded_data_resources(&raw, EmbeddedImageLimit::unbounded())
3955                    .unwrap()
3956                    .total_bytes;
3957            let svg = compatible_svg(&raw);
3958            let options = RasterOptions {
3959                embedded_image_limit: EmbeddedImageLimit::new(
3960                    Some(one_source_resource),
3961                    Some(one_source_resource),
3962                    None,
3963                    None,
3964                ),
3965                ..RasterOptions::default()
3966            };
3967
3968            let error = super::prepare_raster(&svg, &options)
3969                .err()
3970                .expect("repeated filter or marker data URLs must exceed the preflight budget");
3971            assert!(error.to_string().contains("max_total_bytes"), "{error}");
3972        }
3973    }
3974
3975    #[test]
3976    fn css_effect_fanout_counts_filter_and_mask_data_urls_before_usvg_parsing() {
3977        let href = png_data_uri_with_declared_size(1, 1);
3978        let one_resource = plan_embedded_data_resources(
3979            &format!(r#"<svg xmlns="http://www.w3.org/2000/svg"><image href="{href}"/></svg>"#),
3980            EmbeddedImageLimit::unbounded(),
3981        )
3982        .unwrap()
3983        .total_bytes;
3984        let raw = format!(
3985            r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><defs><filter id="f"><feImage href="{href}"/></filter><mask id="m"><image href="{href}" width="1" height="1"/></mask></defs><style>.affected {{ filter: url(#f); mask: url(#m); }}</style><rect class="affected" width="1" height="1"/><rect class="affected" x="2" width="1" height="1"/><rect class="affected" x="4" width="1" height="1"/></svg>"##
3986        );
3987        let svg = compatible_svg(&raw);
3988        let options = RasterOptions {
3989            embedded_image_limit: EmbeddedImageLimit::new(
3990                Some(one_resource),
3991                Some(one_resource.saturating_mul(2)),
3992                None,
3993                None,
3994            ),
3995            ..RasterOptions::default()
3996        };
3997
3998        let error = super::prepare_raster(&svg, &options)
3999            .err()
4000            .expect("CSS effect fanout must exceed the aggregate preflight budget");
4001
4002        assert!(error.to_string().contains("max_total_bytes"), "{error}");
4003    }
4004
4005    #[test]
4006    fn svg_to_png_does_not_load_local_image_hrefs() {
4007        let local_image = TempFile::new("png", encode_rgba_png(1, 1, &[255, 0, 0, 255]));
4008        let href = escape_xml_attr(&local_image.href_path());
4009        let svg = format!(
4010            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="white"/><image href="{href}" width="10" height="10"/></svg>"#
4011        );
4012
4013        let bytes = svg_to_png(&svg, &RasterOptions::default()).unwrap();
4014        let center = rgba_pixel(&bytes, 5, 5);
4015
4016        assert!(
4017            center[0] > 240 && center[1] > 240 && center[2] > 240,
4018            "expected local image href to be ignored, got center pixel {center:?}"
4019        );
4020    }
4021
4022    #[test]
4023    fn embedded_image_limits_reject_large_decode_before_png_or_pdf_encoding() {
4024        let href = png_data_uri_with_declared_size(100_000, 100_000);
4025        let svg = format!(
4026            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><image href="{href}" width="10" height="10"/></svg>"#
4027        );
4028
4029        let png_err = prepare_raster(&svg, &RasterOptions::default())
4030            .err()
4031            .expect("PNG preparation should reject the decoded image size");
4032        let pdf_err = prepare_pdf(&svg, &PdfOptions::default())
4033            .err()
4034            .expect("PDF preparation should reject the decoded image size");
4035
4036        assert!(png_err.to_string().contains("max_pixels_per_image"));
4037        assert!(pdf_err.to_string().contains("max_pixels_per_image"));
4038    }
4039
4040    #[test]
4041    fn embedded_image_plan_reports_intrinsic_pixels_from_headers() {
4042        let href = png_data_uri_with_declared_size(2, 3);
4043        let svg = format!(
4044            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><image href="{href}" width="10" height="10"/></svg>"#
4045        );
4046        let prepared = prepare_raster(&svg, &RasterOptions::default()).unwrap();
4047
4048        assert_eq!(
4049            prepared.embedded_image_plan(),
4050            EmbeddedImagePlan {
4051                data_resources: 1,
4052                raster_images: 1,
4053                largest_data_bytes: 68,
4054                total_data_bytes: 68,
4055                largest_raster_pixels: 6,
4056                total_pixels: 6,
4057            }
4058        );
4059    }
4060
4061    #[test]
4062    fn namespaced_image_and_filter_image_share_href_byte_limits() {
4063        let href = png_data_uri_with_declared_size(1, 1);
4064        let single_svg =
4065            format!(r#"<svg xmlns="http://www.w3.org/2000/svg"><image href="{href}"/></svg>"#);
4066        let bytes_per_resource =
4067            plan_embedded_data_resources(&single_svg, EmbeddedImageLimit::unbounded())
4068                .unwrap()
4069                .total_bytes;
4070        assert!(bytes_per_resource > 0);
4071        let total_bytes = bytes_per_resource * 2;
4072        let svg = format!(
4073            r#"<svg:svg xmlns:svg="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><svg:image href="{href}"/><svg:defs><svg:filter id="f"><svg:feImage xlink:href="{href}"/></svg:filter></svg:defs></svg:svg>"#
4074        );
4075
4076        let plan = plan_embedded_data_resources(
4077            &svg,
4078            EmbeddedImageLimit::new(Some(bytes_per_resource), Some(total_bytes), None, None),
4079        )
4080        .expect("the exact per-resource and aggregate limits should be accepted");
4081        assert_eq!(plan.resources, 2);
4082        assert_eq!(plan.largest_bytes, bytes_per_resource);
4083        assert_eq!(plan.total_bytes, total_bytes);
4084
4085        let per_resource_error = plan_embedded_data_resources(
4086            &svg,
4087            EmbeddedImageLimit::new(Some(bytes_per_resource - 1), None, None, None),
4088        )
4089        .expect_err("both image element kinds must enforce the per-resource byte limit");
4090        assert!(matches!(
4091            per_resource_error,
4092            ExportError::EmbeddedImageLimit {
4093                limit_name: "max_bytes_per_image",
4094                actual,
4095                max,
4096            } if actual > max
4097        ));
4098
4099        let aggregate_error = plan_embedded_data_resources(
4100            &svg,
4101            EmbeddedImageLimit::new(Some(bytes_per_resource), Some(total_bytes - 1), None, None),
4102        )
4103        .expect_err("feImage must contribute to the aggregate byte limit");
4104        assert!(matches!(
4105            aggregate_error,
4106            ExportError::EmbeddedImageLimit {
4107                limit_name: "max_total_bytes",
4108                actual,
4109                max,
4110            } if actual > max
4111        ));
4112    }
4113
4114    #[test]
4115    fn external_image_hrefs_remain_outside_the_data_url_budget() {
4116        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><image href="https://example.invalid/image.png"/><defs><filter id="f"><feImage xlink:href="file:///tmp/image.png"/></filter></defs></svg>"#;
4117
4118        let plan = plan_embedded_data_resources(
4119            svg,
4120            EmbeddedImageLimit::new(Some(1), Some(1), None, None),
4121        )
4122        .expect("external references are handled by the separate href resolver policy");
4123
4124        assert_eq!(plan.resources, 0);
4125        assert_eq!(plan.largest_bytes, 0);
4126        assert_eq!(plan.total_bytes, 0);
4127    }
4128
4129    #[test]
4130    fn embedded_image_bytes_are_limited_before_usvg_decodes_data_urls() {
4131        let href = png_data_uri_with_declared_size(1, 1);
4132        let svg = format!(
4133            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><image href="{href}" width="10" height="10"/></svg>"#
4134        );
4135        let options = RasterOptions {
4136            embedded_image_limit: EmbeddedImageLimit::new(Some(16), None, None, None),
4137            ..RasterOptions::default()
4138        };
4139
4140        let error = prepare_raster(&svg, &options)
4141            .err()
4142            .expect("data URL should be rejected before usvg parsing");
4143
4144        assert!(error.to_string().contains("max_bytes_per_image"));
4145    }
4146
4147    #[test]
4148    fn filter_image_bytes_are_limited_before_usvg_decodes_data_urls() {
4149        let href = png_data_uri_with_declared_size(1, 1);
4150        let svg = format!(
4151            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><defs><filter id="f"><feImage href="{href}"/></filter></defs><rect width="10" height="10" filter="url(#f)"/></svg>"#
4152        );
4153        let raster_options = RasterOptions {
4154            embedded_image_limit: EmbeddedImageLimit::new(Some(16), None, None, None),
4155            ..RasterOptions::default()
4156        };
4157        let pdf_options = PdfOptions {
4158            embedded_image_limit: EmbeddedImageLimit::new(Some(16), None, None, None),
4159            ..PdfOptions::default()
4160        };
4161
4162        let png_error = prepare_raster(&svg, &raster_options)
4163            .err()
4164            .expect("filter data URL should be rejected before raster usvg parsing");
4165        let pdf_error = prepare_pdf(&svg, &pdf_options)
4166            .err()
4167            .expect("filter data URL should be rejected before PDF usvg parsing");
4168
4169        assert!(png_error.to_string().contains("max_bytes_per_image"));
4170        assert!(pdf_error.to_string().contains("max_bytes_per_image"));
4171    }
4172
4173    #[test]
4174    fn filter_image_pixels_are_limited_after_header_decode() {
4175        let href = png_data_uri_with_declared_size(100_000, 100_000);
4176        let svg = format!(
4177            r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 10 10"><defs><filter id="f"><feImage xlink:href="{href}"/></filter></defs><rect width="10" height="10" filter="url(#f)"/></svg>"#
4178        );
4179
4180        let png_error = prepare_raster(&svg, &RasterOptions::default())
4181            .err()
4182            .expect("filter image intrinsic pixels should be bounded for raster output");
4183        let pdf_error = prepare_pdf(&svg, &PdfOptions::default())
4184            .err()
4185            .expect("filter image intrinsic pixels should be bounded for PDF output");
4186
4187        assert!(png_error.to_string().contains("max_pixels_per_image"));
4188        assert!(pdf_error.to_string().contains("max_pixels_per_image"));
4189    }
4190
4191    #[test]
4192    fn embedded_image_limits_cover_pattern_subroots() {
4193        let href = png_data_uri_with_declared_size(100, 100);
4194        let svg = format!(
4195            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><defs><pattern id="p" patternUnits="userSpaceOnUse" width="1" height="1"><image href="{href}" width="1" height="1"/></pattern></defs><rect width="10" height="10" fill="url(#p)"/></svg>"#
4196        );
4197
4198        let options = RasterOptions {
4199            embedded_image_limit: EmbeddedImageLimit::new(None, None, Some(10), None),
4200            ..RasterOptions::default()
4201        };
4202        let error = prepare_raster(&svg, &options)
4203            .err()
4204            .expect("pattern raster image should be checked");
4205
4206        assert!(error.to_string().contains("max_pixels_per_image"));
4207    }
4208
4209    #[test]
4210    fn svg_to_pdf_produces_pdf_signature() {
4211        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="black"/></svg>"#;
4212        let bytes = svg_to_pdf(svg).unwrap();
4213        assert!(bytes.starts_with(b"%PDF-"));
4214    }
4215
4216    #[test]
4217    fn fixed_pdf_page_policy_uses_requested_media_box() {
4218        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 20"><rect width="10" height="20" fill="black"/></svg>"#;
4219        let bytes = svg_to_pdf_with_options(
4220            svg,
4221            &PdfOptions::default()
4222                .with_background("white")
4223                .with_page_policy(PdfPagePolicy::Fixed {
4224                    width_pt: 612.0,
4225                    height_pt: 792.0,
4226                }),
4227        )
4228        .unwrap();
4229        let pdf = String::from_utf8_lossy(&bytes);
4230        let media_box = pdf
4231            .find("/MediaBox")
4232            .map(|start| &pdf[start..pdf.len().min(start + 80)])
4233            .expect("fixed PDF media box");
4234
4235        assert!(
4236            media_box.contains("612") && media_box.contains("792"),
4237            "{media_box}"
4238        );
4239    }
4240
4241    #[test]
4242    fn fixed_pdf_page_policy_rejects_invalid_page_dimensions() {
4243        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"/>"#;
4244        let err = svg_to_pdf_with_options(
4245            svg,
4246            &PdfOptions::default().with_page_policy(PdfPagePolicy::Fixed {
4247                width_pt: f32::NAN,
4248                height_pt: 792.0,
4249            }),
4250        )
4251        .unwrap_err();
4252
4253        assert!(
4254            err.to_string().contains("fixed PDF page dimensions"),
4255            "{err}"
4256        );
4257    }
4258
4259    #[test]
4260    fn css_width_pdf_page_policy_matches_browser_pixel_to_point_sizing() {
4261        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 9000 9000"><rect width="9000" height="9000" fill="black"/></svg>"#;
4262        let bytes = svg_to_pdf_with_options(
4263            svg,
4264            &PdfOptions::default().with_page_policy(PdfPagePolicy::FitCssWidth {
4265                max_width_px: 800.0,
4266            }),
4267        )
4268        .unwrap();
4269        let pdf = String::from_utf8_lossy(&bytes);
4270        let media_box = pdf
4271            .find("/MediaBox")
4272            .map(|start| &pdf[start..pdf.len().min(start + 80)])
4273            .expect("CSS-sized PDF media box");
4274
4275        assert!(media_box.contains("600"), "{media_box}");
4276    }
4277
4278    #[test]
4279    fn fixed_pdf_page_scales_large_vector_source_without_pixel_allocation_limits() {
4280        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 9000 9000"><rect width="9000" height="9000" fill="black"/></svg>"#;
4281        let bytes = svg_to_pdf_with_options(
4282            svg,
4283            &PdfOptions::default().with_page_policy(PdfPagePolicy::Fixed {
4284                width_pt: 612.0,
4285                height_pt: 792.0,
4286            }),
4287        )
4288        .unwrap();
4289
4290        assert!(bytes.starts_with(b"%PDF-"));
4291    }
4292
4293    #[test]
4294    fn svg_to_pdf_preserves_large_intrinsic_vector_page() {
4295        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 9000 9000"><rect width="9000" height="9000" fill="black"/></svg>"#;
4296        let bytes = svg_to_pdf(svg).unwrap();
4297        let pdf = String::from_utf8_lossy(&bytes);
4298        let media_box = pdf
4299            .find("/MediaBox")
4300            .map(|start| &pdf[start..pdf.len().min(start + 80)])
4301            .expect("large PDF media box");
4302
4303        assert!(
4304            media_box.contains("9000"),
4305            "large vector dimensions should survive PDF conversion: {media_box}"
4306        );
4307    }
4308
4309    #[test]
4310    fn svg_to_pdf_rejects_invalid_filter_scale() {
4311        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"/>"#;
4312        let err = svg_to_pdf_with_options(svg, &PdfOptions::default().with_filter_scale(0.0))
4313            .unwrap_err();
4314
4315        assert!(err.to_string().contains("PDF filter_scale"), "{err}");
4316    }
4317
4318    #[test]
4319    fn pdf_filter_plan_bounds_aggregate_localized_rasterization() {
4320        let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10000 10000">
4321          <defs><filter id="blur"><feGaussianBlur stdDeviation="2"/></filter></defs>
4322          <g filter="url(#blur)"><rect width="10000" height="10000" fill="black"/></g>
4323          <g filter="url(#blur)"><rect width="10000" height="10000" fill="white"/></g>
4324        </svg>"##;
4325        let prepared = prepare_pdf(svg, &PdfOptions::default()).unwrap();
4326        let plan = prepared.filter_plan();
4327
4328        assert_eq!(plan.filtered_groups, 2);
4329        assert_eq!(plan.requested_image_pixels, 50_000_000);
4330        assert!(plan.limited, "{plan:?}");
4331        assert!(
4332            plan.effective_image_pixels <= DEFAULT_MAX_PDF_FILTER_IMAGE_PIXELS,
4333            "{plan:?}"
4334        );
4335        assert!(plan.effective_scale < plan.requested_scale, "{plan:?}");
4336    }
4337
4338    #[test]
4339    fn pdf_filter_plan_allows_explicit_trusted_unbounded_policy() {
4340        let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10000 10000">
4341          <defs><filter id="blur"><feGaussianBlur stdDeviation="2"/></filter></defs>
4342          <g filter="url(#blur)"><rect width="10000" height="10000" fill="black"/></g>
4343          <g filter="url(#blur)"><rect width="10000" height="10000" fill="white"/></g>
4344        </svg>"##;
4345        let prepared =
4346            prepare_pdf(svg, &PdfOptions::default().with_unbounded_filter_images()).unwrap();
4347        let plan = prepared.filter_plan();
4348
4349        assert_eq!(plan.requested_image_pixels, 50_000_000);
4350        assert_eq!(plan.effective_image_pixels, 50_000_000);
4351        assert!(!plan.limited, "{plan:?}");
4352    }
4353
4354    #[test]
4355    fn conversion_plan_rejects_filter_primitive_fanout_before_backend_rendering() {
4356        let primitives = (0..=DEFAULT_MAX_FILTER_PRIMITIVES_PER_FILTER)
4357            .map(|index| {
4358                format!(
4359                    r#"<feColorMatrix in="SourceGraphic" result="p{index}" type="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0"/>"#
4360                )
4361            })
4362            .collect::<String>();
4363        let svg = format!(
4364            r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><filter id="f">{primitives}</filter></defs><g filter="url(#f)"><rect width="16" height="16"/></g></svg>"#
4365        );
4366
4367        let error = prepare_pdf(&svg, &PdfOptions::default())
4368            .err()
4369            .expect("filter primitive fanout should be rejected during preparation");
4370
4371        assert!(
4372            error
4373                .to_string()
4374                .contains("max_filter_primitives_per_filter"),
4375            "{error}"
4376        );
4377    }
4378
4379    #[test]
4380    fn conversion_plan_rejects_deep_isolation_before_backend_rendering() {
4381        let depth = DEFAULT_MAX_SVG_ISOLATION_DEPTH + 1;
4382        let mut svg =
4383            String::from(r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">"#);
4384        svg.push_str(&r#"<g opacity="0.99">"#.repeat(depth));
4385        svg.push_str(r#"<rect width="16" height="16"/>"#);
4386        svg.push_str(&"</g>".repeat(depth));
4387        svg.push_str("</svg>");
4388
4389        let error = prepare_raster(&svg, &RasterOptions::default())
4390            .err()
4391            .expect("deep isolation should be rejected during preparation");
4392
4393        assert!(error.to_string().contains("max_isolation_depth"), "{error}");
4394    }
4395
4396    #[test]
4397    fn pdf_filter_plan_counts_only_krilla_owned_outer_filtered_groups() {
4398        let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
4399          <defs><filter id="blur"><feGaussianBlur stdDeviation="2"/></filter></defs>
4400          <g filter="url(#blur)"><g filter="url(#blur)"><rect width="100" height="100"/></g></g>
4401        </svg>"##;
4402
4403        let prepared = prepare_pdf(svg, &PdfOptions::default()).unwrap();
4404
4405        assert_eq!(prepared.filter_plan().filtered_groups, 1);
4406        assert_eq!(prepared.conversion_plan().filtered_groups, 2);
4407        assert_eq!(prepared.conversion_plan().filter_primitives, 2);
4408    }
4409
4410    #[test]
4411    fn svg_to_jpeg_defaults_to_white_background() {
4412        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"></svg>"#;
4413        let bytes = svg_to_jpeg(svg, &RasterOptions::default()).unwrap();
4414        let img = image::load_from_memory_with_format(&bytes, image::ImageFormat::Jpeg)
4415            .unwrap()
4416            .to_rgb8();
4417        let px = img.get_pixel(0, 0);
4418
4419        assert!(
4420            px[0] > 240 && px[1] > 240 && px[2] > 240,
4421            "expected default JPG background to be white-ish, got {px:?}"
4422        );
4423    }
4424
4425    #[test]
4426    fn jpeg_encoder_limit_is_checked_before_allocating_the_pixmap() {
4427        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 65536 1"><rect width="65536" height="1" fill="black"/></svg>"#;
4428        let err = svg_to_jpeg(svg, &RasterOptions::default().with_unbounded_size()).unwrap_err();
4429
4430        assert!(matches!(err, ExportError::JpegDimensionLimit));
4431    }
4432
4433    #[test]
4434    fn svg_to_png_keeps_text_visible_when_requested_font_is_missing() {
4435        let svg = format!(
4436            r##"<svg xmlns="http://www.w3.org/2000/svg" width="100%" style="max-width: 400px; background-color: white;"><text x="100" y="40" fill="#333333" font-size="32" style="font-family: '__merman_missing_font__'; text-anchor: middle;">v{}</text></svg>"##,
4437            merman_core::baseline::PINNED_MERMAID_BASELINE_VERSION
4438        );
4439        let bytes = svg_to_png(&svg, &RasterOptions::default()).unwrap();
4440        assert_png_has_visible_non_background_ink(&bytes);
4441    }
4442
4443    #[test]
4444    fn default_plan_downscales_large_intrinsic_svg_without_allocating() {
4445        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14544.4375 6565.5"><rect width="14544.4375" height="6565.5" fill="white"/></svg>"#;
4446        let plan = svg_raster_plan(svg, &RasterOptions::default()).unwrap();
4447
4448        assert_eq!(plan.requested_width_px, 14545.0);
4449        assert_eq!(plan.requested_height_px, 6566.0);
4450        assert_eq!(plan.width_px, DEFAULT_MAX_RASTER_SIDE_LENGTH);
4451        assert!(plan.height_px < DEFAULT_MAX_RASTER_SIDE_LENGTH);
4452        assert!(plan.limited);
4453        assert!(plan.effective_scale < plan.requested_scale);
4454    }
4455
4456    #[test]
4457    fn fit_to_models_browser_preview_container_before_scale() {
4458        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 500"><rect width="1000" height="500" fill="black"/></svg>"#;
4459        let options = RasterOptions::default()
4460            .with_fit_to(RasterFitBox::width(250))
4461            .with_scale(2.0);
4462        let plan = svg_raster_plan(svg, &options).unwrap();
4463
4464        assert_eq!(plan.requested_width_px, 500.0);
4465        assert_eq!(plan.requested_height_px, 250.0);
4466        assert_eq!(plan.width_px, 500);
4467        assert_eq!(plan.height_px, 250);
4468        assert!(!plan.limited);
4469    }
4470
4471    #[test]
4472    fn size_limit_caps_actual_png_dimensions() {
4473        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 500"><rect width="1000" height="500" fill="black"/></svg>"#;
4474        let options = RasterOptions::default()
4475            .with_size_limit(RasterSizeLimit::max_side_length(128))
4476            .with_background("white");
4477        let bytes = svg_to_png(svg, &options).unwrap();
4478        let (width, height) = png_size(&bytes);
4479
4480        assert_eq!((width, height), (128, 64));
4481    }
4482
4483    #[test]
4484    fn size_limit_caps_by_total_pixels() {
4485        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000"><rect width="1000" height="1000" fill="black"/></svg>"#;
4486        let options = RasterOptions::default().with_size_limit(RasterSizeLimit::new(
4487            None,
4488            None,
4489            Some(10_000),
4490        ));
4491        let plan = svg_raster_plan(svg, &options).unwrap();
4492
4493        assert_eq!((plan.width_px, plan.height_px), (100, 100));
4494        assert!(plan.limited);
4495    }
4496
4497    #[test]
4498    fn unbounded_size_keeps_requested_dimensions() {
4499        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 9000 4500"><rect width="9000" height="4500" fill="black"/></svg>"#;
4500        let plan = svg_raster_plan(svg, &RasterOptions::default().with_unbounded_size()).unwrap();
4501
4502        assert_eq!((plan.width_px, plan.height_px), (9000, 4500));
4503        assert!(!plan.limited);
4504    }
4505
4506    fn png_size(bytes: &[u8]) -> (u32, u32) {
4507        let decoder = png::Decoder::new(std::io::Cursor::new(bytes));
4508        let reader = decoder.read_info().expect("png read_info");
4509        let info = reader.info();
4510        (info.width, info.height)
4511    }
4512
4513    fn rgba_pixel(bytes: &[u8], x: u32, y: u32) -> [u8; 4] {
4514        let decoder = png::Decoder::new(std::io::Cursor::new(bytes));
4515        let mut reader = decoder.read_info().expect("png read_info");
4516        let size = reader
4517            .output_buffer_size()
4518            .expect("invalid png output buffer size");
4519        let mut buf = vec![0u8; size];
4520        let info = reader.next_frame(&mut buf).expect("png next_frame");
4521        assert_eq!(info.color_type, png::ColorType::Rgba);
4522        assert_eq!(info.bit_depth, png::BitDepth::Eight);
4523        assert!(x < info.width && y < info.height);
4524        let offset = ((y * info.width + x) as usize) * 4;
4525        [
4526            buf[offset],
4527            buf[offset + 1],
4528            buf[offset + 2],
4529            buf[offset + 3],
4530        ]
4531    }
4532
4533    fn encode_rgba_png(width: u32, height: u32, data: &[u8]) -> Vec<u8> {
4534        let mut bytes = Vec::new();
4535        {
4536            let mut encoder = png::Encoder::new(&mut bytes, width, height);
4537            encoder.set_color(png::ColorType::Rgba);
4538            encoder.set_depth(png::BitDepth::Eight);
4539            let mut writer = encoder.write_header().expect("png write_header");
4540            writer.write_image_data(data).expect("png write_image_data");
4541        }
4542        bytes
4543    }
4544
4545    fn png_data_uri_with_declared_size(width: u32, height: u32) -> String {
4546        let mut png = encode_rgba_png(1, 1, &[0, 0, 0, 0]);
4547        png[16..20].copy_from_slice(&width.to_be_bytes());
4548        png[20..24].copy_from_slice(&height.to_be_bytes());
4549        format!(
4550            "data:image/png;base64,{}",
4551            base64::engine::general_purpose::STANDARD.encode(png)
4552        )
4553    }
4554
4555    fn escape_xml_attr(value: &str) -> String {
4556        value
4557            .replace('&', "&amp;")
4558            .replace('"', "&quot;")
4559            .replace('<', "&lt;")
4560            .replace('>', "&gt;")
4561    }
4562
4563    struct TempFile {
4564        path: std::path::PathBuf,
4565    }
4566
4567    impl TempFile {
4568        fn new(extension: &str, data: Vec<u8>) -> Self {
4569            let path = std::env::temp_dir().join(format!(
4570                "merman-raster-{}-{}.{}",
4571                std::process::id(),
4572                line!(),
4573                extension
4574            ));
4575            std::fs::write(&path, data).expect("write temp image");
4576            Self { path }
4577        }
4578
4579        fn href_path(&self) -> String {
4580            self.path.to_string_lossy().replace('\\', "/")
4581        }
4582    }
4583
4584    impl Drop for TempFile {
4585        fn drop(&mut self) {
4586            let _ = std::fs::remove_file(&self.path);
4587        }
4588    }
4589
4590    fn assert_png_has_visible_non_background_ink(bytes: &[u8]) {
4591        let decoder = png::Decoder::new(std::io::Cursor::new(bytes));
4592        let mut reader = decoder.read_info().expect("png read_info");
4593        let size = reader
4594            .output_buffer_size()
4595            .expect("invalid png output buffer size");
4596        let mut buf = vec![0u8; size];
4597        let info = reader.next_frame(&mut buf).expect("png next_frame");
4598
4599        assert_eq!(
4600            info.color_type,
4601            png::ColorType::Rgba,
4602            "expected RGBA PNG output"
4603        );
4604        assert_eq!(
4605            info.bit_depth,
4606            png::BitDepth::Eight,
4607            "expected 8-bit PNG output"
4608        );
4609
4610        let pixels = &buf[..info.buffer_size()];
4611        let Some(background) = pixels.chunks_exact(4).next() else {
4612            panic!("expected at least one PNG pixel");
4613        };
4614        let differing_pixels = pixels
4615            .chunks_exact(4)
4616            .filter(|px| {
4617                let alpha_delta = px[3].abs_diff(background[3]) as u16;
4618                let rgb_delta = px[0].abs_diff(background[0]) as u16
4619                    + px[1].abs_diff(background[1]) as u16
4620                    + px[2].abs_diff(background[2]) as u16;
4621                alpha_delta > 3 || (px[3] > 0 && rgb_delta > 8)
4622            })
4623            .take(16)
4624            .count();
4625        assert!(
4626            differing_pixels >= 8,
4627            "expected visible text ink in rasterized PNG"
4628        );
4629    }
4630}