Skip to main content

merman_export/
lib.rs

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