Skip to main content

merman_render/svg/pipeline/
context.rs

1use super::builtin::util::{
2    SvgTagScanner, checkpoint_loop, next_svg_quoted_attr_with_checkpoints, start_tag_name,
3};
4use super::preset::SvgPipelinePreset;
5use crate::environment::{RenderSession, RoutedTextMeasurer, TextMeasurementPhase};
6use crate::family::RenderFamilyKind;
7use crate::resources::{
8    RenderResourcePolicy, ResourceLimitCause, ResourceLimitExceeded, ResourceLimitOverride,
9    ResourceLimitPhase,
10};
11use merman_core::OperationPhase;
12
13#[derive(Debug, Clone, Default, PartialEq, Eq)]
14pub struct SvgPostprocessMetadata {
15    family_kind: Option<RenderFamilyKind>,
16    diagram_type: Option<String>,
17    diagram_title: Option<String>,
18    svg_id: Option<String>,
19}
20
21impl SvgPostprocessMetadata {
22    pub fn new() -> Self {
23        Self::default()
24    }
25
26    /// Recovers descriptive metadata from the root SVG without granting family capabilities.
27    ///
28    /// Family-specific passes consume an explicitly supplied [`RenderFamilyKind`], never metadata
29    /// inferred from SVG text.
30    pub fn from_svg(svg: &str) -> Self {
31        let mut checkpoint = || Ok::<(), std::convert::Infallible>(());
32        match Self::from_svg_with_checkpoints(svg, &mut checkpoint) {
33            Ok(metadata) => metadata,
34            Err(error) => match error {},
35        }
36    }
37
38    pub(crate) fn from_svg_with_execution(
39        svg: &str,
40        execution: SvgPostprocessExecution<'_>,
41    ) -> crate::Result<Self> {
42        execution.checkpoint()?;
43        execution.preflight_svg_byte_count(svg.len())?;
44        Self::from_svg_with_checkpoints(svg, &mut || execution.checkpoint())
45    }
46
47    fn from_svg_with_checkpoints<E>(
48        svg: &str,
49        checkpoint: &mut impl FnMut() -> Result<(), E>,
50    ) -> Result<Self, E> {
51        checkpoint()?;
52        let mut scanner = SvgTagScanner::new(svg);
53        let mut tag_iteration = 0usize;
54        let root_tag = loop {
55            let Some(tag) = scanner.next_with_checkpoints(checkpoint)? else {
56                return Ok(Self::default());
57            };
58            checkpoint_loop(tag_iteration, checkpoint)?;
59            tag_iteration = tag_iteration.saturating_add(1);
60            let Some(root_name) = start_tag_name(tag.raw()) else {
61                continue;
62            };
63            if root_name != "svg" {
64                return Ok(Self::default());
65            }
66            break tag.raw();
67        };
68
69        let mut diagram_type = None;
70        let mut svg_id = None;
71        let mut cursor = 0usize;
72        let mut attribute_iteration = 0usize;
73        while let Some(attribute) =
74            next_svg_quoted_attr_with_checkpoints(root_tag, cursor, checkpoint)?
75        {
76            checkpoint_loop(attribute_iteration, checkpoint)?;
77            attribute_iteration = attribute_iteration.saturating_add(1);
78            let name = &root_tag[attribute.name_start..attribute.name_end];
79            if name.eq_ignore_ascii_case("aria-roledescription") && diagram_type.is_none() {
80                diagram_type = Some(copy_trimmed_with_checkpoints(
81                    &root_tag[attribute.value_start..attribute.value_end],
82                    checkpoint,
83                )?);
84            } else if name.eq_ignore_ascii_case("id") && svg_id.is_none() {
85                svg_id = Some(copy_trimmed_with_checkpoints(
86                    &root_tag[attribute.value_start..attribute.value_end],
87                    checkpoint,
88                )?);
89            }
90            cursor = attribute.full_end;
91        }
92        checkpoint()?;
93
94        Ok(Self {
95            diagram_type,
96            svg_id,
97            ..Self::default()
98        })
99    }
100
101    /// Supplies the renderer-owned family identity required by family-specific built-in passes.
102    pub(crate) fn with_family_kind(mut self, family_kind: RenderFamilyKind) -> Self {
103        self.family_kind = Some(family_kind);
104        self
105    }
106
107    /// Records descriptive diagram metadata without granting family-specific processing.
108    pub fn with_diagram_type(mut self, diagram_type: impl Into<String>) -> Self {
109        self.diagram_type = Some(diagram_type.into());
110        self
111    }
112
113    pub fn with_optional_diagram_type(mut self, diagram_type: Option<impl Into<String>>) -> Self {
114        self.diagram_type = diagram_type.map(Into::into);
115        self
116    }
117
118    pub fn with_diagram_title(mut self, diagram_title: impl Into<String>) -> Self {
119        self.diagram_title = Some(diagram_title.into());
120        self
121    }
122
123    pub fn with_optional_diagram_title(mut self, diagram_title: Option<impl Into<String>>) -> Self {
124        self.diagram_title = diagram_title.map(Into::into);
125        self
126    }
127
128    pub fn with_svg_id(mut self, svg_id: impl Into<String>) -> Self {
129        self.svg_id = Some(svg_id.into());
130        self
131    }
132
133    pub fn with_optional_svg_id(mut self, svg_id: Option<impl Into<String>>) -> Self {
134        if let Some(svg_id) = svg_id {
135            self.svg_id = Some(svg_id.into());
136        }
137        self
138    }
139
140    pub fn diagram_type(&self) -> Option<&str> {
141        self.diagram_type.as_deref()
142    }
143
144    pub fn family_kind(&self) -> Option<RenderFamilyKind> {
145        self.family_kind
146    }
147
148    pub fn diagram_title(&self) -> Option<&str> {
149        self.diagram_title.as_deref()
150    }
151
152    pub fn svg_id(&self) -> Option<&str> {
153        self.svg_id.as_deref()
154    }
155}
156
157fn copy_trimmed_with_checkpoints<E>(
158    value: &str,
159    checkpoint: &mut impl FnMut() -> Result<(), E>,
160) -> Result<String, E> {
161    let value = value.trim();
162    checkpoint()?;
163    let mut copied = String::with_capacity(value.len());
164    for (index, character) in value.chars().enumerate() {
165        checkpoint_loop(index, checkpoint)?;
166        copied.push(character);
167    }
168    checkpoint()?;
169    Ok(copied)
170}
171
172/// Operation-owned capabilities available to built-in SVG postprocessing stages.
173///
174/// Custom [`super::SvgPostprocessor`] implementations retain the documented opaque-callback
175/// boundary. This projection is intentionally crate-private so built-ins can cooperate without
176/// exposing the render session or its resource ledger through the public extension API.
177#[derive(Clone, Copy)]
178pub(crate) struct SvgPostprocessExecution<'a> {
179    session: &'a RenderSession,
180}
181
182impl<'a> SvgPostprocessExecution<'a> {
183    pub(crate) const fn new(session: &'a RenderSession) -> Self {
184        Self { session }
185    }
186
187    pub(crate) fn checkpoint(self) -> crate::Result<()> {
188        self.session.checkpoint(OperationPhase::Postprocess)
189    }
190
191    pub(crate) const fn resource_policy(self) -> RenderResourcePolicy {
192        self.session.resource_policy()
193    }
194
195    pub(crate) fn preflight_svg_byte_count(self, actual: usize) -> crate::Result<()> {
196        self.session
197            .work_meter()
198            .preflight_svg_byte_count(
199                actual,
200                ResourceLimitPhase::SvgPostprocess,
201                OperationPhase::Postprocess,
202            )
203            .map_err(Into::into)
204    }
205
206    pub(crate) fn preflight_svg_structure(
207        self,
208        elements: usize,
209        tree_depth: usize,
210    ) -> crate::Result<()> {
211        self.session
212            .work_meter()
213            .preflight_svg_structure(elements, tree_depth, OperationPhase::Postprocess)
214            .map_err(Into::into)
215    }
216
217    pub(crate) fn selector_index_limit(self, actual: usize, maximum: usize) -> crate::Error {
218        let policy = self.resource_policy();
219        ResourceLimitExceeded {
220            cause: ResourceLimitCause::Ceiling,
221            phase: ResourceLimitPhase::SvgPostprocess,
222            limit: "svg_fallback_selector_index",
223            actual,
224            max: maximum,
225            profile: policy.profile(),
226            explicit_overrides: policy
227                .explicit_overrides()
228                .map(|(id, value)| ResourceLimitOverride { id, value })
229                .collect(),
230        }
231        .into()
232    }
233
234    pub(crate) fn svg_byte_count_overflow(self) -> crate::Error {
235        self.session
236            .work_meter()
237            .terminate_svg_byte_count_overflow(
238                ResourceLimitPhase::SvgPostprocess,
239                OperationPhase::Postprocess,
240            )
241            .into()
242    }
243
244    pub(crate) fn terminate_resource_error(self, error: ResourceLimitExceeded) -> crate::Error {
245        self.session
246            .work_meter()
247            .terminate_absolute_resource_error(error, OperationPhase::Postprocess)
248            .into()
249    }
250
251    pub(crate) fn controlled_text_measurer(
252        self,
253        phase: TextMeasurementPhase,
254    ) -> RoutedTextMeasurer<'a> {
255        self.session
256            .controlled_text_measurer(phase, OperationPhase::Postprocess)
257    }
258}
259
260#[derive(Clone, Copy)]
261pub struct SvgPostprocessContext<'a> {
262    preset: SvgPipelinePreset,
263    pass_index: usize,
264    pass_name: &'a str,
265    metadata: &'a SvgPostprocessMetadata,
266    session: &'a RenderSession,
267}
268
269impl std::fmt::Debug for SvgPostprocessContext<'_> {
270    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
271        f.debug_struct("SvgPostprocessContext")
272            .field("preset", &self.preset)
273            .field("pass_index", &self.pass_index)
274            .field("pass_name", &self.pass_name)
275            .field("metadata", &self.metadata)
276            .finish_non_exhaustive()
277    }
278}
279
280impl<'a> SvgPostprocessContext<'a> {
281    pub(crate) fn new(
282        preset: SvgPipelinePreset,
283        pass_index: usize,
284        pass_name: &'a str,
285        metadata: &'a SvgPostprocessMetadata,
286        session: &'a RenderSession,
287    ) -> Self {
288        Self {
289            preset,
290            pass_index,
291            pass_name,
292            metadata,
293            session,
294        }
295    }
296
297    pub fn preset(&self) -> SvgPipelinePreset {
298        self.preset
299    }
300
301    pub fn pass_index(&self) -> usize {
302        self.pass_index
303    }
304
305    pub fn pass_name(&self) -> &'a str {
306        self.pass_name
307    }
308
309    pub fn diagram_type(&self) -> Option<&'a str> {
310        self.metadata.diagram_type()
311    }
312
313    pub fn family_kind(&self) -> Option<RenderFamilyKind> {
314        self.metadata.family_kind()
315    }
316
317    pub fn diagram_title(&self) -> Option<&'a str> {
318        self.metadata.diagram_title()
319    }
320
321    pub fn svg_id(&self) -> Option<&'a str> {
322        self.metadata.svg_id()
323    }
324
325    pub fn text_measurer(&self, phase: TextMeasurementPhase) -> RoutedTextMeasurer<'a> {
326        self.session.text_measurer(phase)
327    }
328
329    pub(crate) fn controlled_text_measurer(
330        &self,
331        phase: TextMeasurementPhase,
332    ) -> RoutedTextMeasurer<'a> {
333        self.session
334            .controlled_text_measurer(phase, OperationPhase::Postprocess)
335    }
336
337    /// Checks the operation-owned postprocess control for work performed by a built-in pass.
338    pub(crate) fn checkpoint(&self) -> crate::Result<()> {
339        self.execution().checkpoint()
340    }
341
342    pub(crate) const fn execution(&self) -> SvgPostprocessExecution<'a> {
343        SvgPostprocessExecution::new(self.session)
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn metadata_extracts_root_svg_id_and_diagram_type() {
353        let metadata = SvgPostprocessMetadata::from_svg(
354            r#"<svg xmlns="http://www.w3.org/2000/svg" id="diagram-1" aria-roledescription="quadrantChart"><g/></svg>"#,
355        );
356
357        assert_eq!(metadata.svg_id(), Some("diagram-1"));
358        assert_eq!(metadata.diagram_type(), Some("quadrantChart"));
359        assert_eq!(metadata.family_kind(), None);
360    }
361
362    #[test]
363    fn metadata_ignores_non_root_ids() {
364        let metadata = SvgPostprocessMetadata::from_svg(
365            r#"<g id="nested" aria-roledescription="quadrantChart"></g>"#,
366        );
367
368        assert_eq!(metadata.svg_id(), None);
369        assert_eq!(metadata.diagram_type(), None);
370        assert_eq!(metadata.family_kind(), None);
371    }
372
373    #[test]
374    fn metadata_rejects_comment_spoofs_and_nested_svg_roles() {
375        for svg in [
376            r#"<!-- <svg id="spoof" aria-roledescription="quadrantChart"> --><g/>"#,
377            r#"<g><svg id="nested" aria-roledescription="quadrantChart"/></g>"#,
378        ] {
379            let metadata = SvgPostprocessMetadata::from_svg(svg);
380
381            assert_eq!(metadata.svg_id(), None, "{svg}");
382            assert_eq!(metadata.diagram_type(), None, "{svg}");
383            assert_eq!(metadata.family_kind(), None, "{svg}");
384        }
385    }
386
387    #[test]
388    fn metadata_accepts_a_root_svg_after_xml_prolog_and_comment() {
389        let metadata = SvgPostprocessMetadata::from_svg(
390            r#"<?xml version="1.0"?><!-- generated --><svg id="root" aria-roledescription="quadrantChart"/>"#,
391        );
392
393        assert_eq!(metadata.svg_id(), Some("root"));
394        assert_eq!(metadata.diagram_type(), Some("quadrantChart"));
395        assert_eq!(metadata.family_kind(), None);
396    }
397
398    #[test]
399    fn diagram_type_string_does_not_forge_typed_family_context() {
400        let metadata = SvgPostprocessMetadata::new().with_diagram_type("quadrantChart");
401
402        assert_eq!(metadata.diagram_type(), Some("quadrantChart"));
403        assert_eq!(metadata.family_kind(), None);
404    }
405}