Skip to main content

merman_render/svg/pipeline/
mod.rs

1mod builtin;
2mod context;
3mod embedding;
4mod final_validation;
5mod policy;
6mod preset;
7mod static_validation;
8
9pub(crate) use builtin::util::{
10    SvgTagScanner, checkpoint_loop, end_tag_name, escape_xml_attr_with_checkpoints,
11    escape_xml_text_with_checkpoints, extract_exact_double_quoted_attr_with_checkpoints,
12    find_tag_end_with_checkpoints, find_with_checkpoints, rfind_with_checkpoints, start_tag_name,
13    trim_with_checkpoints,
14};
15pub use builtin::{
16    CssOverridePolicy, CssOverridePostprocessor, ForeignObjectFallbackPostprocessor,
17    RootBackgroundPostprocessor, SanitizeCssPostprocessor, SanitizeSvgAttributesPostprocessor,
18    ScopedCssPostprocessor, StripForeignObjectPostprocessor,
19};
20pub(crate) use builtin::{GitGraphBranchLabelBaselinePostprocessor, RebaseSvgIdsPostprocessor};
21pub(crate) use context::SvgPostprocessExecution;
22pub use context::{SvgPostprocessContext, SvgPostprocessMetadata};
23pub(crate) use final_validation::{SvgStructureMetrics, validate_well_formed_svg_with_controls};
24pub use policy::SvgOutputPolicy;
25pub use preset::SvgPipelinePreset;
26
27pub(crate) fn is_css_value_attribute(name: &str) -> bool {
28    matches!(
29        name.to_ascii_lowercase().as_str(),
30        "style"
31            | "background"
32            | "background-image"
33            | "fill"
34            | "stroke"
35            | "filter"
36            | "clip-path"
37            | "mask"
38            | "marker"
39            | "marker-start"
40            | "marker-mid"
41            | "marker-end"
42            | "cursor"
43            | "color-profile"
44    )
45}
46
47pub(crate) fn is_svg_idref_attribute(name: &str) -> bool {
48    matches!(
49        name.to_ascii_lowercase().as_str(),
50        "aria-activedescendant"
51            | "aria-controls"
52            | "aria-describedby"
53            | "aria-details"
54            | "aria-errormessage"
55            | "aria-flowto"
56            | "aria-labelledby"
57            | "aria-owns"
58    )
59}
60
61/// Internal cross-crate adapter for rebasing ids in Merman-produced SVG.
62#[doc(hidden)]
63pub fn rebase_svg_ids(
64    svg: &str,
65    prefix: impl Into<String>,
66    session: &RenderSession,
67) -> Result<String> {
68    SvgPipeline::parity()
69        .with_rebased_ids(prefix)
70        .process_to_string(svg, session)
71}
72
73/// Validates sanitized SVG for Merman's known-host static-inline embedding policy.
74///
75/// This policy permits ordinary navigation links and same-document fragment references. It is not
76/// a general DOM-mount admission contract: the host must not inject or inherit a `<base>` URL that
77/// changes how fragment-only references resolve.
78#[doc(hidden)]
79pub fn validate_static_inline_svg(svg: &str, session: &RenderSession) -> Result<()> {
80    static_validation::validate_rustdoc_static_svg(svg, SvgPostprocessExecution::new(session))
81}
82
83/// Validates renderer output before static-inline fallback and compatibility transformations.
84#[doc(hidden)]
85pub fn validate_static_inline_svg_admission(svg: &str, session: &RenderSession) -> Result<()> {
86    static_validation::validate_rustdoc_admission_svg(svg, SvgPostprocessExecution::new(session))
87}
88
89use crate::environment::RenderSession;
90use crate::{Error, Result};
91use std::borrow::Cow;
92use std::fmt;
93use std::sync::Arc;
94
95pub trait SvgPostprocessor: Send + Sync {
96    fn name(&self) -> &'static str;
97
98    /// Host CSS injected by this pass after layout, when applicable.
99    fn scoped_css(&self) -> Option<&str> {
100        None
101    }
102
103    fn process<'a>(
104        &self,
105        svg: Cow<'a, str>,
106        ctx: &SvgPostprocessContext<'_>,
107    ) -> Result<Cow<'a, str>>;
108}
109
110/// SVG that has passed the terminal resvg compatibility and rendering-resource finalizer.
111///
112/// The inner string cannot be constructed directly. Custom postprocessors operate on an SVG draft
113/// before finalization and therefore cannot claim this type. Structural resources are limited to
114/// same-document fragments, ordinary image elements require approved, syntactically valid inline
115/// raster data URLs, and `feImage` accepts either form.
116///
117/// ```compile_fail
118/// use merman_render::svg::ResvgCompatibleSvg;
119///
120/// let forged = ResvgCompatibleSvg { svg: "<svg/>".to_string() };
121/// ```
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct ResvgCompatibleSvg {
124    svg: String,
125    reference_plan: SvgReferencePlan,
126}
127
128/// Reference-expansion work retained by a sealed resvg-compatible SVG.
129///
130/// The occurrence slice is ordered by source-document element encounter order. Exporters use it
131/// to charge each inline resource once for every `<use>`-expanded instance before asking usvg to
132/// decode that resource.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct SvgReferencePlan {
135    expanded_elements: usize,
136    max_tree_depth: usize,
137    raw_element_occurrences: Box<[usize]>,
138}
139
140impl SvgReferencePlan {
141    /// Returns the element count after same-document `<use>` expansion.
142    pub const fn expanded_elements(&self) -> usize {
143        self.expanded_elements
144    }
145
146    /// Returns the maximum resolved XML-tree depth after `<use>` expansion.
147    pub const fn max_tree_depth(&self) -> usize {
148        self.max_tree_depth
149    }
150
151    /// Returns expanded instance counts for source elements in document encounter order.
152    pub fn raw_element_occurrences(&self) -> &[usize] {
153        &self.raw_element_occurrences
154    }
155}
156
157impl ResvgCompatibleSvg {
158    fn finalized(svg: String, reference_plan: SvgReferencePlan) -> Self {
159        Self {
160            svg,
161            reference_plan,
162        }
163    }
164
165    pub fn as_str(&self) -> &str {
166        &self.svg
167    }
168
169    pub fn into_string(self) -> String {
170        self.svg
171    }
172
173    /// Returns the reference-expansion preflight retained at terminal finalization.
174    pub const fn reference_plan(&self) -> &SvgReferencePlan {
175        &self.reference_plan
176    }
177}
178
179impl AsRef<str> for ResvgCompatibleSvg {
180    fn as_ref(&self) -> &str {
181        self.as_str()
182    }
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186enum InlineContract {
187    Browser,
188    Static,
189}
190
191#[derive(Clone)]
192pub struct SvgPipeline {
193    preset: SvgPipelinePreset,
194    postprocessors: Vec<Arc<dyn SvgPostprocessor>>,
195    drop_native_duplicate_fallbacks: bool,
196    inline_contract: Option<InlineContract>,
197    isolate_ids: bool,
198}
199
200impl fmt::Debug for SvgPipeline {
201    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202        let names = self
203            .postprocessors
204            .iter()
205            .map(|pass| pass.name())
206            .collect::<Vec<_>>();
207
208        f.debug_struct("SvgPipeline")
209            .field("preset", &self.preset)
210            .field("postprocessors", &names)
211            .field(
212                "drop_native_duplicate_fallbacks",
213                &self.drop_native_duplicate_fallbacks,
214            )
215            .field("inline_contract", &self.inline_contract)
216            .field("isolate_ids", &self.isolate_ids)
217            .finish()
218    }
219}
220
221impl Default for SvgPipeline {
222    fn default() -> Self {
223        Self::parity()
224    }
225}
226
227impl SvgPipeline {
228    /// Default browser-facing SVG; preserves Mermaid labels and styles without text overlays.
229    pub fn parity() -> Self {
230        Self::from_preset(SvgPipelinePreset::Parity)
231    }
232
233    /// Advanced text overlay for consumers that ignore HTML labels.
234    ///
235    /// Browsers may show duplicate text. Prefer [`Self::parity`] for browser previews or
236    /// [`Self::resvg_safe`] for resvg/usvg compatibility. See [`SvgPipelinePreset::Readable`].
237    pub fn readable() -> Self {
238        Self::from_preset(SvgPipelinePreset::Readable)
239    }
240
241    /// Converts HTML labels and applies terminal resvg compatibility cleanup and validation.
242    pub fn resvg_safe() -> Self {
243        Self::from_preset(SvgPipelinePreset::ResvgSafe)
244    }
245
246    pub fn from_preset(preset: SvgPipelinePreset) -> Self {
247        Self {
248            preset,
249            postprocessors: Vec::new(),
250            drop_native_duplicate_fallbacks: false,
251            inline_contract: None,
252            isolate_ids: false,
253        }
254    }
255
256    pub fn preset(&self) -> SvgPipelinePreset {
257        self.preset
258    }
259
260    /// Keeps every configured draft transformation while replacing the terminal output contract.
261    pub fn with_preset(mut self, preset: SvgPipelinePreset) -> Self {
262        self.preset = preset;
263        self
264    }
265
266    pub fn into_resvg_safe(self) -> Self {
267        self.with_preset(SvgPipelinePreset::ResvgSafe)
268    }
269
270    pub fn with_drop_native_duplicate_fallbacks(mut self, drop: bool) -> Self {
271        self.drop_native_duplicate_fallbacks = drop;
272        self
273    }
274
275    /// Adds Merman's static-inline publication contract to this pipeline.
276    ///
277    /// Admission runs before any lossy compatibility transform. Final validation runs after all
278    /// configured transforms and XML 1.0 character cleanup, while the renderer-owned session is
279    /// still available for cancellation and cumulative resource accounting.
280    #[doc(hidden)]
281    pub fn with_static_inline_contract(mut self, id_prefix: impl Into<String>) -> Self {
282        self.inline_contract = Some(InlineContract::Static);
283        self.postprocessors
284            .push(Arc::new(ForeignObjectFallbackPostprocessor));
285        self.postprocessors.push(Arc::new(SanitizeCssPostprocessor));
286        self.postprocessors
287            .push(Arc::new(SanitizeSvgAttributesPostprocessor));
288        self.with_rebased_ids(id_prefix)
289    }
290
291    /// Adds Merman's browser-inline publication contract without converting HTML labels.
292    ///
293    /// Resource and selector-scope checks run before any configured transformation and again
294    /// after terminal processing. Safe XHTML labels and browser CSS remain available; select a
295    /// compatible preset separately when the consumer also needs a text-only SVG. Custom CSS
296    /// animation names remain document-global, matching Mermaid.
297    ///
298    /// As with the static-inline contract, the host must not introduce a `<base>` URL that changes
299    /// the resolution of same-document fragment references.
300    #[doc(hidden)]
301    pub fn with_browser_inline_contract(mut self, id_prefix: impl Into<String>) -> Self {
302        self.inline_contract = Some(InlineContract::Browser);
303        self.with_rebased_ids(id_prefix)
304    }
305
306    /// Isolates SVG IDs and their references without imposing an embedding safety contract.
307    ///
308    /// Renderer-certified Mindmap output first drops the redundant background-shape ID retained
309    /// by raw Mermaid parity. Other duplicate IDs remain errors.
310    #[doc(hidden)]
311    pub fn with_rebased_ids(mut self, id_prefix: impl Into<String>) -> Self {
312        self.isolate_ids = true;
313        self.with_postprocessor(RebaseSvgIdsPostprocessor::new(id_prefix))
314    }
315
316    pub fn with_postprocessor<P>(mut self, postprocessor: P) -> Self
317    where
318        P: SvgPostprocessor + 'static,
319    {
320        self.postprocessors.push(Arc::new(postprocessor));
321        self
322    }
323
324    pub fn with_shared_postprocessor(mut self, postprocessor: Arc<dyn SvgPostprocessor>) -> Self {
325        self.postprocessors.push(postprocessor);
326        self
327    }
328
329    pub fn push_postprocessor<P>(&mut self, postprocessor: P)
330    where
331        P: SvgPostprocessor + 'static,
332    {
333        self.postprocessors.push(Arc::new(postprocessor));
334    }
335
336    /// Whether the pipeline injects host CSS after text layout has completed.
337    pub fn has_scoped_css(&self) -> bool {
338        self.postprocessors.iter().any(|postprocessor| {
339            postprocessor
340                .scoped_css()
341                .is_some_and(|css| !css.trim().is_empty())
342        })
343    }
344
345    pub fn process<'a>(&self, svg: &'a str, session: &RenderSession) -> Result<Cow<'a, str>> {
346        let execution = SvgPostprocessExecution::new(session);
347        let metadata = SvgPostprocessMetadata::from_svg_with_execution(svg, execution)?;
348        self.process_with_metadata(svg, &metadata, session)
349    }
350
351    pub fn process_with_metadata<'a>(
352        &self,
353        svg: &'a str,
354        metadata: &SvgPostprocessMetadata,
355        session: &RenderSession,
356    ) -> Result<Cow<'a, str>> {
357        self.process_cow_with_metadata(Cow::Borrowed(svg), metadata, session)
358    }
359
360    fn process_cow_with_metadata<'a>(
361        &self,
362        svg: Cow<'a, str>,
363        metadata: &SvgPostprocessMetadata,
364        session: &RenderSession,
365    ) -> Result<Cow<'a, str>> {
366        Ok(self
367            .process_cow_with_reference_plan(svg, metadata, session)?
368            .0)
369    }
370
371    fn process_cow_with_reference_plan<'a>(
372        &self,
373        svg: Cow<'a, str>,
374        metadata: &SvgPostprocessMetadata,
375        session: &RenderSession,
376    ) -> Result<(Cow<'a, str>, Option<SvgReferencePlan>)> {
377        let execution = SvgPostprocessExecution::new(session);
378        execution.checkpoint()?;
379        execution.preflight_svg_byte_count(svg.len())?;
380        let mut current =
381            crate::xml::strip_forbidden_xml_1_0_chars_cow_with_checkpoints(svg, || {
382                execution.checkpoint()
383            })?;
384        execution.preflight_svg_byte_count(current.len())?;
385        let mut structure = match final_validation::validate_well_formed_svg_with_execution(
386            current.as_ref(),
387            execution,
388        ) {
389            Ok(structure) => structure,
390            Err(initial_error) if self.preset == SvgPipelinePreset::ResvgSafe => {
391                // Browser-facing SVG may contain HTML named entities in serialized attribute
392                // values (for example `&colon;`). They are not XML entities, so run the same
393                // resource-aware attribute sanitizer once before retrying structural validation.
394                // Text/entity errors remain rejected because this pass never rewrites text nodes.
395                let sanitized =
396                    builtin::attr_sanitize::sanitize_element_attributes_cow_with_checkpoints(
397                        current.clone(),
398                        &mut || execution.checkpoint(),
399                    )?;
400                execution.preflight_svg_byte_count(sanitized.len())?;
401                if sanitized.as_ref() == current.as_ref() {
402                    return Err(initial_error);
403                }
404                current = sanitized;
405                final_validation::validate_well_formed_svg_with_execution(
406                    current.as_ref(),
407                    execution,
408                )?
409            }
410            Err(error) => return Err(error),
411        };
412        if self.isolate_ids {
413            current = embedding::normalize_renderer_ids(current, metadata, execution)?;
414        }
415        if self.inline_contract.is_some() {
416            static_validation::validate_rustdoc_admission_svg(current.as_ref(), execution)?;
417        }
418
419        for (index, postprocessor) in self.postprocessors.iter().enumerate() {
420            execution.checkpoint()?;
421            let ctx = SvgPostprocessContext::new(
422                self.preset,
423                index,
424                postprocessor.name(),
425                metadata,
426                session,
427            );
428            let result = postprocessor.process(current, &ctx);
429            execution.checkpoint()?;
430            current = match result {
431                Ok(current) => current,
432                Err(Error::Cancelled(error)) => return Err(Error::Cancelled(error)),
433                Err(Error::ResourceLimitExceeded(error)) => {
434                    return Err(execution.terminate_resource_error(error));
435                }
436                Err(error) => {
437                    return Err(Error::svg_postprocess(
438                        postprocessor.name(),
439                        error.to_string(),
440                    ));
441                }
442            };
443            execution.preflight_svg_byte_count(current.len())?;
444            structure = final_validation::validate_well_formed_svg_with_execution(
445                current.as_ref(),
446                execution,
447            )?;
448        }
449
450        execution.checkpoint()?;
451        let finalized = preset::apply_preset_cow(
452            self.preset,
453            current,
454            metadata,
455            execution,
456            structure,
457            self.drop_native_duplicate_fallbacks,
458        )?;
459        let finalized =
460            crate::xml::strip_forbidden_xml_1_0_chars_cow_with_checkpoints(finalized, || {
461                execution.checkpoint()
462            })?;
463        execution.preflight_svg_byte_count(finalized.len())?;
464        match self.inline_contract {
465            Some(InlineContract::Static) => {
466                static_validation::validate_rustdoc_static_svg(finalized.as_ref(), execution)?;
467            }
468            Some(InlineContract::Browser) => {
469                static_validation::validate_rustdoc_admission_svg(finalized.as_ref(), execution)?;
470            }
471            None => {}
472        }
473        let reference_plan = if self.preset == SvgPipelinePreset::ResvgSafe {
474            Some(
475                final_validation::validate_resvg_compatible_svg_with_execution(
476                    finalized.as_ref(),
477                    execution,
478                )?,
479            )
480        } else {
481            final_validation::validate_well_formed_svg_with_execution(
482                finalized.as_ref(),
483                execution,
484            )?;
485            None
486        };
487        execution.checkpoint()?;
488        Ok((finalized, reference_plan))
489    }
490
491    pub fn process_to_string(&self, svg: &str, session: &RenderSession) -> Result<String> {
492        Ok(self.process(svg, session)?.into_owned())
493    }
494
495    pub fn process_owned_to_string(&self, svg: String, session: &RenderSession) -> Result<String> {
496        let execution = SvgPostprocessExecution::new(session);
497        let metadata = SvgPostprocessMetadata::from_svg_with_execution(&svg, execution)?;
498        self.process_owned_to_string_with_metadata(svg, &metadata, session)
499    }
500
501    pub fn process_to_string_with_metadata(
502        &self,
503        svg: &str,
504        metadata: &SvgPostprocessMetadata,
505        session: &RenderSession,
506    ) -> Result<String> {
507        Ok(self
508            .process_with_metadata(svg, metadata, session)?
509            .into_owned())
510    }
511
512    pub fn process_owned_to_string_with_metadata(
513        &self,
514        svg: String,
515        metadata: &SvgPostprocessMetadata,
516        session: &RenderSession,
517    ) -> Result<String> {
518        Ok(self
519            .process_cow_with_metadata(Cow::Owned(svg), metadata, session)?
520            .into_owned())
521    }
522
523    pub fn process_resvg_compatible(
524        &self,
525        svg: &str,
526        session: &RenderSession,
527    ) -> Result<ResvgCompatibleSvg> {
528        let execution = SvgPostprocessExecution::new(session);
529        let metadata = SvgPostprocessMetadata::from_svg_with_execution(svg, execution)?;
530        self.process_resvg_compatible_with_metadata(svg, &metadata, session)
531    }
532
533    pub fn process_resvg_compatible_with_metadata(
534        &self,
535        svg: &str,
536        metadata: &SvgPostprocessMetadata,
537        session: &RenderSession,
538    ) -> Result<ResvgCompatibleSvg> {
539        self.ensure_resvg_safe_contract()?;
540        let (svg, reference_plan) =
541            self.process_cow_with_reference_plan(Cow::Borrowed(svg), metadata, session)?;
542        Ok(ResvgCompatibleSvg::finalized(
543            svg.into_owned(),
544            reference_plan.expect("resvg-safe processing always produces a reference plan"),
545        ))
546    }
547
548    pub fn process_owned_resvg_compatible_with_metadata(
549        &self,
550        svg: String,
551        metadata: &SvgPostprocessMetadata,
552        session: &RenderSession,
553    ) -> Result<ResvgCompatibleSvg> {
554        self.ensure_resvg_safe_contract()?;
555        let (svg, reference_plan) =
556            self.process_cow_with_reference_plan(Cow::Owned(svg), metadata, session)?;
557        Ok(ResvgCompatibleSvg::finalized(
558            svg.into_owned(),
559            reference_plan.expect("resvg-safe processing always produces a reference plan"),
560        ))
561    }
562
563    fn ensure_resvg_safe_contract(&self) -> Result<()> {
564        if self.preset != SvgPipelinePreset::ResvgSafe {
565            return Err(Error::svg_postprocess(
566                "resvg-finalize",
567                "ResvgCompatibleSvg requires the resvg-safe terminal preset",
568            ));
569        }
570        Ok(())
571    }
572}
573
574/// Finalizes arbitrary SVG for resvg/raster consumption without a family capability.
575pub fn finalize_resvg_svg(svg: &str, session: &RenderSession) -> Result<ResvgCompatibleSvg> {
576    SvgPipeline::resvg_safe().process_resvg_compatible(svg, session)
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582    use merman_core::{CancelReason, OperationControl, OperationPhase};
583
584    fn render_session() -> RenderSession {
585        crate::environment::RenderEnvironment::deterministic()
586            .begin_session()
587            .unwrap()
588    }
589
590    #[test]
591    fn parity_pipeline_preserves_svg_exactly() {
592        let svg = r#"<svg><style>@keyframes a{to{opacity:1}}</style><rect width="10"/></svg>"#;
593        let session = render_session();
594        let out = SvgPipeline::parity().process(svg, &session).unwrap();
595        assert!(matches!(out, Cow::Borrowed(_)));
596        assert_eq!(out, svg);
597    }
598
599    #[test]
600    fn browser_inline_contract_preserves_html_labels_and_rebases_references() {
601        let svg = r##"<svg id="root" xmlns="http://www.w3.org/2000/svg" aria-labelledby="title"><title id="title">Diagram</title><defs><marker id="arrow"/></defs><style>@keyframes dash{to{stroke-dashoffset:0}}#root .edge{animation:dash 20s linear infinite}#root #label{color:red}</style><path class="edge" marker-end="url(#arrow)"/><foreignObject width="80" height="24"><div xmlns="http://www.w3.org/1999/xhtml" id="label" style="transform:rotate(5deg)"><strong>Safe label</strong></div></foreignObject></svg>"##;
602        let output = SvgPipeline::parity()
603            .with_browser_inline_contract("embed")
604            .process_to_string(svg, &render_session())
605            .unwrap();
606
607        assert!(output.contains("<foreignObject"), "{output}");
608        assert!(output.contains("<strong>Safe label</strong>"), "{output}");
609        assert!(output.contains("transform:rotate(5deg)"), "{output}");
610        assert!(output.contains("animation:dash 20s"), "{output}");
611        assert!(output.contains(r#"id="embed-root""#), "{output}");
612        assert!(
613            output.contains(r#"aria-labelledby="embed-title""#),
614            "{output}"
615        );
616        assert!(output.contains("url(#embed-arrow)"), "{output}");
617        assert!(output.contains("#embed-root #embed-label"), "{output}");
618    }
619
620    #[test]
621    fn browser_inline_contract_accepts_renderer_owned_family_styles() {
622        for (name, source) in [
623            ("error", "error"),
624            #[cfg(feature = "diagram-event-modeling")]
625            ("eventmodeling", "eventmodeling\ntf 01 event Start"),
626            #[cfg(feature = "diagram-ishikawa")]
627            (
628                "ishikawa",
629                "ishikawa-beta\n    Problem\n        Cause\n            Detail",
630            ),
631            #[cfg(feature = "diagram-tree-view")]
632            ("treeView", "treeView-beta\n    root/\n        file.txt"),
633            #[cfg(feature = "diagram-zenuml")]
634            ("zenuml", "zenuml\nAlice->Bob: Hello"),
635            #[cfg(all(feature = "diagram-mindmap", feature = "layout-cytoscape"))]
636            ("mindmap", "mindmap\n  root\n    a\n    b"),
637            #[cfg(all(feature = "diagram-architecture", feature = "layout-cytoscape"))]
638            (
639                "architecture",
640                "architecture-beta\nservice worker \"<a href='https://example.test'>Docs</a>\" [Worker]",
641            ),
642        ] {
643            let parsed = merman_core::Engine::new()
644                .parse_diagram_for_render_model_sync(source, merman_core::ParseOptions::strict())
645                .unwrap_or_else(|error| panic!("{name}: {error}"))
646                .unwrap();
647            let artifact =
648                crate::family::prepare(parsed, &crate::LayoutOptions::default(), render_session())
649                    .unwrap_or_else(|error| panic!("{name}: {error}"));
650            let rendered = artifact
651                .render_svg(
652                    &crate::svg::SvgRenderOptions {
653                        diagram_id: Some(name.to_string()),
654                        ..Default::default()
655                    },
656                    &crate::svg::SvgDebugOptions::default(),
657                )
658                .unwrap_or_else(|error| panic!("{name}: {error}"))
659                .apply_pipeline(&SvgPipeline::parity().with_browser_inline_contract("embed"))
660                .unwrap_or_else(|error| panic!("{name}: {error}"));
661            assert!(rendered.svg().contains(&format!("id=\"embed-{name}\"")));
662        }
663    }
664
665    #[test]
666    fn browser_inline_contract_rejects_external_html_and_escaped_css_resources() {
667        for (svg, expected) in [
668            (
669                r#"<svg><foreignObject><img xmlns="http://www.w3.org/1999/xhtml" src="https://tracker.test/image.png"/></foreignObject></svg>"#,
670                "forbidden <img>",
671            ),
672            (
673                r#"<svg><foreignObject><div xmlns="http://www.w3.org/1999/xhtml" style="background:u\72l(https://tracker.test/image.png)">Label</div></foreignObject></svg>"#,
674                "non-local CSS URL",
675            ),
676            (
677                r#"<svg id="root"><style>#root .label{background:u\72l('https://tracker.test/image.png')}</style></svg>"#,
678                "non-local CSS URL",
679            ),
680        ] {
681            let error = SvgPipeline::parity()
682                .with_browser_inline_contract("embed")
683                .process_to_string(svg, &render_session())
684                .unwrap_err();
685            assert!(error.to_string().contains(expected), "{svg}: {error}");
686        }
687    }
688
689    #[test]
690    fn inline_contract_admits_before_any_lossy_postprocessor() {
691        struct MustNotRun;
692        impl SvgPostprocessor for MustNotRun {
693            fn name(&self) -> &'static str {
694                "must-not-run"
695            }
696
697            fn process<'a>(
698                &self,
699                _: Cow<'a, str>,
700                _: &SvgPostprocessContext<'_>,
701            ) -> Result<Cow<'a, str>> {
702                panic!("unsafe SVG must be rejected before postprocessing")
703            }
704        }
705        for pipeline in [
706            SvgPipeline::parity()
707                .with_postprocessor(MustNotRun)
708                .with_browser_inline_contract("embed"),
709            SvgPipeline::parity()
710                .with_postprocessor(MustNotRun)
711                .with_static_inline_contract("embed"),
712        ] {
713            let error = pipeline
714                .process_to_string("<svg><script/></svg>", &render_session())
715                .unwrap_err();
716            assert!(error.to_string().contains("forbidden <script>"), "{error}");
717        }
718    }
719
720    #[test]
721    fn inline_contract_validates_after_all_custom_postprocessors() {
722        struct InjectResource;
723        impl SvgPostprocessor for InjectResource {
724            fn name(&self) -> &'static str {
725                "inject-resource"
726            }
727
728            fn process<'a>(
729                &self,
730                _: Cow<'a, str>,
731                _: &SvgPostprocessContext<'_>,
732            ) -> Result<Cow<'a, str>> {
733                Ok(Cow::Borrowed(
734                    r#"<svg><image href="https://tracker.test/image.png"/></svg>"#,
735                ))
736            }
737        }
738        for pipeline in [
739            SvgPipeline::parity().with_browser_inline_contract("embed"),
740            SvgPipeline::parity().with_static_inline_contract("embed"),
741        ] {
742            let error = pipeline
743                .with_postprocessor(InjectResource)
744                .process_to_string("<svg/>", &render_session())
745                .unwrap_err();
746            assert!(error.to_string().contains("non-local resource"), "{error}");
747        }
748    }
749
750    #[test]
751    fn static_inline_contract_still_converts_html_and_removes_animation() {
752        let svg = r#"<svg id="root"><style>@keyframes dash{to{opacity:0}}#root .edge{animation:dash 1s}</style><foreignObject width="80" height="24"><div xmlns="http://www.w3.org/1999/xhtml">Safe label</div></foreignObject></svg>"#;
753        let output = SvgPipeline::parity()
754            .with_static_inline_contract("embed")
755            .process_to_string(svg, &render_session())
756            .unwrap();
757
758        assert!(!output.contains("<foreignObject"), "{output}");
759        assert!(!output.contains("@keyframes"), "{output}");
760        assert!(!output.contains("animation:"), "{output}");
761        assert!(output.contains("Safe label"), "{output}");
762        assert!(output.contains(r#"id="embed-root""#), "{output}");
763    }
764
765    #[test]
766    fn rebasing_without_inline_contract_preserves_external_resources() {
767        let svg = r#"<svg id="root"><image href="https://example.test/image.png"/></svg>"#;
768        let output = SvgPipeline::parity()
769            .with_rebased_ids("embed")
770            .process_to_string(svg, &render_session())
771            .unwrap();
772
773        assert!(output.contains(r#"id="embed-root""#), "{output}");
774        assert!(
775            output.contains("https://example.test/image.png"),
776            "{output}"
777        );
778    }
779
780    #[test]
781    fn parity_pipeline_returns_owned_svg_without_reallocating() {
782        let svg = String::from(r#"<svg><rect width="10"/></svg>"#);
783        let allocation = svg.as_ptr();
784        let session = render_session();
785
786        let out = SvgPipeline::parity()
787            .process_owned_to_string(svg, &session)
788            .unwrap();
789
790        assert_eq!(out.as_ptr(), allocation);
791    }
792
793    #[test]
794    fn every_pipeline_preset_enforces_the_xml_1_0_character_contract() {
795        let svg = "<svg><text>A\u{0}B\u{1c}C\u{fffe}D</text></svg>";
796        let session = render_session();
797
798        for preset in [
799            SvgPipelinePreset::Parity,
800            SvgPipelinePreset::Readable,
801            SvgPipelinePreset::ResvgSafe,
802        ] {
803            let out = SvgPipeline::from_preset(preset)
804                .process_to_string(svg, &session)
805                .unwrap();
806            assert_eq!(out, "<svg><text>ABCD</text></svg>", "{preset:?}");
807            roxmltree::Document::parse(&out).expect("pipeline output must remain XML 1.0");
808        }
809    }
810
811    #[test]
812    fn every_pipeline_preset_rejects_unknown_xml_entities() {
813        let session = render_session();
814
815        for preset in [
816            SvgPipelinePreset::Parity,
817            SvgPipelinePreset::Readable,
818            SvgPipelinePreset::ResvgSafe,
819        ] {
820            let error = SvgPipeline::from_preset(preset)
821                .process_to_string("<svg><text>&unknown;</text></svg>", &session)
822                .unwrap_err();
823            assert!(
824                error.to_string().contains("invalid XML"),
825                "{preset:?}: {error}"
826            );
827        }
828    }
829
830    #[test]
831    fn readable_pipeline_matches_foreign_object_fallback() {
832        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg"><g transform="translate(10,20)"><foreignObject width="80" height="48"><div xmlns="http://www.w3.org/1999/xhtml"><p>Layer 7\nHTTP</p></div></foreignObject></g></svg>"#;
833        let session = render_session();
834        let measurer = session.text_measurer(crate::environment::TextMeasurementPhase::Wrap);
835
836        let expected = super::builtin::foreign_object::foreign_object_fallback_svg(svg, &measurer);
837        let out = SvgPipeline::readable()
838            .process_to_string(svg, &session)
839            .unwrap();
840
841        assert_eq!(out, expected);
842        assert!(out.contains(">Layer 7</text>"));
843        assert!(out.contains(">HTTP</text>"));
844    }
845
846    #[test]
847    fn resvg_safe_pipeline_strips_generic_raster_hazards() {
848        let svg = r#"<svg id="test" xmlns="http://www.w3.org/2000/svg"><style type="text/css">@keyframes bounce { 0% { transform: scale(1); } 100% { transform: scale(1.1); } } #test :root { --bg: white; } .node rect { animation: dash 1s linear; transform: rotate(45deg); fill: red; }</style><g transform="translate(undefined,NaN)"><foreignObject width="10" height="10"><div xmlns="http://www.w3.org/1999/xhtml"><p>Hello</p></div></foreignObject><rect width="10px" height="12px" stroke="" style="fill: ; stroke: #333; transform: rotate(45deg); animation: dash 1s;"/><rect width="10px" height="" fill="hsl(240, 100%, NaN%)"/></g></svg>"#;
849        let session = render_session();
850
851        let out = SvgPipeline::resvg_safe()
852            .process_to_string(svg, &session)
853            .unwrap();
854
855        assert!(!out.contains("<foreignObject"));
856        assert!(!out.contains("@keyframes"));
857        assert!(!out.contains(":root"));
858        assert!(!out.contains("animation"));
859        assert!(!out.contains("deg"));
860        assert!(!out.contains("NaN"));
861        assert!(!out.contains("undefined"));
862        assert!(!out.contains(r#"height="""#));
863        assert!(!out.contains(r#"fill="hsl"#));
864        assert!(!out.contains(r#"stroke="""#));
865        assert!(out.contains(r#"width="10""#));
866        assert!(out.contains(r#"height="12""#));
867        assert!(out.contains("stroke:#333"));
868        assert!(out.contains(">Hello</text>"));
869    }
870
871    #[test]
872    fn resvg_safe_pipeline_sanitizes_url_attributes_after_malformed_text() {
873        let svg = r##"<svg xmlns="http://www.w3.org/2000/svg">
874"#shape"/>
875  <path stroke="url(javascript:alert(1))" style="filterBurl('file://юmp/xter:');svroke:#033"/>
876</svg>"##;
877        let session = render_session();
878
879        let out = SvgPipeline::resvg_safe()
880            .process_to_string(svg, &session)
881            .expect("resvg-safe pipeline should sanitize the fuzz regression input");
882
883        assert!(!out.to_ascii_lowercase().contains("javascript"), "{out}");
884        assert!(!out.contains(r#"stroke="url("#), "{out}");
885    }
886
887    #[test]
888    fn resvg_safe_finalization_drops_unclosed_inline_css_blocks_idempotently() {
889        let svg = r##"<svg xmlns="http://www.w3.org/2000/svg"><path style="filter:5rl('file:///{animatiEtroke:#333"/></svg>"##;
890        let session = render_session();
891
892        let once = super::finalize_resvg_svg(svg, &session).unwrap();
893        let twice = super::finalize_resvg_svg(once.as_str(), &session).unwrap();
894
895        assert_eq!(twice, once);
896        assert!(!once.as_str().contains("style="), "{}", once.as_str());
897    }
898
899    #[test]
900    fn resvg_safe_pipeline_bounds_css_nesting_on_a_small_thread_stack() {
901        const CSS_NESTING_LIMIT: usize = 64;
902
903        fn nested_function(depth: usize, leaf: &str) -> String {
904            format!("{}{}{}", "f(".repeat(depth), leaf, ")".repeat(depth))
905        }
906
907        fn nested_media(depth: usize, rule: &str) -> String {
908            format!(
909                "{}{}{}",
910                "@media all{".repeat(depth),
911                rule,
912                "}".repeat(depth)
913            )
914        }
915
916        std::thread::Builder::new()
917            .name("bounded-css-nesting".into())
918            .stack_size(512 * 1024)
919            .spawn(|| {
920                let style_exact = nested_function(CSS_NESTING_LIMIT - 1, "red");
921                let style_over = nested_function(CSS_NESTING_LIMIT, "red");
922                let inline_exact = nested_function(CSS_NESTING_LIMIT, "red");
923                let selector_exact = format!(
924                    "{}.selector-exact{}",
925                    ":is(".repeat(CSS_NESTING_LIMIT),
926                    ")".repeat(CSS_NESTING_LIMIT)
927                );
928                let selector_over = format!(
929                    "{}.selector-over{}",
930                    ":is(".repeat(CSS_NESTING_LIMIT + 1),
931                    ")".repeat(CSS_NESTING_LIMIT + 1)
932                );
933                let media_exact = nested_media(CSS_NESTING_LIMIT - 1, ".media-exact{fill:red}");
934                let media_over = nested_media(CSS_NESTING_LIMIT, ".media-over{fill:red}");
935                let very_deep = nested_function(4_096, "red");
936                let svg = format!(
937                    r#"<svg xmlns="http://www.w3.org/2000/svg"><style>
938.style-exact{{fill:{style_exact}}}
939.style-over{{fill:{style_over};stroke:blue}}
940{selector_exact}{{fill:red}}
941{selector_over}{{fill:red}}
942{media_exact}
943{media_over}
944</style>
945<path id="inline-exact" style="fill:{inline_exact};stroke:blue"/>
946<path id="inline-over" style="fill:{very_deep};stroke:blue"/>
947<path id="presentation-over" fill="{very_deep}" stroke="blue"/>
948</svg>"#
949                );
950
951                let session = render_session();
952                let out = SvgPipeline::resvg_safe()
953                    .process_to_string(&svg, &session)
954                    .expect("resvg-safe CSS sanitization must remain bounded");
955
956                assert!(out.contains(".style-exact{fill:"), "{out}");
957                assert!(out.contains(".style-over{stroke:blue}"), "{out}");
958                assert!(out.contains(".selector-exact"), "{out}");
959                assert!(out.contains("){fill:red}"), "{out}");
960                assert!(!out.contains("selector-over"), "{out}");
961                assert!(out.contains("media-exact"), "{out}");
962                assert!(!out.contains("media-over"), "{out}");
963
964                let document = roxmltree::Document::parse(&out).expect("valid sanitized SVG");
965                let inline_exact = document
966                    .descendants()
967                    .find(|node| node.attribute("id") == Some("inline-exact"))
968                    .unwrap();
969                assert!(inline_exact.attribute("style").unwrap().contains("fill:"));
970                let inline_over = document
971                    .descendants()
972                    .find(|node| node.attribute("id") == Some("inline-over"))
973                    .unwrap();
974                assert_eq!(inline_over.attribute("style"), Some("stroke:blue"));
975                let presentation_over = document
976                    .descendants()
977                    .find(|node| node.attribute("id") == Some("presentation-over"))
978                    .unwrap();
979                assert_eq!(presentation_over.attribute("fill"), None);
980                assert_eq!(presentation_over.attribute("stroke"), Some("blue"));
981            })
982            .expect("small-stack CSS regression thread must start")
983            .join()
984            .expect("bounded CSS traversal must not overflow the small thread stack");
985    }
986
987    struct AppendPass(&'static str);
988
989    impl SvgPostprocessor for AppendPass {
990        fn name(&self) -> &'static str {
991            self.0
992        }
993
994        fn process<'a>(
995            &self,
996            svg: Cow<'a, str>,
997            ctx: &SvgPostprocessContext<'_>,
998        ) -> Result<Cow<'a, str>> {
999            Ok(Cow::Owned(format!(
1000                "{}<!--{}:{}:{:?}:{}:{}:{}-->",
1001                svg,
1002                ctx.pass_index(),
1003                ctx.pass_name(),
1004                ctx.preset(),
1005                ctx.diagram_type().unwrap_or("none"),
1006                ctx.diagram_title().unwrap_or("none"),
1007                ctx.svg_id().unwrap_or("none")
1008            )))
1009        }
1010    }
1011
1012    struct CancelOperationPass(OperationControl);
1013
1014    impl SvgPostprocessor for CancelOperationPass {
1015        fn name(&self) -> &'static str {
1016            "cancel-operation"
1017        }
1018
1019        fn process<'a>(
1020            &self,
1021            svg: Cow<'a, str>,
1022            _ctx: &SvgPostprocessContext<'_>,
1023        ) -> Result<Cow<'a, str>> {
1024            self.0.cancel();
1025            Ok(svg)
1026        }
1027    }
1028
1029    #[test]
1030    fn pipeline_checks_control_after_an_opaque_postprocessor() {
1031        let control = OperationControl::new();
1032        let session = crate::environment::RenderEnvironment::deterministic()
1033            .begin_session_with_control(control.clone())
1034            .unwrap();
1035
1036        let error = SvgPipeline::parity()
1037            .with_postprocessor(CancelOperationPass(control))
1038            .process_to_string("<svg/>", &session)
1039            .unwrap_err();
1040
1041        let Error::Cancelled(error) = error else {
1042            panic!("expected structured cancellation");
1043        };
1044        assert_eq!(error.phase, OperationPhase::Postprocess);
1045        assert_eq!(error.reason, CancelReason::Requested);
1046    }
1047
1048    #[test]
1049    fn custom_postprocessors_run_before_builtin_finalizer_in_order() {
1050        let svg = r#"<svg><foreignObject width="10" height="10"><div><p>Hello</p></div></foreignObject></svg>"#;
1051        let pipeline = SvgPipeline::readable()
1052            .with_postprocessor(AppendPass("first"))
1053            .with_postprocessor(AppendPass("second"));
1054        let session = render_session();
1055
1056        let out = pipeline.process_to_string(svg, &session).unwrap();
1057
1058        let first = out.find("<!--0:first:Readable").unwrap();
1059        let second = out.find("<!--1:second:Readable").unwrap();
1060        assert!(first < second);
1061        assert!(out.contains("data-merman-foreignobject"));
1062    }
1063
1064    #[test]
1065    fn custom_postprocessor_output_is_cleaned_by_resvg_finalizer() {
1066        struct InjectActiveContent;
1067
1068        impl SvgPostprocessor for InjectActiveContent {
1069            fn name(&self) -> &'static str {
1070                "inject-active-content"
1071            }
1072
1073            fn process<'a>(
1074                &self,
1075                svg: Cow<'a, str>,
1076                _ctx: &SvgPostprocessContext<'_>,
1077            ) -> Result<Cow<'a, str>> {
1078                Ok(Cow::Owned(svg.replace(
1079                    "</svg>",
1080                    r#"<script>alert(1)</script><rect animation="spin 1s"/></svg>"#,
1081                )))
1082            }
1083        }
1084
1085        let session = render_session();
1086        let output = SvgPipeline::resvg_safe()
1087            .with_postprocessor(InjectActiveContent)
1088            .process_resvg_compatible("<svg></svg>", &session)
1089            .unwrap();
1090
1091        assert!(!output.as_str().contains("script"));
1092        assert!(!output.as_str().contains("animation"));
1093    }
1094
1095    #[test]
1096    fn expanded_draft_is_budgeted_before_terminal_xml_validation() {
1097        struct ExpandDraft;
1098
1099        impl SvgPostprocessor for ExpandDraft {
1100            fn name(&self) -> &'static str {
1101                "expand-draft"
1102            }
1103
1104            fn process<'a>(
1105                &self,
1106                _svg: Cow<'a, str>,
1107                _ctx: &SvgPostprocessContext<'_>,
1108            ) -> Result<Cow<'a, str>> {
1109                Ok(Cow::Owned(format!("<svg>{}</svg>", "x".repeat(128))))
1110            }
1111        }
1112
1113        let session = crate::environment::RenderEnvironment::deterministic()
1114            .with_resource_policy(
1115                crate::resources::RenderResourcePolicy::unbounded_for_trusted_input()
1116                    .with_limit(crate::resources::ResourceLimitId::MaxSvgBytes, 64)
1117                    .unwrap(),
1118            )
1119            .begin_session()
1120            .unwrap();
1121        let error = SvgPipeline::resvg_safe()
1122            .with_postprocessor(ExpandDraft)
1123            .process_resvg_compatible("<svg/>", &session)
1124            .unwrap_err();
1125
1126        assert!(error.to_string().contains("max_svg_bytes"), "{error}");
1127    }
1128
1129    #[test]
1130    fn provided_metadata_admits_raw_svg_bytes_before_xml_cleanup() {
1131        let svg = "<svg>\u{0}</svg>";
1132        let maximum = svg.len() - 1;
1133        let control = OperationControl::new();
1134        let session = crate::environment::RenderEnvironment::deterministic()
1135            .with_resource_policy(
1136                crate::resources::RenderResourcePolicy::unbounded_for_trusted_input()
1137                    .with_limit(crate::resources::ResourceLimitId::MaxSvgBytes, maximum)
1138                    .unwrap(),
1139            )
1140            .begin_session_with_control(control.clone())
1141            .unwrap();
1142        let metadata = SvgPostprocessMetadata::new();
1143
1144        let error = SvgPipeline::parity()
1145            .process_with_metadata(svg, &metadata, &session)
1146            .expect_err("raw SVG bytes must be admitted before XML cleanup allocates");
1147        let Error::ResourceLimitExceeded(first) = error else {
1148            panic!("expected a resource rejection");
1149        };
1150        assert_eq!(first.limit, "max_svg_bytes");
1151        assert_eq!(first.actual, svg.len());
1152        assert_eq!(first.max, maximum);
1153
1154        control.cancel();
1155        let replayed = SvgPipeline::parity()
1156            .process_with_metadata("<svg/>", &metadata, &session)
1157            .expect_err("the first resource terminal must remain sticky");
1158        let Error::ResourceLimitExceeded(replayed) = replayed else {
1159            panic!("expected the resource rejection to replay");
1160        };
1161        assert_eq!(replayed, first);
1162    }
1163
1164    #[test]
1165    fn structure_limit_rejects_before_custom_postprocessors_run() {
1166        struct MustNotRun;
1167
1168        impl SvgPostprocessor for MustNotRun {
1169            fn name(&self) -> &'static str {
1170                "must-not-run"
1171            }
1172
1173            fn process<'a>(
1174                &self,
1175                _svg: Cow<'a, str>,
1176                _ctx: &SvgPostprocessContext<'_>,
1177            ) -> Result<Cow<'a, str>> {
1178                panic!("structure admission must precede custom postprocessing")
1179            }
1180        }
1181
1182        let session = crate::environment::RenderEnvironment::deterministic()
1183            .with_resource_policy(
1184                crate::resources::RenderResourcePolicy::unbounded_for_trusted_input()
1185                    .with_limit(crate::resources::ResourceLimitId::MaxSvgElements, 1)
1186                    .unwrap(),
1187            )
1188            .begin_session()
1189            .unwrap();
1190        let error = SvgPipeline::parity()
1191            .with_postprocessor(MustNotRun)
1192            .process_to_string("<svg><g/></svg>", &session)
1193            .unwrap_err();
1194
1195        assert!(error.to_string().contains("max_svg_elements"), "{error}");
1196    }
1197
1198    #[test]
1199    fn fallback_generated_elements_are_rejected_before_the_overlay_is_completed() {
1200        let session = crate::environment::RenderEnvironment::deterministic()
1201            .with_resource_policy(
1202                crate::resources::RenderResourcePolicy::unbounded_for_trusted_input()
1203                    .with_limit(crate::resources::ResourceLimitId::MaxSvgElements, 4)
1204                    .unwrap(),
1205            )
1206            .begin_session()
1207            .unwrap();
1208        let svg = concat!(
1209            r#"<svg xmlns="http://www.w3.org/2000/svg">"#,
1210            r#"<foreignObject width="10" height="10">"#,
1211            r#"<div xmlns="http://www.w3.org/1999/xhtml">Hello</div>"#,
1212            "</foreignObject></svg>",
1213        );
1214        let error = SvgPipeline::readable()
1215            .process_to_string(svg, &session)
1216            .unwrap_err();
1217
1218        assert!(error.to_string().contains("max_svg_elements"), "{error}");
1219    }
1220
1221    #[test]
1222    fn non_resvg_pipeline_cannot_construct_resvg_compatible_svg() {
1223        let session = render_session();
1224        let error = SvgPipeline::parity()
1225            .process_resvg_compatible("<svg/>", &session)
1226            .unwrap_err();
1227
1228        assert!(error.to_string().contains("resvg-safe terminal preset"));
1229    }
1230
1231    #[test]
1232    fn custom_postprocessor_context_exposes_metadata() {
1233        let svg = r#"<svg id="host-diagram"><rect width="10"/></svg>"#;
1234        let metadata = SvgPostprocessMetadata::from_svg(svg)
1235            .with_diagram_type("flowchart-v2")
1236            .with_diagram_title("Host Diagram");
1237        let pipeline = SvgPipeline::parity().with_postprocessor(AppendPass("meta"));
1238        let session = render_session();
1239
1240        let out = pipeline
1241            .process_to_string_with_metadata(svg, &metadata, &session)
1242            .unwrap();
1243
1244        assert!(out.contains("<!--0:meta:Parity:flowchart-v2:Host Diagram:host-diagram-->"));
1245    }
1246
1247    struct ErrorPass;
1248
1249    impl SvgPostprocessor for ErrorPass {
1250        fn name(&self) -> &'static str {
1251            "error-pass"
1252        }
1253
1254        fn process<'a>(
1255            &self,
1256            _svg: Cow<'a, str>,
1257            _ctx: &SvgPostprocessContext<'_>,
1258        ) -> Result<Cow<'a, str>> {
1259            Err(Error::InvalidModel {
1260                message: "boom".to_string(),
1261            })
1262        }
1263    }
1264
1265    #[test]
1266    fn custom_postprocessor_errors_surface_with_pass_name() {
1267        let session = render_session();
1268        let err = SvgPipeline::parity()
1269            .with_postprocessor(ErrorPass)
1270            .process_to_string("<svg/>", &session)
1271            .unwrap_err();
1272
1273        let message = err.to_string();
1274        assert!(message.contains("error-pass"));
1275        assert!(message.contains("boom"));
1276    }
1277
1278    struct CancelAndErrorPass(OperationControl);
1279
1280    impl SvgPostprocessor for CancelAndErrorPass {
1281        fn name(&self) -> &'static str {
1282            "cancel-and-error"
1283        }
1284
1285        fn process<'a>(
1286            &self,
1287            _svg: Cow<'a, str>,
1288            _ctx: &SvgPostprocessContext<'_>,
1289        ) -> Result<Cow<'a, str>> {
1290            self.0.cancel();
1291            Err(Error::InvalidModel {
1292                message: "opaque failure".to_string(),
1293            })
1294        }
1295    }
1296
1297    #[test]
1298    fn cancellation_observed_after_a_failing_postprocessor_wins_over_wrapping() {
1299        let control = OperationControl::new();
1300        let session = crate::environment::RenderEnvironment::deterministic()
1301            .begin_session_with_control(control.clone())
1302            .unwrap();
1303
1304        let error = SvgPipeline::parity()
1305            .with_postprocessor(CancelAndErrorPass(control))
1306            .process_to_string("<svg/>", &session)
1307            .unwrap_err();
1308
1309        let Error::Cancelled(error) = error else {
1310            panic!("expected structured cancellation");
1311        };
1312        assert_eq!(error.phase, OperationPhase::Postprocess);
1313        assert_eq!(error.reason, CancelReason::Requested);
1314    }
1315}