Skip to main content

document_svg/
svg.rs

1//! Deterministic SVG serialization for the public [`crate::ir`] model.
2//!
3//! Normal document conversion should use [`crate::convert_path`]. Call
4//! [`write_page`] directly only when an application already has an
5//! [`crate::ir::Page`] that it wants to serialize.
6
7pub mod color;
8pub mod geometry;
9pub mod reader;
10
11pub use color::{ParsedStyle, parse_color, parse_color_hex, parse_style};
12pub use geometry::{
13    Point2D, Transform2D, parse_transform, sample_cubic_bezier, sample_elliptical_arc,
14    sample_quad_bezier,
15};
16pub use reader::{
17    PathToken, SvgDocument, SvgElement, SvgPathTokenizer, SvgVectorDocument, decompose_svg_path,
18    extract_embedded_source, parse_svg_elements,
19};
20
21use std::borrow::Cow;
22use std::collections::{HashMap, HashSet};
23use std::io::{Read, Write};
24
25use crate::error::Result;
26use crate::ir::{
27    ClipPath, ImageColorEffect, LineCap, LineJoin, Matrix, Node, Page, Paint, Stroke, TextAnchor,
28    TilingPatternDefinition,
29};
30
31#[derive(Debug, Clone, Copy)]
32pub struct SvgOptions {
33    pub include_metadata: bool,
34    pub precision: usize,
35}
36
37impl Default for SvgOptions {
38    fn default() -> Self {
39        Self {
40            include_metadata: true,
41            precision: 5,
42        }
43    }
44}
45
46pub fn write_page<W: Write>(page: &Page, mut output: W, options: SvgOptions) -> Result<()> {
47    let options = SvgOptions {
48        precision: options.precision.min(12),
49        ..options
50    };
51    let clip_parents = page
52        .clips
53        .iter()
54        .map(|clip| (clip.id.clone(), clip.parent_id.clone()))
55        .collect::<HashMap<_, _>>();
56    writeln!(output, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>")?;
57    // draw.io stores a diagram's own source in a `content` attribute on the
58    // root and reads it back when the SVG is opened as a diagram. Every SVG
59    // renderer ignores the attribute, so carrying it costs nothing but bytes.
60    let embedded_source = page
61        .embedded_source
62        .as_deref()
63        .filter(|value| !value.is_empty())
64        .map(|value| format!(" content=\"{}\"", escape_attr(value)))
65        .unwrap_or_default();
66    writeln!(
67        output,
68        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{}pt\" height=\"{}pt\" viewBox=\"0 0 {} {}\" data-source-format=\"{}\" data-source-page=\"{}\"{embedded_source}>",
69        number(page.width, options.precision),
70        number(page.height, options.precision),
71        number(page.width, options.precision),
72        number(page.height, options.precision),
73        escape_attr(&page.source_format),
74        page.number
75    )?;
76    if !page.title.is_empty() {
77        writeln!(output, "  <title>{}</title>", escape_text(&page.title))?;
78    }
79    if !page.description.is_empty() {
80        writeln!(output, "  <desc>{}</desc>", escape_text(&page.description))?;
81    }
82    if options.include_metadata {
83        let warnings = serde_json::to_string(&page.warnings)?;
84        writeln!(
85            output,
86            "  <metadata id=\"docsvg-metadata\">{}</metadata>",
87            escape_text(&format!(
88                "{{\"page\":{},\"nodes\":{},\"warnings\":{warnings}}}",
89                page.number,
90                page.nodes.len()
91            ))
92        )?;
93    }
94    writeln!(
95        output,
96        "  <rect x=\"0\" y=\"0\" width=\"{}\" height=\"{}\" fill=\"#FFFFFF\" data-role=\"page-background\"/>",
97        number(page.width, options.precision),
98        number(page.height, options.precision)
99    )?;
100    let mut gradient_use_index = 0usize;
101    if !page.clips.is_empty()
102        || !page.masks.is_empty()
103        || !page.patterns.is_empty()
104        || has_gradients(&page.nodes)
105        || has_drawingml_effects(&page.nodes)
106    {
107        writeln!(output, "  <defs>")?;
108        let mut shadow_ids = HashSet::new();
109        write_drawingml_effect_definitions(
110            &mut output,
111            &page.nodes,
112            page.width,
113            page.height,
114            options.precision,
115            &mut shadow_ids,
116        )?;
117        for clip in &page.clips {
118            write_clip(&mut output, clip, options.precision)?;
119        }
120        let mut gradient_index = 0usize;
121        for pattern in &page.patterns {
122            for node in &pattern.nodes {
123                write_gradient_definitions(
124                    &mut output,
125                    node,
126                    &mut gradient_index,
127                    options.precision,
128                )?;
129            }
130            write_tiling_pattern(
131                &mut output,
132                pattern,
133                &mut gradient_use_index,
134                options.precision,
135                &clip_parents,
136            )?;
137        }
138        for mask in &page.masks {
139            for node in &mask.nodes {
140                write_gradient_definitions(
141                    &mut output,
142                    node,
143                    &mut gradient_index,
144                    options.precision,
145                )?;
146            }
147        }
148        for node in &page.nodes {
149            write_gradient_definitions(&mut output, node, &mut gradient_index, options.precision)?;
150        }
151        for mask in &page.masks {
152            if !mask.transfer_values.is_empty() {
153                write_mask_transfer_filter(
154                    &mut output,
155                    mask,
156                    page.width,
157                    page.height,
158                    options.precision,
159                )?;
160            }
161            writeln!(
162                output,
163                "    <mask id=\"{}\" maskUnits=\"userSpaceOnUse\" x=\"0\" y=\"0\" width=\"{}\" height=\"{}\" style=\"mask-type:{}\">",
164                escape_attr(&mask.id),
165                number(page.width, options.precision),
166                number(page.height, options.precision),
167                escape_attr(&mask.mask_type)
168            )?;
169            if !mask.transfer_values.is_empty() {
170                writeln!(
171                    output,
172                    "      <g filter=\"url(#{}-transfer)\">",
173                    escape_attr(&mask.id)
174                )?;
175            }
176            for node in &mask.nodes {
177                write_node(
178                    &mut output,
179                    node,
180                    3 + usize::from(!mask.transfer_values.is_empty()),
181                    &mut gradient_use_index,
182                    options.precision,
183                    &clip_parents,
184                )?;
185            }
186            if !mask.transfer_values.is_empty() {
187                writeln!(output, "      </g>")?;
188            }
189            writeln!(output, "    </mask>")?;
190        }
191        writeln!(output, "  </defs>")?;
192    }
193    let mut gradient_index = gradient_use_index;
194    for node in &page.nodes {
195        write_node(
196            &mut output,
197            node,
198            1,
199            &mut gradient_index,
200            options.precision,
201            &clip_parents,
202        )?;
203    }
204    writeln!(output, "</svg>")?;
205    Ok(())
206}
207
208fn write_mask_transfer_filter<W: Write>(
209    output: &mut W,
210    mask: &crate::ir::MaskDefinition,
211    width: f64,
212    height: f64,
213    precision: usize,
214) -> Result<()> {
215    let table = mask
216        .transfer_values
217        .iter()
218        .map(|value| number(value.clamp(0.0, 1.0), precision))
219        .collect::<Vec<_>>()
220        .join(" ");
221    writeln!(
222        output,
223        "    <filter id=\"{}-transfer\" filterUnits=\"userSpaceOnUse\" x=\"0\" y=\"0\" width=\"{}\" height=\"{}\" color-interpolation-filters=\"sRGB\">",
224        escape_attr(&mask.id),
225        number(width, precision),
226        number(height, precision)
227    )?;
228    if mask.mask_type == "luminance" {
229        writeln!(
230            output,
231            "      <feColorMatrix type=\"matrix\" values=\"0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0 0 0 1 0\"/>"
232        )?;
233        writeln!(
234            output,
235            "      <feComponentTransfer><feFuncR type=\"table\" tableValues=\"{table}\"/><feFuncG type=\"table\" tableValues=\"{table}\"/><feFuncB type=\"table\" tableValues=\"{table}\"/></feComponentTransfer>"
236        )?;
237    } else {
238        writeln!(
239            output,
240            "      <feComponentTransfer><feFuncA type=\"table\" tableValues=\"{table}\"/></feComponentTransfer>"
241        )?;
242    }
243    writeln!(output, "    </filter>")?;
244    Ok(())
245}
246
247fn write_tiling_pattern<W: Write>(
248    output: &mut W,
249    pattern: &TilingPatternDefinition,
250    gradient_index: &mut usize,
251    precision: usize,
252    clip_parents: &HashMap<String, Option<String>>,
253) -> Result<()> {
254    writeln!(
255        output,
256        "    <pattern id=\"{}\" patternUnits=\"userSpaceOnUse\" patternContentUnits=\"userSpaceOnUse\" x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" patternTransform=\"{}\">",
257        escape_attr(&pattern.id),
258        number(pattern.x, precision),
259        number(pattern.y, precision),
260        number(pattern.width.abs().max(1e-9), precision),
261        number(pattern.height.abs().max(1e-9), precision),
262        matrix(pattern.transform, precision),
263    )?;
264    for node in &pattern.nodes {
265        write_node(output, node, 3, gradient_index, precision, clip_parents)?;
266    }
267    writeln!(output, "    </pattern>")?;
268    Ok(())
269}
270
271fn write_clip<W: Write>(output: &mut W, clip: &ClipPath, precision: usize) -> Result<()> {
272    write!(
273        output,
274        "    <clipPath id=\"{}\" clipPathUnits=\"userSpaceOnUse\">",
275        escape_attr(&clip.id),
276    )?;
277    write!(
278        output,
279        "<path d=\"{}\" transform=\"{}\" clip-rule=\"{}\"/>",
280        escape_attr(&clip.d),
281        matrix(clip.transform, precision),
282        escape_attr(&clip.fill_rule)
283    )?;
284    for member in &clip.additional_paths {
285        write!(
286            output,
287            "<path d=\"{}\" transform=\"{}\" clip-rule=\"{}\"/>",
288            escape_attr(&member.d),
289            matrix(member.transform, precision),
290            escape_attr(&member.fill_rule)
291        )?;
292    }
293    writeln!(output, "</clipPath>")?;
294    Ok(())
295}
296
297fn write_drawingml_effect_definitions<W: Write>(
298    output: &mut W,
299    nodes: &[Node],
300    page_width: f64,
301    page_height: f64,
302    precision: usize,
303    emitted: &mut HashSet<String>,
304) -> Result<()> {
305    for node in nodes {
306        let (id, meta, children) = match node {
307            Node::Path { id, meta, .. }
308            | Node::Text { id, meta, .. }
309            | Node::Image { id, meta, .. } => (id, meta, None),
310            Node::Group {
311                id, meta, nodes, ..
312            } => (id, meta, Some(nodes.as_slice())),
313        };
314        if let Some(filter_prefix) = drawingml_filter_prefix(meta)
315            && emitted.insert(id.clone())
316        {
317            let shadow_values = meta.outer_shadow.as_ref().map(|shadow| {
318                let angle = shadow.direction_degrees.to_radians();
319                let distance = shadow.distance.clamp(0.0, 4_096.0);
320                let blur_radius = shadow.blur_radius.clamp(0.0, 512.0);
321                (
322                    blur_radius,
323                    distance * angle.cos(),
324                    distance * angle.sin(),
325                    blur_radius * 3.0 + distance + 1.0,
326                )
327            });
328            let glow_radius = meta
329                .glow
330                .as_ref()
331                .map_or(0.0, |glow| glow.radius.clamp(0.0, 512.0));
332            let padding = shadow_values
333                .map_or(0.0, |values| values.3)
334                .max(glow_radius * 3.0 + 1.0);
335            writeln!(
336                output,
337                "    <filter id=\"{}-{}\" filterUnits=\"userSpaceOnUse\" primitiveUnits=\"userSpaceOnUse\" x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" color-interpolation-filters=\"sRGB\">",
338                filter_prefix,
339                escape_attr(id),
340                number(-padding, precision),
341                number(-padding, precision),
342                number(page_width + padding * 2.0, precision),
343                number(page_height + padding * 2.0, precision),
344            )?;
345            let image_input = write_image_color_effects(output, &meta.image_effects, precision)?;
346            let alpha_input = if meta.image_effects.is_empty() {
347                "SourceAlpha"
348            } else {
349                image_input.as_str()
350            };
351            if let (Some(shadow), Some((blur_radius, dx, dy, _))) =
352                (&meta.outer_shadow, shadow_values)
353            {
354                writeln!(
355                    output,
356                    "      <feGaussianBlur in=\"{}\" stdDeviation=\"{}\" result=\"shadow-blur\"/>",
357                    alpha_input,
358                    number(blur_radius / 2.0, precision)
359                )?;
360                writeln!(
361                    output,
362                    "      <feOffset in=\"shadow-blur\" dx=\"{}\" dy=\"{}\" result=\"shadow-offset\"/>",
363                    number(dx, precision),
364                    number(dy, precision)
365                )?;
366                writeln!(
367                    output,
368                    "      <feFlood flood-color=\"{}\" flood-opacity=\"{}\" result=\"shadow-color\"/>",
369                    escape_attr(&shadow.color),
370                    number(shadow.opacity.clamp(0.0, 1.0), precision)
371                )?;
372                writeln!(
373                    output,
374                    "      <feComposite in=\"shadow-color\" in2=\"shadow-offset\" operator=\"in\" result=\"shadow\"/>"
375                )?;
376            }
377            if let Some(glow) = &meta.glow {
378                writeln!(
379                    output,
380                    "      <feGaussianBlur in=\"{}\" stdDeviation=\"{}\" result=\"glow-blur\"/>",
381                    alpha_input,
382                    number(glow_radius / 2.0, precision)
383                )?;
384                writeln!(
385                    output,
386                    "      <feFlood flood-color=\"{}\" flood-opacity=\"{}\" result=\"glow-color\"/>",
387                    escape_attr(&glow.color),
388                    number(glow.opacity.clamp(0.0, 1.0), precision)
389                )?;
390                writeln!(
391                    output,
392                    "      <feComposite in=\"glow-color\" in2=\"glow-blur\" operator=\"in\" result=\"glow\"/>"
393                )?;
394            }
395            write!(output, "      <feMerge>")?;
396            if meta.outer_shadow.is_some() {
397                write!(output, "<feMergeNode in=\"shadow\"/>")?;
398            }
399            if meta.glow.is_some() {
400                write!(output, "<feMergeNode in=\"glow\"/>")?;
401            }
402            writeln!(output, "<feMergeNode in=\"{}\"/></feMerge>", image_input)?;
403            writeln!(output, "    </filter>")?;
404        }
405        if let Some(children) = children {
406            write_drawingml_effect_definitions(
407                output,
408                children,
409                page_width,
410                page_height,
411                precision,
412                emitted,
413            )?;
414        }
415    }
416    Ok(())
417}
418
419fn write_image_color_effects<W: Write>(
420    output: &mut W,
421    effects: &[ImageColorEffect],
422    precision: usize,
423) -> Result<String> {
424    let mut input = "SourceGraphic".to_owned();
425    for (index, effect) in effects.iter().enumerate() {
426        let number_index = index + 1;
427        let result = format!("image-effect-{number_index}");
428        match effect {
429            ImageColorEffect::Duotone { dark, light } => {
430                let dark = svg_hex_rgb(dark).unwrap_or([0.0; 3]);
431                let light = svg_hex_rgb(light).unwrap_or([1.0; 3]);
432                let gray = format!("{result}-gray");
433                writeln!(
434                    output,
435                    "      <feColorMatrix in=\"{}\" type=\"matrix\" values=\"0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0 0 0 1 0\" result=\"{}\"/>",
436                    input, gray
437                )?;
438                writeln!(
439                    output,
440                    "      <feComponentTransfer in=\"{}\" result=\"{}\"><feFuncR type=\"linear\" slope=\"{}\" intercept=\"{}\"/><feFuncG type=\"linear\" slope=\"{}\" intercept=\"{}\"/><feFuncB type=\"linear\" slope=\"{}\" intercept=\"{}\"/></feComponentTransfer>",
441                    gray,
442                    result,
443                    number(light[0] - dark[0], precision + 2),
444                    number(dark[0], precision + 2),
445                    number(light[1] - dark[1], precision + 2),
446                    number(dark[1], precision + 2),
447                    number(light[2] - dark[2], precision + 2),
448                    number(dark[2], precision + 2),
449                )?;
450            }
451            ImageColorEffect::Grayscale => {
452                writeln!(
453                    output,
454                    "      <feColorMatrix in=\"{}\" type=\"matrix\" values=\"0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0 0 0 1 0\" result=\"{}\"/>",
455                    input, result
456                )?;
457            }
458            ImageColorEffect::Luminance {
459                brightness,
460                contrast,
461            } => {
462                let contrast = contrast.clamp(-0.999, 0.999);
463                let (contrast_slope, contrast_intercept) = if contrast >= 0.0 {
464                    let slope = 1.0 / (1.0 - contrast);
465                    (slope, 0.5 - slope * 0.5)
466                } else {
467                    let slope = 1.0 + contrast;
468                    (slope, 0.5 - slope * 0.5)
469                };
470                let brightness = brightness.clamp(-1.0, 1.0);
471                let brightness_slope = 1.0 - brightness.abs();
472                let slope = contrast_slope * brightness_slope;
473                let intercept = contrast_intercept * brightness_slope + brightness.max(0.0);
474                writeln!(
475                    output,
476                    "      <feComponentTransfer in=\"{}\" result=\"{}\"><feFuncR type=\"linear\" slope=\"{}\" intercept=\"{}\"/><feFuncG type=\"linear\" slope=\"{}\" intercept=\"{}\"/><feFuncB type=\"linear\" slope=\"{}\" intercept=\"{}\"/></feComponentTransfer>",
477                    input,
478                    result,
479                    number(slope, precision + 2),
480                    number(intercept, precision + 2),
481                    number(slope, precision + 2),
482                    number(intercept, precision + 2),
483                    number(slope, precision + 2),
484                    number(intercept, precision + 2),
485                )?;
486            }
487            ImageColorEffect::ColorChange {
488                from,
489                to,
490                to_opacity,
491            } => {
492                let target = format!("{result}-target");
493                let difference = format!("{result}-difference");
494                let distance = format!("{result}-distance");
495                let mask = format!("{result}-mask");
496                let replacement_color = format!("{result}-replacement-color");
497                let replacement = format!("{result}-replacement");
498                let remainder = format!("{result}-remainder");
499                writeln!(
500                    output,
501                    "      <feFlood flood-color=\"{}\" result=\"{}\"/>",
502                    escape_attr(from),
503                    target
504                )?;
505                writeln!(
506                    output,
507                    "      <feBlend in=\"{}\" in2=\"{}\" mode=\"difference\" result=\"{}\"/>",
508                    input, target, difference
509                )?;
510                writeln!(
511                    output,
512                    "      <feColorMatrix in=\"{}\" type=\"matrix\" values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 255 255 255 0 0\" result=\"{}\"/>",
513                    difference, distance
514                )?;
515                writeln!(
516                    output,
517                    "      <feComponentTransfer in=\"{}\" result=\"{}\"><feFuncA type=\"linear\" slope=\"-1\" intercept=\"1\"/></feComponentTransfer>",
518                    distance, mask
519                )?;
520                writeln!(
521                    output,
522                    "      <feFlood flood-color=\"{}\" flood-opacity=\"{}\" result=\"{}\"/>",
523                    escape_attr(to),
524                    number(to_opacity.clamp(0.0, 1.0), precision),
525                    replacement_color
526                )?;
527                writeln!(
528                    output,
529                    "      <feComposite in=\"{}\" in2=\"{}\" operator=\"in\" result=\"{}\"/>",
530                    replacement_color, mask, replacement
531                )?;
532                writeln!(
533                    output,
534                    "      <feComposite in=\"{}\" in2=\"{}\" operator=\"out\" result=\"{}\"/>",
535                    input, mask, remainder
536                )?;
537                writeln!(
538                    output,
539                    "      <feMerge result=\"{}\"><feMergeNode in=\"{}\"/><feMergeNode in=\"{}\"/></feMerge>",
540                    result, remainder, replacement
541                )?;
542            }
543        }
544        input = result;
545    }
546    Ok(input)
547}
548
549fn svg_hex_rgb(color: &str) -> Option<[f64; 3]> {
550    let color = color.trim_start_matches('#');
551    if color.len() != 6 || !color.is_ascii() {
552        return None;
553    }
554    Some([
555        f64::from(u8::from_str_radix(&color[0..2], 16).ok()?) / 255.0,
556        f64::from(u8::from_str_radix(&color[2..4], 16).ok()?) / 255.0,
557        f64::from(u8::from_str_radix(&color[4..6], 16).ok()?) / 255.0,
558    ])
559}
560
561fn drawingml_filter_prefix(meta: &crate::ir::SourceMeta) -> Option<&'static str> {
562    let shadow = meta.outer_shadow.is_some();
563    let glow = meta.glow.is_some();
564    let image = !meta.image_effects.is_empty();
565    match (shadow, glow, image) {
566        (false, false, false) => None,
567        (true, false, false) => Some("outer-shadow"),
568        (false, true, false) => Some("glow"),
569        (false, false, true) => Some("image-effects"),
570        _ => Some("drawingml-effects"),
571    }
572}
573
574fn write_gradient_definitions<W: Write>(
575    output: &mut W,
576    node: &Node,
577    gradient_index: &mut usize,
578    precision: usize,
579) -> Result<()> {
580    match node {
581        Node::Path { fill, stroke, .. } => {
582            write_paint_gradient(output, fill, gradient_index, precision)?;
583            write_paint_gradient(output, &stroke.paint, gradient_index, precision)?;
584        }
585        Node::Text { runs, .. } => {
586            for run in runs {
587                write_paint_gradient(output, &run.fill, gradient_index, precision)?;
588            }
589        }
590        Node::Group { nodes, .. } => {
591            for child in nodes {
592                write_gradient_definitions(output, child, gradient_index, precision)?;
593            }
594        }
595        Node::Image { .. } => {}
596    }
597    Ok(())
598}
599
600fn write_paint_gradient<W: Write>(
601    output: &mut W,
602    paint: &Paint,
603    gradient_index: &mut usize,
604    precision: usize,
605) -> Result<()> {
606    let (stops, closing_tag) = match paint {
607        Paint::LinearGradient(gradient) => {
608            *gradient_index += 1;
609            writeln!(
610                output,
611                "    <linearGradient id=\"gradient-{}\" gradientUnits=\"userSpaceOnUse\" x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\">",
612                gradient_index,
613                number(gradient.x1, precision),
614                number(gradient.y1, precision),
615                number(gradient.x2, precision),
616                number(gradient.y2, precision)
617            )?;
618            (&gradient.stops, "linearGradient")
619        }
620        Paint::RadialGradient(gradient) => {
621            *gradient_index += 1;
622            writeln!(
623                output,
624                "    <radialGradient id=\"gradient-{}\" gradientUnits=\"userSpaceOnUse\" fx=\"{}\" fy=\"{}\" fr=\"{}\" cx=\"{}\" cy=\"{}\" r=\"{}\" gradientTransform=\"{}\">",
625                gradient_index,
626                number(gradient.fx, precision),
627                number(gradient.fy, precision),
628                number(gradient.fr, precision),
629                number(gradient.cx, precision),
630                number(gradient.cy, precision),
631                number(gradient.radius, precision),
632                matrix(gradient.transform, precision)
633            )?;
634            (&gradient.stops, "radialGradient")
635        }
636        _ => return Ok(()),
637    };
638    for stop in stops {
639        writeln!(
640            output,
641            "      <stop offset=\"{}\" stop-color=\"{}\" stop-opacity=\"{}\"/>",
642            number(stop.offset.clamp(0.0, 1.0), precision),
643            escape_attr(&stop.color),
644            number(stop.opacity.clamp(0.0, 1.0), precision)
645        )?;
646    }
647    writeln!(output, "    </{closing_tag}>")?;
648    Ok(())
649}
650
651fn write_node<W: Write>(
652    output: &mut W,
653    node: &Node,
654    depth: usize,
655    gradient_index: &mut usize,
656    precision: usize,
657    clip_parents: &HashMap<String, Option<String>>,
658) -> Result<()> {
659    let meta = match node {
660        Node::Path { meta, .. }
661        | Node::Text { meta, .. }
662        | Node::Image { meta, .. }
663        | Node::Group { meta, .. } => meta,
664    };
665    let has_effect_wrapper = !meta.mask_id.is_empty()
666        || (!meta.blend_mode.is_empty() && meta.blend_mode != "normal")
667        || meta.isolation;
668    let node_clip_id = match node {
669        Node::Path { clip_id, .. }
670        | Node::Text { clip_id, .. }
671        | Node::Image { clip_id, .. }
672        | Node::Group { clip_id, .. } => clip_id.as_deref(),
673    };
674    let parent_clips = parent_clip_chain(node_clip_id, clip_parents);
675    for (index, parent_clip) in parent_clips.iter().enumerate() {
676        let indent = "  ".repeat(depth + index);
677        writeln!(
678            output,
679            "{indent}<g clip-path=\"url(#{})\">",
680            escape_attr(parent_clip)
681        )?;
682    }
683    let effect_depth = depth + parent_clips.len();
684    let wrapper_indent = "  ".repeat(effect_depth);
685    if has_effect_wrapper {
686        write!(output, "{wrapper_indent}<g")?;
687        if !meta.mask_id.is_empty() {
688            write!(output, " mask=\"url(#{})\"", escape_attr(&meta.mask_id))?;
689        }
690        let mut styles = Vec::new();
691        if !meta.blend_mode.is_empty() && meta.blend_mode != "normal" {
692            styles.push(format!("mix-blend-mode:{}", escape_attr(&meta.blend_mode)));
693        }
694        if meta.isolation {
695            styles.push("isolation:isolate".into());
696        }
697        if !styles.is_empty() {
698            write!(output, " style=\"{}\"", styles.join(";"))?;
699        }
700        writeln!(output, ">")?;
701    }
702    let content_depth = effect_depth + usize::from(has_effect_wrapper);
703    let indent = "  ".repeat(content_depth);
704    match node {
705        Node::Path {
706            id,
707            d,
708            fill_rule,
709            fill,
710            stroke,
711            transform,
712            clip_id: _,
713            meta,
714        } => {
715            write!(
716                output,
717                "{indent}<path id=\"{}\" d=\"{}\" fill-rule=\"{}\" transform=\"{}\"",
718                escape_attr(id),
719                escape_attr(d),
720                escape_attr(fill_rule),
721                matrix(*transform, precision)
722            )?;
723            write_paint_attributes(output, fill, gradient_index, precision, "fill")?;
724            write_stroke_attributes(output, stroke, gradient_index, precision)?;
725            write_common_attributes(output, None, meta, id)?;
726            writeln!(output, "/>")?;
727        }
728        Node::Text {
729            id,
730            x,
731            y,
732            runs,
733            anchor,
734            transform,
735            opacity,
736            stroke,
737            clip_id: _,
738            meta,
739        } => {
740            write!(
741                output,
742                "{indent}<text id=\"{}\" x=\"{}\" y=\"{}\" text-anchor=\"{}\" transform=\"{}\" opacity=\"{}\"",
743                escape_attr(id),
744                number(*x, precision),
745                number(*y, precision),
746                match anchor {
747                    TextAnchor::Start => "start",
748                    TextAnchor::Middle => "middle",
749                    TextAnchor::End => "end",
750                },
751                matrix(*transform, precision),
752                number(opacity.clamp(0.0, 1.0), precision)
753            )?;
754            write_stroke_attributes(output, stroke, gradient_index, precision)?;
755            write_common_attributes(output, None, meta, id)?;
756            writeln!(output, ">")?;
757            for run in runs {
758                write!(
759                    output,
760                    "{indent}  <tspan xml:space=\"preserve\" font-family=\"{}\" font-size=\"{}\" font-weight=\"{}\" font-style=\"{}\" baseline-shift=\"{}\"",
761                    escape_attr(&run.font_family),
762                    number(run.font_size, precision),
763                    if run.bold { "700" } else { "400" },
764                    if run.italic { "italic" } else { "normal" },
765                    number(run.baseline_shift, precision)
766                )?;
767                if let Some(target_advance) = run
768                    .target_advance
769                    .filter(|value| value.is_finite() && *value > 0.0)
770                    .filter(|_| run.glyph_x_offsets.is_empty())
771                {
772                    write!(
773                        output,
774                        " textLength=\"{}\" lengthAdjust=\"spacingAndGlyphs\"",
775                        number(target_advance, precision)
776                    )?;
777                }
778                write_paint_attributes(output, &run.fill, gradient_index, precision, "fill")?;
779                let characters = run.text.chars().collect::<Vec<_>>();
780                if !characters.is_empty()
781                    && characters.len() == run.glyph_x_offsets.len()
782                    && run.glyph_x_offsets.iter().all(|value| value.is_finite())
783                {
784                    write!(output, ">")?;
785                    for (character, x) in characters.iter().zip(&run.glyph_x_offsets) {
786                        let mut buf = [0u8; 4];
787                        let char_str = character.encode_utf8(&mut buf);
788                        write!(
789                            output,
790                            "<tspan x=\"{}\" y=\"0\">{}</tspan>",
791                            number(*x, precision),
792                            escape_text(char_str)
793                        )?;
794                    }
795                    writeln!(output, "</tspan>")?;
796                } else {
797                    writeln!(output, ">{}</tspan>", escape_text(&run.text))?;
798                }
799            }
800            writeln!(output, "{indent}</text>")?;
801        }
802        Node::Image {
803            id,
804            href,
805            x,
806            y,
807            width,
808            height,
809            transform,
810            opacity,
811            clip_id: _,
812            meta,
813        } => {
814            // Only SVG 2's `href`. Repeating the data URI in `xlink:href` for
815            // pre-2019 renderers doubled the largest part of every page: on
816            // image-heavy decks the duplicate was 40-48% of the output.
817            write!(
818                output,
819                "{indent}<image id=\"{}\" href=\"{}\" x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" transform=\"{}\" opacity=\"{}\" preserveAspectRatio=\"none\"",
820                escape_attr(id),
821                escape_attr(href),
822                number(*x, precision),
823                number(*y, precision),
824                number(*width, precision),
825                number(*height, precision),
826                matrix(*transform, precision),
827                number(opacity.clamp(0.0, 1.0), precision)
828            )?;
829            write_common_attributes(output, None, meta, id)?;
830            writeln!(output, "/>")?;
831        }
832        Node::Group {
833            id,
834            nodes,
835            transform,
836            opacity,
837            clip_id: _,
838            meta,
839        } => {
840            write!(
841                output,
842                "{indent}<g id=\"{}\" transform=\"{}\" opacity=\"{}\"",
843                escape_attr(id),
844                matrix(*transform, precision),
845                number(opacity.clamp(0.0, 1.0), precision)
846            )?;
847            write_common_attributes(output, None, meta, id)?;
848            writeln!(output, ">")?;
849            for child in nodes {
850                write_node(
851                    output,
852                    child,
853                    content_depth + 1,
854                    gradient_index,
855                    precision,
856                    clip_parents,
857                )?;
858            }
859            writeln!(output, "{indent}</g>")?;
860        }
861    }
862    if has_effect_wrapper {
863        writeln!(output, "{wrapper_indent}</g>")?;
864    }
865    for index in (0..parent_clips.len()).rev() {
866        let indent = "  ".repeat(depth + index);
867        writeln!(output, "{indent}</g>")?;
868    }
869    Ok(())
870}
871
872fn parent_clip_chain(
873    clip_id: Option<&str>,
874    clip_parents: &HashMap<String, Option<String>>,
875) -> Vec<String> {
876    let mut result = Vec::new();
877    let mut current = clip_id.map(str::to_owned);
878    while let Some(parent) = current {
879        if result.contains(&parent) || result.len() >= 256 {
880            break;
881        }
882        current = clip_parents.get(&parent).and_then(Clone::clone);
883        result.push(parent);
884    }
885    result.reverse();
886    result
887}
888
889fn write_common_attributes<W: Write>(
890    output: &mut W,
891    clip_id: Option<&str>,
892    meta: &crate::ir::SourceMeta,
893    node_id: &str,
894) -> Result<()> {
895    if let Some(clip_id) = clip_id {
896        write!(output, " clip-path=\"url(#{})\"", escape_attr(clip_id))?;
897    }
898    if !meta.kind.is_empty() {
899        write!(output, " data-content-kind=\"{}\"", escape_attr(&meta.kind))?;
900    }
901    if !meta.source_id.is_empty() {
902        write!(
903            output,
904            " data-source-id=\"{}\"",
905            escape_attr(&meta.source_id)
906        )?;
907    }
908    if !meta.semantic_role.is_empty() {
909        write!(
910            output,
911            " data-semantic-role=\"{}\"",
912            escape_attr(&meta.semantic_role)
913        )?;
914    }
915    if !meta.alt_text.is_empty() {
916        write!(output, " aria-label=\"{}\"", escape_attr(&meta.alt_text))?;
917    }
918    if !meta.image_rendering.is_empty() {
919        write!(
920            output,
921            " image-rendering=\"{}\"",
922            escape_attr(&meta.image_rendering)
923        )?;
924    }
925    if !meta.shape_rendering.is_empty() {
926        write!(
927            output,
928            " shape-rendering=\"{}\"",
929            escape_attr(&meta.shape_rendering)
930        )?;
931    }
932    if let Some(filter_prefix) = drawingml_filter_prefix(meta) {
933        write!(
934            output,
935            " filter=\"url(#{}-{})\"",
936            filter_prefix,
937            escape_attr(node_id)
938        )?;
939    }
940    Ok(())
941}
942
943fn write_stroke_attributes<W: Write>(
944    output: &mut W,
945    stroke: &Stroke,
946    gradient_index: &mut usize,
947    precision: usize,
948) -> Result<()> {
949    write_paint_attributes(output, &stroke.paint, gradient_index, precision, "stroke")?;
950    if !matches!(stroke.paint, Paint::None) {
951        write!(
952            output,
953            " stroke-width=\"{}\" stroke-linecap=\"{}\" stroke-linejoin=\"{}\" stroke-miterlimit=\"{}\"",
954            number(stroke.width, precision),
955            match stroke.line_cap {
956                LineCap::Butt => "butt",
957                LineCap::Round => "round",
958                LineCap::Square => "square",
959            },
960            match stroke.line_join {
961                LineJoin::Miter => "miter",
962                LineJoin::Round => "round",
963                LineJoin::Bevel => "bevel",
964            },
965            number(stroke.miter_limit, precision)
966        )?;
967        if !stroke.dash_array.is_empty() {
968            let values = stroke
969                .dash_array
970                .iter()
971                .map(|value| number(*value, precision))
972                .collect::<Vec<_>>()
973                .join(" ");
974            write!(
975                output,
976                " stroke-dasharray=\"{values}\" stroke-dashoffset=\"{}\"",
977                number(stroke.dash_offset, precision)
978            )?;
979        }
980    }
981    Ok(())
982}
983
984fn write_paint_attributes<W: Write>(
985    output: &mut W,
986    paint: &Paint,
987    gradient_index: &mut usize,
988    precision: usize,
989    attribute: &str,
990) -> Result<()> {
991    match paint {
992        Paint::None => write!(output, " {attribute}=\"none\"")?,
993        Paint::Solid { color, opacity } => {
994            write!(
995                output,
996                " {attribute}=\"{}\" {attribute}-opacity=\"{}\"",
997                escape_attr(color),
998                number(opacity.clamp(0.0, 1.0), precision)
999            )?;
1000        }
1001        Paint::LinearGradient(_) | Paint::RadialGradient(_) => {
1002            *gradient_index += 1;
1003            write!(output, " {attribute}=\"url(#gradient-{gradient_index})\"")?;
1004        }
1005        Paint::PatternRef { id, opacity } => {
1006            write!(
1007                output,
1008                " {attribute}=\"url(#{})\" {attribute}-opacity=\"{}\"",
1009                escape_attr(id),
1010                number(opacity.clamp(0.0, 1.0), precision)
1011            )?;
1012        }
1013    }
1014    Ok(())
1015}
1016
1017fn has_gradients(nodes: &[Node]) -> bool {
1018    nodes.iter().any(|node| match node {
1019        Node::Path { fill, stroke, .. } => {
1020            matches!(fill, Paint::LinearGradient(_) | Paint::RadialGradient(_))
1021                || matches!(
1022                    stroke.paint,
1023                    Paint::LinearGradient(_) | Paint::RadialGradient(_)
1024                )
1025        }
1026        Node::Text { runs, .. } => runs.iter().any(|run| {
1027            matches!(
1028                run.fill,
1029                Paint::LinearGradient(_) | Paint::RadialGradient(_)
1030            )
1031        }),
1032        Node::Group { nodes, .. } => has_gradients(nodes),
1033        Node::Image { .. } => false,
1034    })
1035}
1036
1037fn has_drawingml_effects(nodes: &[Node]) -> bool {
1038    nodes.iter().any(|node| match node {
1039        Node::Path { meta, .. } | Node::Text { meta, .. } | Node::Image { meta, .. } => {
1040            drawingml_filter_prefix(meta).is_some()
1041        }
1042        Node::Group { nodes, meta, .. } => {
1043            drawingml_filter_prefix(meta).is_some() || has_drawingml_effects(nodes)
1044        }
1045    })
1046}
1047
1048fn matrix(matrix: Matrix, precision: usize) -> String {
1049    format!(
1050        "matrix({})",
1051        matrix
1052            .iter()
1053            .map(|value| number(*value, precision + 3))
1054            .collect::<Vec<_>>()
1055            .join(" ")
1056    )
1057}
1058
1059fn number(value: f64, precision: usize) -> String {
1060    // SvgOptions is public; bound direct callers as well as the CLI.
1061    let precision = precision.min(12);
1062    if !value.is_finite() || value.abs() < 0.5 * 10f64.powi(-(precision as i32)) {
1063        return "0".into();
1064    }
1065    // Use a stack buffer to avoid heap allocation for the format step.
1066    let mut buf = [0u8; 32];
1067    let len = {
1068        use std::io::Write as _;
1069        let mut cursor = std::io::Cursor::new(&mut buf[..]);
1070        write!(cursor, "{value:.precision$}").unwrap();
1071        cursor.position() as usize
1072    };
1073    let formatted = std::str::from_utf8(&buf[..len]).unwrap();
1074    // With integer precision, trailing zeroes are significant (100 != 1).
1075    if precision == 0 {
1076        return formatted.to_owned();
1077    }
1078    let trimmed = formatted.trim_end_matches('0').trim_end_matches('.');
1079    if trimmed.is_empty() || trimmed == "-0" {
1080        "0".into()
1081    } else {
1082        trimmed.to_owned()
1083    }
1084}
1085
1086fn escape_text(value: &str) -> Cow<'_, str> {
1087    escape_xml(value, false)
1088}
1089
1090fn escape_attr(value: &str) -> Cow<'_, str> {
1091    escape_xml(value, true)
1092}
1093
1094fn escape_xml(value: &str, attribute: bool) -> Cow<'_, str> {
1095    // In particular, embedded image data and path strings usually need no
1096    // escaping. Borrow them instead of copying multi-megabyte values once
1097    // for each href attribute.
1098    let needs_escaping = value.bytes().any(|byte| {
1099        matches!(byte, b'&' | b'<' | b'>' | 0..=8 | 11 | 12 | 14..=31)
1100            || (attribute && matches!(byte, b'"' | b'\'' | b'\t' | b'\n' | b'\r'))
1101    });
1102    if !needs_escaping {
1103        return Cow::Borrowed(value);
1104    }
1105    let mut escaped = String::with_capacity(value.len());
1106    for character in value.chars() {
1107        match character {
1108            '&' => escaped.push_str("&amp;"),
1109            '<' => escaped.push_str("&lt;"),
1110            '>' => escaped.push_str("&gt;"),
1111            '"' if attribute => escaped.push_str("&quot;"),
1112            '\'' if attribute => escaped.push_str("&apos;"),
1113            // A reader turns literal tabs and newlines inside an attribute
1114            // into spaces, so anything meant to survive the trip has to be
1115            // written as a character reference.
1116            '\t' if attribute => escaped.push_str("&#9;"),
1117            '\n' if attribute => escaped.push_str("&#10;"),
1118            '\r' if attribute => escaped.push_str("&#13;"),
1119            '\t' | '\n' | '\r' => escaped.push(character),
1120            value if value >= ' ' => escaped.push(value),
1121            _ => {}
1122        }
1123    }
1124    Cow::Owned(escaped)
1125}
1126
1127pub(crate) fn convert(
1128    path: &std::path::Path,
1129    options: &crate::convert::ConvertOptions,
1130    sink: &mut dyn crate::convert::PageConsumer,
1131) -> Result<Vec<String>> {
1132    let mut file = std::fs::File::open(path)?;
1133    let mut source_bytes = Vec::new();
1134    Read::take(&mut file, options.max_input_bytes.saturating_add(1))
1135        .read_to_end(&mut source_bytes)?;
1136    if source_bytes.len() as u64 > options.max_input_bytes {
1137        return Err(crate::error::Error::LimitExceeded(format!(
1138            "SVG input exceeds maximum bytes ({})",
1139            options.max_input_bytes
1140        )));
1141    }
1142    let bytes = if source_bytes.starts_with(&[0x1f, 0x8b]) {
1143        let mut decoder = flate2::read::MultiGzDecoder::new(source_bytes.as_slice());
1144        let mut decompressed = Vec::new();
1145        Read::take(&mut decoder, options.max_input_bytes.saturating_add(1))
1146            .read_to_end(&mut decompressed)?;
1147        if decompressed.len() as u64 > options.max_input_bytes {
1148            return Err(crate::error::Error::LimitExceeded(format!(
1149                "decompressed SVGZ input exceeds maximum bytes ({})",
1150                options.max_input_bytes
1151            )));
1152        }
1153        decompressed
1154    } else {
1155        source_bytes
1156    };
1157    crate::reverse::validate_svg_document(&bytes, 0)?;
1158    let svg_str = std::str::from_utf8(&bytes)
1159        .map_err(|e| crate::error::Error::InvalidInput(format!("SVG is not valid UTF-8: {e}")))?;
1160
1161    let doc = parse_svg_elements(svg_str)?;
1162    let mut page = Page::new(1, doc.width, doc.height, "SVG");
1163    page.embedded_source = Some(svg_str.to_string());
1164
1165    for (i, elem) in doc.elements.into_iter().enumerate() {
1166        match elem {
1167            SvgElement::Line {
1168                p1,
1169                p2,
1170                stroke_color,
1171                stroke_width,
1172                ..
1173            } => {
1174                page.nodes.push(Node::Path {
1175                    id: format!("svg-line-{}", i + 1),
1176                    d: format!("M {} {} L {} {}", p1.x, p1.y, p2.x, p2.y),
1177                    fill_rule: "nonzero".into(),
1178                    fill: Paint::None,
1179                    stroke: Stroke {
1180                        paint: stroke_color
1181                            .map(|c| Paint::solid(format!("#{c:06x}")))
1182                            .unwrap_or_else(|| Paint::solid("#000000")),
1183                        width: stroke_width.max(0.1),
1184                        ..Stroke::default()
1185                    },
1186                    transform: crate::ir::IDENTITY,
1187                    clip_id: None,
1188                    meta: crate::ir::SourceMeta::default(),
1189                });
1190            }
1191            SvgElement::Rect {
1192                x,
1193                y,
1194                width,
1195                height,
1196                stroke_color,
1197                fill_color,
1198                ..
1199            } => {
1200                let fill = fill_color
1201                    .map(|c| Paint::solid(format!("#{c:06x}")))
1202                    .unwrap_or(Paint::None);
1203                let stroke = stroke_color
1204                    .map(|c| Stroke {
1205                        paint: Paint::solid(format!("#{c:06x}")),
1206                        width: 1.0,
1207                        ..Stroke::default()
1208                    })
1209                    .unwrap_or_default();
1210                page.nodes.push(Node::Path {
1211                    id: format!("svg-rect-{}", i + 1),
1212                    d: format!("M {x} {y} h {width} v {height} h -{width} Z"),
1213                    fill_rule: "nonzero".into(),
1214                    fill,
1215                    stroke,
1216                    transform: crate::ir::IDENTITY,
1217                    clip_id: None,
1218                    meta: crate::ir::SourceMeta::default(),
1219                });
1220            }
1221            SvgElement::Circle {
1222                center,
1223                radius,
1224                stroke_color,
1225                fill_color,
1226                ..
1227            } => {
1228                let fill = fill_color
1229                    .map(|c| Paint::solid(format!("#{c:06x}")))
1230                    .unwrap_or(Paint::None);
1231                let stroke = stroke_color
1232                    .map(|c| Stroke {
1233                        paint: Paint::solid(format!("#{c:06x}")),
1234                        width: 1.0,
1235                        ..Stroke::default()
1236                    })
1237                    .unwrap_or_default();
1238                let r = radius;
1239                let cx = center.x;
1240                let cy = center.y;
1241                page.nodes.push(Node::Path {
1242                    id: format!("svg-circle-{}", i + 1),
1243                    d: format!(
1244                        "M {} {} m -{}, 0 a {},{} 0 1,0 {},0 a {},{} 0 1,0 -{},0",
1245                        cx,
1246                        cy,
1247                        r,
1248                        r,
1249                        r,
1250                        r * 2.0,
1251                        r,
1252                        r,
1253                        r * 2.0
1254                    ),
1255                    fill_rule: "nonzero".into(),
1256                    fill,
1257                    stroke,
1258                    transform: crate::ir::IDENTITY,
1259                    clip_id: None,
1260                    meta: crate::ir::SourceMeta::default(),
1261                });
1262            }
1263            SvgElement::Polyline {
1264                points,
1265                is_closed,
1266                stroke_color,
1267                fill_color,
1268                ..
1269            } => {
1270                if points.is_empty() {
1271                    continue;
1272                }
1273                let mut d = format!("M {} {}", points[0].x, points[0].y);
1274                for pt in &points[1..] {
1275                    d.push_str(&format!(" L {} {}", pt.x, pt.y));
1276                }
1277                if is_closed {
1278                    d.push_str(" Z");
1279                }
1280                let fill = fill_color
1281                    .map(|c| Paint::solid(format!("#{c:06x}")))
1282                    .unwrap_or(Paint::None);
1283                let stroke = stroke_color
1284                    .map(|c| Stroke {
1285                        paint: Paint::solid(format!("#{c:06x}")),
1286                        width: 1.0,
1287                        ..Stroke::default()
1288                    })
1289                    .unwrap_or_default();
1290                page.nodes.push(Node::Path {
1291                    id: format!("svg-poly-{}", i + 1),
1292                    d,
1293                    fill_rule: "nonzero".into(),
1294                    fill,
1295                    stroke,
1296                    transform: crate::ir::IDENTITY,
1297                    clip_id: None,
1298                    meta: crate::ir::SourceMeta::default(),
1299                });
1300            }
1301            SvgElement::Text {
1302                pos,
1303                font_size,
1304                color,
1305                content,
1306                ..
1307            } => {
1308                let fill = color
1309                    .map(|c| Paint::solid(format!("#{c:06x}")))
1310                    .unwrap_or_else(|| Paint::solid("#000000"));
1311                page.nodes.push(Node::Text {
1312                    id: format!("svg-text-{}", i + 1),
1313                    x: pos.x,
1314                    y: pos.y,
1315                    runs: vec![crate::ir::TextRun {
1316                        text: content,
1317                        font_size,
1318                        fill,
1319                        ..crate::ir::TextRun::default()
1320                    }],
1321                    anchor: TextAnchor::Start,
1322                    transform: crate::ir::IDENTITY,
1323                    opacity: 1.0,
1324                    stroke: Stroke::default(),
1325                    clip_id: None,
1326                    meta: crate::ir::SourceMeta::default(),
1327                });
1328            }
1329        }
1330    }
1331
1332    sink.consume(page)?;
1333    Ok(Vec::new())
1334}
1335
1336#[cfg(test)]
1337mod tests {
1338    use super::*;
1339    use crate::ir::{IDENTITY, Node, Page, SourceMeta, TextRun};
1340
1341    #[test]
1342    fn escaping_preserves_xml_rules_and_borrows_plain_values() {
1343        assert_eq!(escape_text("日本語<&>\"'\0\t"), "日本語&lt;&amp;&gt;\"'\t");
1344        assert_eq!(
1345            escape_attr("中文<&>\"'\u{b}\n"),
1346            "中文&lt;&amp;&gt;&quot;&apos;&#10;"
1347        );
1348        assert!(matches!(
1349            escape_attr("data:image/png;base64,AAAA"),
1350            Cow::Borrowed(_)
1351        ));
1352        assert!(matches!(escape_text("plain 日本語"), Cow::Borrowed(_)));
1353    }
1354
1355    #[test]
1356    fn integer_precision_preserves_page_dimensions_and_coordinates() {
1357        let page = Page::new(1, 100.0, 200.0, "test");
1358        let mut output = Vec::new();
1359        write_page(
1360            &page,
1361            &mut output,
1362            SvgOptions {
1363                precision: 0,
1364                ..SvgOptions::default()
1365            },
1366        )
1367        .unwrap();
1368        let svg = String::from_utf8(output).unwrap();
1369        assert!(svg.contains("width=\"100pt\" height=\"200pt\" viewBox=\"0 0 100 200\""));
1370        assert_eq!(number(-120.0, 0), "-120");
1371        assert_eq!(number(10.4, 0), "10");
1372        assert_eq!(number(0.0, 0), "0");
1373        assert_eq!(number(100.0, usize::MAX), "100");
1374    }
1375
1376    #[test]
1377    fn serializer_escapes_text_and_is_deterministic() {
1378        let mut page = Page::new(1, 100.0, 50.0, "test");
1379        page.nodes.push(Node::Text {
1380            id: "t1".into(),
1381            x: 4.0,
1382            y: 12.0,
1383            runs: vec![TextRun {
1384                text: "A < B & C".into(),
1385                ..TextRun::default()
1386            }],
1387            anchor: TextAnchor::Start,
1388            transform: IDENTITY,
1389            opacity: 1.0,
1390            stroke: Stroke::default(),
1391            clip_id: None,
1392            meta: SourceMeta::default(),
1393        });
1394        let mut first = Vec::new();
1395        let mut second = Vec::new();
1396        write_page(&page, &mut first, SvgOptions::default()).unwrap();
1397        write_page(&page, &mut second, SvgOptions::default()).unwrap();
1398        assert_eq!(first, second);
1399        let svg = String::from_utf8(first).unwrap();
1400        assert!(svg.contains("A &lt; B &amp; C"));
1401    }
1402}