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