1pub 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 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 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 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 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 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 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("&"),
1109 '<' => escaped.push_str("<"),
1110 '>' => escaped.push_str(">"),
1111 '"' if attribute => escaped.push_str("""),
1112 '\'' if attribute => escaped.push_str("'"),
1113 '\t' if attribute => escaped.push_str("	"),
1117 '\n' if attribute => escaped.push_str(" "),
1118 '\r' if attribute => escaped.push_str(" "),
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"), "日本語<&>\"'\t");
1344 assert_eq!(
1345 escape_attr("中文<&>\"'\u{b}\n"),
1346 "中文<&>"' "
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 < B & C"));
1401 }
1402}