Skip to main content

graphitepdf_kit/
svg_render.rs

1use std::collections::HashMap;
2use std::fmt::Write as _;
3
4use crate::error::{GraphitePdfKitError, Result};
5use graphitepdf_math::MathRender;
6use graphitepdf_svg::{SvgNode, SvgNodeKind};
7
8const DEFAULT_VIEWPORT_SIZE: f64 = 100.0;
9const DEFAULT_FONT_NAME: &str = "F1";
10const DEFAULT_FONT_SIZE: f64 = 12.0;
11const CIRCLE_BEZIER_KAPPA: f64 = 0.552_284_749_830_793_6;
12
13#[derive(Clone, Debug, PartialEq)]
14pub struct SvgRenderOptions {
15    pub x: f64,
16    pub y: f64,
17    pub width: Option<f64>,
18    pub height: Option<f64>,
19    pub font_name: String,
20    pub font_size: f64,
21}
22
23impl SvgRenderOptions {
24    pub fn new() -> Self {
25        Self::default()
26    }
27
28    pub fn position(mut self, x: f64, y: f64) -> Self {
29        self.x = x;
30        self.y = y;
31        self
32    }
33
34    pub fn width(mut self, width: f64) -> Self {
35        self.width = Some(width);
36        self
37    }
38
39    pub fn height(mut self, height: f64) -> Self {
40        self.height = Some(height);
41        self
42    }
43
44    pub fn size(mut self, width: f64, height: f64) -> Self {
45        self.width = Some(width);
46        self.height = Some(height);
47        self
48    }
49
50    pub fn font_name(mut self, font_name: impl Into<String>) -> Self {
51        self.font_name = font_name.into();
52        self
53    }
54
55    pub fn font_size(mut self, font_size: f64) -> Self {
56        self.font_size = font_size;
57        self
58    }
59}
60
61impl Default for SvgRenderOptions {
62    fn default() -> Self {
63        Self {
64            x: 0.0,
65            y: 0.0,
66            width: None,
67            height: None,
68            font_name: String::from(DEFAULT_FONT_NAME),
69            font_size: DEFAULT_FONT_SIZE,
70        }
71    }
72}
73
74pub trait ToPdfPageContent {
75    fn to_pdf_page_content(&self) -> Result<Vec<u8>> {
76        self.to_pdf_page_content_with_options(&SvgRenderOptions::default())
77    }
78
79    fn to_pdf_page_content_with_options(&self, options: &SvgRenderOptions) -> Result<Vec<u8>>;
80}
81
82impl ToPdfPageContent for SvgNode {
83    fn to_pdf_page_content_with_options(&self, options: &SvgRenderOptions) -> Result<Vec<u8>> {
84        render_svg_node_to_page_content_with_options(self, options)
85    }
86}
87
88impl ToPdfPageContent for MathRender {
89    fn to_pdf_page_content_with_options(&self, options: &SvgRenderOptions) -> Result<Vec<u8>> {
90        render_math_to_page_content_with_options(self, options)
91    }
92}
93
94pub fn render_svg_node_to_page_content(svg: &SvgNode) -> Result<Vec<u8>> {
95    render_svg_node_to_page_content_with_options(svg, &SvgRenderOptions::default())
96}
97
98pub fn render_svg_node_to_page_content_with_options(
99    svg: &SvgNode,
100    options: &SvgRenderOptions,
101) -> Result<Vec<u8>> {
102    if svg.kind != SvgNodeKind::Svg {
103        return Err(GraphitePdfKitError::Render(
104            "SVG page rendering requires an <svg> root node".to_string(),
105        ));
106    }
107
108    let viewport = SvgViewport::from_node(svg, options)?;
109    let mut content = String::new();
110
111    content.push_str("q\n");
112    push_matrix(&mut content, Transform::translate(options.x, options.y));
113    push_matrix(
114        &mut content,
115        Transform::new(1.0, 0.0, 0.0, -1.0, 0.0, viewport.height),
116    );
117    push_matrix(
118        &mut content,
119        Transform::scale(
120            viewport.width / viewport.view_box.width,
121            viewport.height / viewport.view_box.height,
122        ),
123    );
124    push_matrix(
125        &mut content,
126        Transform::translate(-viewport.view_box.min_x, -viewport.view_box.min_y),
127    );
128
129    let state = RenderState::from_root(svg, options);
130    let definitions = collect_definitions(svg);
131    render_node(svg, &state, &mut content, options, &definitions)?;
132
133    content.push_str("Q\n");
134    Ok(content.into_bytes())
135}
136
137pub fn render_math_to_page_content(math: &MathRender) -> Result<Vec<u8>> {
138    render_math_to_page_content_with_options(math, &SvgRenderOptions::default())
139}
140
141pub fn render_math_to_page_content_with_options(
142    math: &MathRender,
143    options: &SvgRenderOptions,
144) -> Result<Vec<u8>> {
145    render_svg_node_to_page_content_with_options(&math.svg, options)
146}
147
148#[derive(Clone, Copy, Debug, PartialEq)]
149struct SvgViewBox {
150    min_x: f64,
151    min_y: f64,
152    width: f64,
153    height: f64,
154}
155
156impl SvgViewBox {
157    fn aspect_ratio(self) -> f64 {
158        self.width / self.height
159    }
160}
161
162#[derive(Clone, Copy, Debug, PartialEq)]
163struct SvgViewport {
164    view_box: SvgViewBox,
165    width: f64,
166    height: f64,
167}
168
169type DefinitionMap<'a> = HashMap<&'a str, &'a SvgNode>;
170
171impl SvgViewport {
172    fn from_node(svg: &SvgNode, options: &SvgRenderOptions) -> Result<Self> {
173        let mut width_hint = svg
174            .props
175            .get("width")
176            .and_then(|value| parse_length(value).ok());
177        let mut height_hint = svg
178            .props
179            .get("height")
180            .and_then(|value| parse_length(value).ok());
181        let view_box = if let Some(raw_view_box) = svg.props.get("viewBox") {
182            parse_view_box(raw_view_box)?
183        } else {
184            let width = width_hint.unwrap_or(DEFAULT_VIEWPORT_SIZE);
185            let height = height_hint.unwrap_or(DEFAULT_VIEWPORT_SIZE);
186            SvgViewBox {
187                min_x: 0.0,
188                min_y: 0.0,
189                width,
190                height,
191            }
192        };
193
194        if width_hint.is_none() {
195            width_hint = Some(view_box.width);
196        }
197        if height_hint.is_none() {
198            height_hint = Some(view_box.height);
199        }
200
201        let (width, height) = match (options.width, options.height) {
202            (Some(width), Some(height)) => (width, height),
203            (Some(width), None) => (width, width / view_box.aspect_ratio()),
204            (None, Some(height)) => (height * view_box.aspect_ratio(), height),
205            (None, None) => (
206                width_hint.unwrap_or(DEFAULT_VIEWPORT_SIZE),
207                height_hint.unwrap_or(DEFAULT_VIEWPORT_SIZE),
208            ),
209        };
210
211        if width <= 0.0 || height <= 0.0 || view_box.width <= 0.0 || view_box.height <= 0.0 {
212            return Err(GraphitePdfKitError::Render(
213                "SVG dimensions must be positive".to_string(),
214            ));
215        }
216
217        Ok(Self {
218            view_box,
219            width,
220            height,
221        })
222    }
223}
224
225#[derive(Clone, Copy, Debug, PartialEq)]
226struct PdfColor {
227    r: f64,
228    g: f64,
229    b: f64,
230}
231
232impl PdfColor {
233    const BLACK: Self = Self::new(0.0, 0.0, 0.0);
234
235    const fn new(r: f64, g: f64, b: f64) -> Self {
236        Self { r, g, b }
237    }
238}
239
240#[derive(Clone, Debug)]
241struct RenderState {
242    fill: Option<PdfColor>,
243    stroke: Option<PdfColor>,
244    line_width: f64,
245    line_cap: Option<u8>,
246    line_join: Option<u8>,
247    font_size: f64,
248}
249
250impl RenderState {
251    fn from_root(svg: &SvgNode, options: &SvgRenderOptions) -> Self {
252        let mut state = Self {
253            fill: Some(PdfColor::BLACK),
254            stroke: None,
255            line_width: 1.0,
256            line_cap: None,
257            line_join: None,
258            font_size: options.font_size,
259        };
260
261        state = state.inherit(svg);
262        state
263    }
264
265    fn inherit(&self, node: &SvgNode) -> Self {
266        let mut state = self.clone();
267
268        if let Some(fill) = parse_paint_prop(node, "fill") {
269            state.fill = fill;
270        }
271        if let Some(stroke) = parse_paint_prop(node, "stroke") {
272            state.stroke = stroke;
273        }
274        if let Some(width) = parse_number_prop(node, "strokeWidth") {
275            state.line_width = width.max(0.0);
276        }
277        if let Some(line_cap) = parse_line_cap_prop(node) {
278            state.line_cap = Some(line_cap);
279        }
280        if let Some(line_join) = parse_line_join_prop(node) {
281            state.line_join = Some(line_join);
282        }
283        if let Some(font_size) = parse_number_prop(node, "fontSize") {
284            state.font_size = font_size.max(0.0);
285        }
286
287        state
288    }
289}
290
291#[derive(Clone, Copy, Debug, Default, PartialEq)]
292struct TextCursor {
293    x: f64,
294    y: f64,
295}
296
297#[derive(Clone, Copy, Debug, PartialEq)]
298struct Transform {
299    a: f64,
300    b: f64,
301    c: f64,
302    d: f64,
303    e: f64,
304    f: f64,
305}
306
307impl Transform {
308    const fn identity() -> Self {
309        Self::new(1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
310    }
311
312    const fn new(a: f64, b: f64, c: f64, d: f64, e: f64, f: f64) -> Self {
313        Self { a, b, c, d, e, f }
314    }
315
316    const fn translate(tx: f64, ty: f64) -> Self {
317        Self::new(1.0, 0.0, 0.0, 1.0, tx, ty)
318    }
319
320    const fn scale(sx: f64, sy: f64) -> Self {
321        Self::new(sx, 0.0, 0.0, sy, 0.0, 0.0)
322    }
323
324    fn rotate_degrees(angle_degrees: f64) -> Self {
325        let radians = angle_degrees.to_radians();
326        let cos = radians.cos();
327        let sin = radians.sin();
328        Self::new(cos, sin, -sin, cos, 0.0, 0.0)
329    }
330
331    fn skew_x_degrees(angle_degrees: f64) -> Self {
332        Self::new(1.0, 0.0, angle_degrees.to_radians().tan(), 1.0, 0.0, 0.0)
333    }
334
335    fn skew_y_degrees(angle_degrees: f64) -> Self {
336        Self::new(1.0, angle_degrees.to_radians().tan(), 0.0, 1.0, 0.0, 0.0)
337    }
338
339    fn multiply(self, other: Self) -> Self {
340        Self {
341            a: self.a * other.a + self.c * other.b,
342            b: self.b * other.a + self.d * other.b,
343            c: self.a * other.c + self.c * other.d,
344            d: self.b * other.c + self.d * other.d,
345            e: self.a * other.e + self.c * other.f + self.e,
346            f: self.b * other.e + self.d * other.f + self.f,
347        }
348    }
349}
350
351fn render_node(
352    node: &SvgNode,
353    state: &RenderState,
354    content: &mut String,
355    options: &SvgRenderOptions,
356    definitions: &DefinitionMap<'_>,
357) -> Result<()> {
358    let state = state.inherit(node);
359    let transform = node
360        .props
361        .get("transform")
362        .map(|value| parse_transform(value))
363        .transpose()?;
364
365    if let Some(transform) = transform {
366        content.push_str("q\n");
367        push_matrix(content, transform);
368    }
369
370    match node.kind {
371        SvgNodeKind::Svg | SvgNodeKind::G => {
372            for child in &node.children {
373                render_node(child, &state, content, options, definitions)?;
374            }
375        }
376        SvgNodeKind::Rect => render_rect(node, &state, content)?,
377        SvgNodeKind::Circle => render_circle(node, &state, content)?,
378        SvgNodeKind::Ellipse => render_ellipse(node, &state, content)?,
379        SvgNodeKind::Line => render_line(node, &state, content)?,
380        SvgNodeKind::Polyline => render_polyline(node, &state, content, false)?,
381        SvgNodeKind::Polygon => render_polyline(node, &state, content, true)?,
382        SvgNodeKind::Path => render_path(node, &state, content)?,
383        SvgNodeKind::Text | SvgNodeKind::Tspan => {
384            let _ = render_text_container(
385                node,
386                &state,
387                content,
388                options,
389                definitions,
390                TextCursor::default(),
391            )?;
392        }
393        SvgNodeKind::Use => render_use(node, &state, content, options, definitions)?,
394        SvgNodeKind::Defs
395        | SvgNodeKind::ClipPath
396        | SvgNodeKind::LinearGradient
397        | SvgNodeKind::RadialGradient
398        | SvgNodeKind::Marker
399        | SvgNodeKind::Stop
400        | SvgNodeKind::Image
401        | SvgNodeKind::TextInstance => {}
402    }
403
404    if transform.is_some() {
405        content.push_str("Q\n");
406    }
407
408    Ok(())
409}
410
411fn collect_definitions<'a>(node: &'a SvgNode) -> DefinitionMap<'a> {
412    let mut definitions = HashMap::new();
413    collect_definitions_into(node, &mut definitions);
414    definitions
415}
416
417fn collect_definitions_into<'a>(node: &'a SvgNode, definitions: &mut DefinitionMap<'a>) {
418    if let Some(id) = node.props.get("id") {
419        definitions.insert(id.as_str(), node);
420    }
421
422    for child in &node.children {
423        collect_definitions_into(child, definitions);
424    }
425}
426
427fn render_use(
428    node: &SvgNode,
429    state: &RenderState,
430    content: &mut String,
431    options: &SvgRenderOptions,
432    definitions: &DefinitionMap<'_>,
433) -> Result<()> {
434    let Some(reference) = extract_use_href(node) else {
435        return Ok(());
436    };
437    let Some(target) = definitions.get(reference) else {
438        return Ok(());
439    };
440
441    let translate_x = parse_number_prop(node, "x").unwrap_or(0.0);
442    let translate_y = parse_number_prop(node, "y").unwrap_or(0.0);
443    let needs_translation = translate_x != 0.0 || translate_y != 0.0;
444
445    if needs_translation {
446        content.push_str("q\n");
447        push_matrix(content, Transform::translate(translate_x, translate_y));
448    }
449
450    render_node(target, state, content, options, definitions)?;
451
452    if needs_translation {
453        content.push_str("Q\n");
454    }
455
456    Ok(())
457}
458
459fn render_rect(node: &SvgNode, state: &RenderState, content: &mut String) -> Result<()> {
460    let x = parse_number_prop(node, "x").unwrap_or(0.0);
461    let y = parse_number_prop(node, "y").unwrap_or(0.0);
462    let width = parse_number_prop(node, "width").unwrap_or(0.0);
463    let height = parse_number_prop(node, "height").unwrap_or(0.0);
464
465    if width <= 0.0 || height <= 0.0 {
466        return Ok(());
467    }
468
469    content.push_str("q\n");
470    apply_paint_state(content, state);
471    let _ = writeln!(
472        content,
473        "{} {} {} {} re",
474        format_number(x),
475        format_number(y),
476        format_number(width),
477        format_number(height)
478    );
479    apply_paint_operator(content, state, true);
480    content.push_str("Q\n");
481    Ok(())
482}
483
484fn render_circle(node: &SvgNode, state: &RenderState, content: &mut String) -> Result<()> {
485    let cx = parse_number_prop(node, "cx").unwrap_or(0.0);
486    let cy = parse_number_prop(node, "cy").unwrap_or(0.0);
487    let r = parse_number_prop(node, "r").unwrap_or(0.0);
488
489    if r <= 0.0 {
490        return Ok(());
491    }
492
493    render_ellipse_segments(cx, cy, r, r, state, content);
494    Ok(())
495}
496
497fn render_ellipse(node: &SvgNode, state: &RenderState, content: &mut String) -> Result<()> {
498    let cx = parse_number_prop(node, "cx").unwrap_or(0.0);
499    let cy = parse_number_prop(node, "cy").unwrap_or(0.0);
500    let rx = parse_number_prop(node, "rx").unwrap_or(0.0);
501    let ry = parse_number_prop(node, "ry").unwrap_or(0.0);
502
503    if rx <= 0.0 || ry <= 0.0 {
504        return Ok(());
505    }
506
507    render_ellipse_segments(cx, cy, rx, ry, state, content);
508    Ok(())
509}
510
511fn render_ellipse_segments(
512    cx: f64,
513    cy: f64,
514    rx: f64,
515    ry: f64,
516    state: &RenderState,
517    content: &mut String,
518) {
519    let ox = rx * CIRCLE_BEZIER_KAPPA;
520    let oy = ry * CIRCLE_BEZIER_KAPPA;
521
522    content.push_str("q\n");
523    apply_paint_state(content, state);
524    let _ = writeln!(
525        content,
526        "{} {} m",
527        format_number(cx + rx),
528        format_number(cy)
529    );
530    let _ = writeln!(
531        content,
532        "{} {} {} {} {} {} c",
533        format_number(cx + rx),
534        format_number(cy + oy),
535        format_number(cx + ox),
536        format_number(cy + ry),
537        format_number(cx),
538        format_number(cy + ry)
539    );
540    let _ = writeln!(
541        content,
542        "{} {} {} {} {} {} c",
543        format_number(cx - ox),
544        format_number(cy + ry),
545        format_number(cx - rx),
546        format_number(cy + oy),
547        format_number(cx - rx),
548        format_number(cy)
549    );
550    let _ = writeln!(
551        content,
552        "{} {} {} {} {} {} c",
553        format_number(cx - rx),
554        format_number(cy - oy),
555        format_number(cx - ox),
556        format_number(cy - ry),
557        format_number(cx),
558        format_number(cy - ry)
559    );
560    let _ = writeln!(
561        content,
562        "{} {} {} {} {} {} c",
563        format_number(cx + ox),
564        format_number(cy - ry),
565        format_number(cx + rx),
566        format_number(cy - oy),
567        format_number(cx + rx),
568        format_number(cy)
569    );
570    content.push_str("h\n");
571    apply_paint_operator(content, state, true);
572    content.push_str("Q\n");
573}
574
575fn render_line(node: &SvgNode, state: &RenderState, content: &mut String) -> Result<()> {
576    let x1 = parse_number_prop(node, "x1").unwrap_or(0.0);
577    let y1 = parse_number_prop(node, "y1").unwrap_or(0.0);
578    let x2 = parse_number_prop(node, "x2").unwrap_or(0.0);
579    let y2 = parse_number_prop(node, "y2").unwrap_or(0.0);
580
581    content.push_str("q\n");
582    apply_paint_state(content, state);
583    let _ = writeln!(content, "{} {} m", format_number(x1), format_number(y1));
584    let _ = writeln!(content, "{} {} l", format_number(x2), format_number(y2));
585    apply_line_operator(content, state);
586    content.push_str("Q\n");
587    Ok(())
588}
589
590fn render_polyline(
591    node: &SvgNode,
592    state: &RenderState,
593    content: &mut String,
594    closed: bool,
595) -> Result<()> {
596    let Some(raw_points) = node.props.get("points") else {
597        return Ok(());
598    };
599    let points = parse_points(raw_points)?;
600    if points.len() < 2 {
601        return Ok(());
602    }
603
604    content.push_str("q\n");
605    apply_paint_state(content, state);
606    let _ = writeln!(
607        content,
608        "{} {} m",
609        format_number(points[0].0),
610        format_number(points[0].1)
611    );
612    for (x, y) in points.iter().copied().skip(1) {
613        let _ = writeln!(content, "{} {} l", format_number(x), format_number(y));
614    }
615    if closed {
616        content.push_str("h\n");
617        apply_paint_operator(content, state, true);
618    } else {
619        apply_line_operator(content, state);
620    }
621    content.push_str("Q\n");
622    Ok(())
623}
624
625fn render_path(node: &SvgNode, state: &RenderState, content: &mut String) -> Result<()> {
626    let Some(data) = node.props.get("d") else {
627        return Ok(());
628    };
629
630    content.push_str("q\n");
631    apply_paint_state(content, state);
632    render_path_data(data, content)?;
633    apply_paint_operator(content, state, false);
634    content.push_str("Q\n");
635    Ok(())
636}
637
638fn render_text_container(
639    node: &SvgNode,
640    state: &RenderState,
641    content: &mut String,
642    options: &SvgRenderOptions,
643    definitions: &DefinitionMap<'_>,
644    inherited_cursor: TextCursor,
645) -> Result<TextCursor> {
646    let mut cursor = inherited_cursor;
647
648    if let Some(x) = parse_number_prop(node, "x") {
649        cursor.x = x;
650    }
651    if let Some(y) = parse_number_prop(node, "y") {
652        cursor.y = y;
653    }
654    if let Some(dx) = parse_number_prop(node, "dx") {
655        cursor.x += dx;
656    }
657    if let Some(dy) = parse_number_prop(node, "dy") {
658        cursor.y += dy;
659    }
660
661    for child in &node.children {
662        match child.kind {
663            SvgNodeKind::TextInstance => {
664                if let Some(value) = child.value.as_deref() {
665                    emit_text_run(content, value, cursor, state, options);
666                    cursor.x += estimate_text_advance(value, state.font_size);
667                }
668            }
669            SvgNodeKind::Tspan | SvgNodeKind::Text => {
670                cursor =
671                    render_node_text_fragment(child, state, content, options, definitions, cursor)?;
672            }
673            _ => render_node(child, state, content, options, definitions)?,
674        }
675    }
676
677    Ok(cursor)
678}
679
680fn render_node_text_fragment(
681    node: &SvgNode,
682    state: &RenderState,
683    content: &mut String,
684    options: &SvgRenderOptions,
685    definitions: &DefinitionMap<'_>,
686    cursor: TextCursor,
687) -> Result<TextCursor> {
688    let state = state.inherit(node);
689    let transform = node
690        .props
691        .get("transform")
692        .map(|value| parse_transform(value))
693        .transpose()?;
694
695    if let Some(transform) = transform {
696        content.push_str("q\n");
697        push_matrix(content, transform);
698    }
699
700    let cursor = render_text_container(node, &state, content, options, definitions, cursor)?;
701
702    if transform.is_some() {
703        content.push_str("Q\n");
704    }
705
706    Ok(cursor)
707}
708
709fn emit_text_run(
710    content: &mut String,
711    text: &str,
712    cursor: TextCursor,
713    state: &RenderState,
714    options: &SvgRenderOptions,
715) {
716    if text.is_empty() || (state.fill.is_none() && state.stroke.is_none()) {
717        return;
718    }
719
720    content.push_str("BT\n");
721    let _ = writeln!(
722        content,
723        "/{} {} Tf",
724        options.font_name,
725        format_number(state.font_size.max(0.001))
726    );
727    match (state.fill, state.stroke) {
728        (Some(fill), Some(stroke)) => {
729            let _ = writeln!(
730                content,
731                "{} {} {} rg",
732                format_number(fill.r),
733                format_number(fill.g),
734                format_number(fill.b)
735            );
736            let _ = writeln!(
737                content,
738                "{} {} {} RG",
739                format_number(stroke.r),
740                format_number(stroke.g),
741                format_number(stroke.b)
742            );
743            content.push_str("2 Tr\n");
744        }
745        (Some(fill), None) => {
746            let _ = writeln!(
747                content,
748                "{} {} {} rg",
749                format_number(fill.r),
750                format_number(fill.g),
751                format_number(fill.b)
752            );
753            content.push_str("0 Tr\n");
754        }
755        (None, Some(stroke)) => {
756            let _ = writeln!(
757                content,
758                "{} {} {} RG",
759                format_number(stroke.r),
760                format_number(stroke.g),
761                format_number(stroke.b)
762            );
763            content.push_str("1 Tr\n");
764        }
765        (None, None) => {}
766    }
767    let _ = writeln!(
768        content,
769        "1 0 0 -1 {} {} Tm",
770        format_number(cursor.x),
771        format_number(cursor.y)
772    );
773    let _ = writeln!(content, "({}) Tj", escape_pdf_text(text));
774    content.push_str("ET\n");
775}
776
777fn apply_paint_state(content: &mut String, state: &RenderState) {
778    if let Some(fill) = state.fill {
779        let _ = writeln!(
780            content,
781            "{} {} {} rg",
782            format_number(fill.r),
783            format_number(fill.g),
784            format_number(fill.b)
785        );
786    }
787    if let Some(stroke) = state.stroke {
788        let _ = writeln!(
789            content,
790            "{} {} {} RG",
791            format_number(stroke.r),
792            format_number(stroke.g),
793            format_number(stroke.b)
794        );
795        let _ = writeln!(content, "{} w", format_number(state.line_width));
796    }
797    if let Some(line_cap) = state.line_cap {
798        let _ = writeln!(content, "{} J", line_cap);
799    }
800    if let Some(line_join) = state.line_join {
801        let _ = writeln!(content, "{} j", line_join);
802    }
803}
804
805fn apply_paint_operator(content: &mut String, state: &RenderState, _closed: bool) {
806    match (state.fill.is_some(), state.stroke.is_some()) {
807        (true, true) => content.push_str("B\n"),
808        (true, false) => content.push_str("f\n"),
809        (false, true) => content.push_str("S\n"),
810        (false, false) => content.push_str("n\n"),
811    }
812}
813
814fn apply_line_operator(content: &mut String, state: &RenderState) {
815    if state.stroke.is_some() {
816        content.push_str("S\n");
817    } else {
818        content.push_str("n\n");
819    }
820}
821
822fn parse_paint_prop(node: &SvgNode, key: &str) -> Option<Option<PdfColor>> {
823    let value = node.props.get(key)?;
824    if value.eq_ignore_ascii_case("none") {
825        return Some(None);
826    }
827    parse_color(value).map(Some)
828}
829
830fn parse_line_cap_prop(node: &SvgNode) -> Option<u8> {
831    match node.props.get("strokeLinecap")?.as_str() {
832        "butt" => Some(0),
833        "round" => Some(1),
834        "square" => Some(2),
835        _ => None,
836    }
837}
838
839fn parse_line_join_prop(node: &SvgNode) -> Option<u8> {
840    match node.props.get("strokeLinejoin")?.as_str() {
841        "miter" => Some(0),
842        "round" => Some(1),
843        "bevel" => Some(2),
844        _ => None,
845    }
846}
847
848fn parse_number_prop(node: &SvgNode, key: &str) -> Option<f64> {
849    node.props
850        .get(key)
851        .and_then(|value| parse_length(value).ok())
852}
853
854fn parse_length(value: &str) -> Result<f64> {
855    parse_number(value)
856}
857
858fn parse_number(value: &str) -> Result<f64> {
859    let trimmed = value.trim();
860    let mut end = 0usize;
861    let mut seen_digit = false;
862    let mut seen_decimal = false;
863    let mut seen_exponent = false;
864
865    for (index, character) in trimmed.char_indices() {
866        let is_first = index == 0;
867        if character.is_ascii_digit() {
868            seen_digit = true;
869            end = index + character.len_utf8();
870            continue;
871        }
872        if (character == '+' || character == '-')
873            && (is_first || matches!(trimmed[..index].chars().last(), Some('e' | 'E')))
874        {
875            end = index + character.len_utf8();
876            continue;
877        }
878        if character == '.' && !seen_decimal && !seen_exponent {
879            seen_decimal = true;
880            end = index + character.len_utf8();
881            continue;
882        }
883        if (character == 'e' || character == 'E') && seen_digit && !seen_exponent {
884            seen_exponent = true;
885            seen_decimal = false;
886            end = index + character.len_utf8();
887            continue;
888        }
889        break;
890    }
891
892    if !seen_digit || end == 0 {
893        return Err(GraphitePdfKitError::Render(format!(
894            "invalid SVG numeric value `{trimmed}`"
895        )));
896    }
897
898    trimmed[..end]
899        .parse::<f64>()
900        .map_err(|_| GraphitePdfKitError::Render(format!("invalid SVG numeric value `{trimmed}`")))
901}
902
903fn parse_view_box(value: &str) -> Result<SvgViewBox> {
904    let values: Vec<f64> = value
905        .split(|character: char| character.is_ascii_whitespace() || character == ',')
906        .filter(|part| !part.is_empty())
907        .map(parse_number)
908        .collect::<Result<Vec<_>>>()?;
909
910    if values.len() != 4 || values[2] <= 0.0 || values[3] <= 0.0 {
911        return Err(GraphitePdfKitError::Render(format!(
912            "invalid SVG viewBox `{value}`"
913        )));
914    }
915
916    Ok(SvgViewBox {
917        min_x: values[0],
918        min_y: values[1],
919        width: values[2],
920        height: values[3],
921    })
922}
923
924fn parse_points(value: &str) -> Result<Vec<(f64, f64)>> {
925    let numbers = tokenize_numbers(value)?;
926    if numbers.len() < 2 {
927        return Ok(Vec::new());
928    }
929    if numbers.len() % 2 != 0 {
930        return Err(GraphitePdfKitError::Render(format!(
931            "invalid SVG points list `{value}`"
932        )));
933    }
934
935    Ok(numbers
936        .chunks_exact(2)
937        .map(|chunk| (chunk[0], chunk[1]))
938        .collect())
939}
940
941fn tokenize_numbers(value: &str) -> Result<Vec<f64>> {
942    let mut numbers = Vec::new();
943    let mut index = 0usize;
944    let bytes = value.as_bytes();
945
946    while index < value.len() {
947        let character = bytes[index] as char;
948        if character.is_ascii_whitespace() || character == ',' {
949            index += 1;
950            continue;
951        }
952
953        let start = index;
954        let mut end = index;
955        let mut seen_decimal = false;
956        let mut seen_exponent = false;
957
958        while end < value.len() {
959            let current = bytes[end] as char;
960            let previous = if end > start {
961                Some(bytes[end - 1] as char)
962            } else {
963                None
964            };
965
966            let is_sign = current == '+' || current == '-';
967            if current.is_ascii_digit()
968                || (current == '.' && !seen_decimal && !seen_exponent)
969                || (current == 'e' || current == 'E') && !seen_exponent
970                || (is_sign && end == start)
971                || (is_sign && matches!(previous, Some('e' | 'E')))
972            {
973                if current == '.' {
974                    seen_decimal = true;
975                } else if current == 'e' || current == 'E' {
976                    seen_exponent = true;
977                    seen_decimal = false;
978                }
979                end += 1;
980                continue;
981            }
982
983            break;
984        }
985
986        if start == end {
987            return Err(GraphitePdfKitError::Render(format!(
988                "invalid SVG numeric token near `{}`",
989                &value[index..]
990            )));
991        }
992
993        numbers.push(value[start..end].parse::<f64>().map_err(|_| {
994            GraphitePdfKitError::Render(format!(
995                "invalid SVG numeric token `{}`",
996                &value[start..end]
997            ))
998        })?);
999        index = end;
1000    }
1001
1002    Ok(numbers)
1003}
1004
1005fn parse_transform(value: &str) -> Result<Transform> {
1006    let mut remainder = value.trim();
1007    let mut transform = Transform::identity();
1008
1009    while !remainder.is_empty() {
1010        let Some(open) = remainder.find('(') else {
1011            break;
1012        };
1013        let name = remainder[..open].trim();
1014        let after_open = &remainder[open + 1..];
1015        let Some(close) = after_open.find(')') else {
1016            return Err(GraphitePdfKitError::Render(format!(
1017                "invalid SVG transform `{value}`"
1018            )));
1019        };
1020        let args = tokenize_numbers(&after_open[..close])?;
1021        let next = match name {
1022            "translate" => {
1023                let tx = args.first().copied().unwrap_or(0.0);
1024                let ty = args.get(1).copied().unwrap_or(0.0);
1025                Transform::translate(tx, ty)
1026            }
1027            "scale" => {
1028                let sx = args.first().copied().unwrap_or(1.0);
1029                let sy = args.get(1).copied().unwrap_or(sx);
1030                Transform::scale(sx, sy)
1031            }
1032            "matrix" if args.len() == 6 => {
1033                Transform::new(args[0], args[1], args[2], args[3], args[4], args[5])
1034            }
1035            "rotate" => match args.as_slice() {
1036                [angle] => Transform::rotate_degrees(*angle),
1037                [angle, cx, cy] => Transform::translate(*cx, *cy)
1038                    .multiply(Transform::rotate_degrees(*angle))
1039                    .multiply(Transform::translate(-*cx, -*cy)),
1040                _ => {
1041                    return Err(GraphitePdfKitError::Render(format!(
1042                        "invalid rotate transform `{value}`"
1043                    )));
1044                }
1045            },
1046            "skewX" if args.len() == 1 => Transform::skew_x_degrees(args[0]),
1047            "skewY" if args.len() == 1 => Transform::skew_y_degrees(args[0]),
1048            _ => {
1049                return Err(GraphitePdfKitError::Render(format!(
1050                    "unsupported SVG transform `{name}`"
1051                )));
1052            }
1053        };
1054
1055        transform = transform.multiply(next);
1056        remainder = after_open[close + 1..].trim_start();
1057    }
1058
1059    Ok(transform)
1060}
1061
1062fn push_matrix(content: &mut String, matrix: Transform) {
1063    let _ = writeln!(
1064        content,
1065        "{} {} {} {} {} {} cm",
1066        format_number(matrix.a),
1067        format_number(matrix.b),
1068        format_number(matrix.c),
1069        format_number(matrix.d),
1070        format_number(matrix.e),
1071        format_number(matrix.f)
1072    );
1073}
1074
1075#[derive(Clone, Copy, Debug, PartialEq)]
1076enum PathToken {
1077    Command(char),
1078    Number(f64),
1079}
1080
1081fn tokenize_path_data(data: &str) -> Result<Vec<PathToken>> {
1082    let mut tokens = Vec::new();
1083    let bytes = data.as_bytes();
1084    let mut index = 0usize;
1085
1086    while index < data.len() {
1087        let character = bytes[index] as char;
1088        if character.is_ascii_whitespace() || character == ',' {
1089            index += 1;
1090            continue;
1091        }
1092        if character.is_ascii_alphabetic() {
1093            tokens.push(PathToken::Command(character));
1094            index += 1;
1095            continue;
1096        }
1097
1098        let start = index;
1099        let mut end = index;
1100        let mut seen_decimal = false;
1101        let mut seen_exponent = false;
1102
1103        while end < data.len() {
1104            let current = bytes[end] as char;
1105            let previous = if end > start {
1106                Some(bytes[end - 1] as char)
1107            } else {
1108                None
1109            };
1110            let is_sign = current == '+' || current == '-';
1111            let can_continue = current.is_ascii_digit()
1112                || (current == '.' && !seen_decimal && !seen_exponent)
1113                || ((current == 'e' || current == 'E') && !seen_exponent)
1114                || (is_sign && end == start)
1115                || (is_sign && matches!(previous, Some('e' | 'E')));
1116
1117            if !can_continue {
1118                break;
1119            }
1120
1121            if current == '.' {
1122                seen_decimal = true;
1123            } else if current == 'e' || current == 'E' {
1124                seen_exponent = true;
1125                seen_decimal = false;
1126            }
1127            end += 1;
1128        }
1129
1130        if start == end {
1131            return Err(GraphitePdfKitError::Render(format!(
1132                "invalid SVG path token near `{}`",
1133                &data[index..]
1134            )));
1135        }
1136
1137        let number = data[start..end].parse::<f64>().map_err(|_| {
1138            GraphitePdfKitError::Render(format!("invalid SVG path number `{}`", &data[start..end]))
1139        })?;
1140        tokens.push(PathToken::Number(number));
1141        index = end;
1142    }
1143
1144    Ok(tokens)
1145}
1146
1147fn render_path_data(data: &str, content: &mut String) -> Result<()> {
1148    let tokens = tokenize_path_data(data)?;
1149    let mut index = 0usize;
1150    let mut current = (0.0, 0.0);
1151    let mut subpath_start = (0.0, 0.0);
1152    let mut last_command = 'M';
1153    let mut last_cubic_ctrl: Option<(f64, f64)> = None;
1154    let mut last_quad_ctrl: Option<(f64, f64)> = None;
1155
1156    while index < tokens.len() {
1157        let command = match tokens[index] {
1158            PathToken::Command(command) => {
1159                index += 1;
1160                last_command = command;
1161                command
1162            }
1163            PathToken::Number(_) => last_command,
1164        };
1165
1166        let relative = command.is_ascii_lowercase();
1167        match command.to_ascii_uppercase() {
1168            'M' => {
1169                let first = next_point(&tokens, &mut index, relative, current)?;
1170                current = first;
1171                subpath_start = first;
1172                last_cubic_ctrl = None;
1173                last_quad_ctrl = None;
1174                let _ = writeln!(
1175                    content,
1176                    "{} {} m",
1177                    format_number(current.0),
1178                    format_number(current.1)
1179                );
1180
1181                while has_number(&tokens, index) {
1182                    current = next_point(&tokens, &mut index, relative, current)?;
1183                    let _ = writeln!(
1184                        content,
1185                        "{} {} l",
1186                        format_number(current.0),
1187                        format_number(current.1)
1188                    );
1189                }
1190            }
1191            'L' => {
1192                while has_number(&tokens, index) {
1193                    current = next_point(&tokens, &mut index, relative, current)?;
1194                    last_cubic_ctrl = None;
1195                    last_quad_ctrl = None;
1196                    let _ = writeln!(
1197                        content,
1198                        "{} {} l",
1199                        format_number(current.0),
1200                        format_number(current.1)
1201                    );
1202                }
1203            }
1204            'H' => {
1205                while has_number(&tokens, index) {
1206                    let value = next_number(&tokens, &mut index)?;
1207                    current.0 = if relative { current.0 + value } else { value };
1208                    last_cubic_ctrl = None;
1209                    last_quad_ctrl = None;
1210                    let _ = writeln!(
1211                        content,
1212                        "{} {} l",
1213                        format_number(current.0),
1214                        format_number(current.1)
1215                    );
1216                }
1217            }
1218            'V' => {
1219                while has_number(&tokens, index) {
1220                    let value = next_number(&tokens, &mut index)?;
1221                    current.1 = if relative { current.1 + value } else { value };
1222                    last_cubic_ctrl = None;
1223                    last_quad_ctrl = None;
1224                    let _ = writeln!(
1225                        content,
1226                        "{} {} l",
1227                        format_number(current.0),
1228                        format_number(current.1)
1229                    );
1230                }
1231            }
1232            'C' => {
1233                while has_number(&tokens, index) {
1234                    let control_1 = next_point(&tokens, &mut index, relative, current)?;
1235                    let control_2 = next_point(&tokens, &mut index, relative, current)?;
1236                    let end = next_point(&tokens, &mut index, relative, current)?;
1237                    let _ = writeln!(
1238                        content,
1239                        "{} {} {} {} {} {} c",
1240                        format_number(control_1.0),
1241                        format_number(control_1.1),
1242                        format_number(control_2.0),
1243                        format_number(control_2.1),
1244                        format_number(end.0),
1245                        format_number(end.1)
1246                    );
1247                    current = end;
1248                    last_cubic_ctrl = Some(control_2);
1249                    last_quad_ctrl = None;
1250                }
1251            }
1252            'S' => {
1253                while has_number(&tokens, index) {
1254                    let control_1 = reflect_control_point(last_cubic_ctrl, current);
1255                    let control_2 = next_point(&tokens, &mut index, relative, current)?;
1256                    let end = next_point(&tokens, &mut index, relative, current)?;
1257                    let _ = writeln!(
1258                        content,
1259                        "{} {} {} {} {} {} c",
1260                        format_number(control_1.0),
1261                        format_number(control_1.1),
1262                        format_number(control_2.0),
1263                        format_number(control_2.1),
1264                        format_number(end.0),
1265                        format_number(end.1)
1266                    );
1267                    current = end;
1268                    last_cubic_ctrl = Some(control_2);
1269                    last_quad_ctrl = None;
1270                }
1271            }
1272            'Q' => {
1273                while has_number(&tokens, index) {
1274                    let control = next_point(&tokens, &mut index, relative, current)?;
1275                    let end = next_point(&tokens, &mut index, relative, current)?;
1276                    let cubic_1 = (
1277                        current.0 + (control.0 - current.0) * (2.0 / 3.0),
1278                        current.1 + (control.1 - current.1) * (2.0 / 3.0),
1279                    );
1280                    let cubic_2 = (
1281                        end.0 + (control.0 - end.0) * (2.0 / 3.0),
1282                        end.1 + (control.1 - end.1) * (2.0 / 3.0),
1283                    );
1284                    let _ = writeln!(
1285                        content,
1286                        "{} {} {} {} {} {} c",
1287                        format_number(cubic_1.0),
1288                        format_number(cubic_1.1),
1289                        format_number(cubic_2.0),
1290                        format_number(cubic_2.1),
1291                        format_number(end.0),
1292                        format_number(end.1)
1293                    );
1294                    current = end;
1295                    last_cubic_ctrl = Some(cubic_2);
1296                    last_quad_ctrl = Some(control);
1297                }
1298            }
1299            'T' => {
1300                while has_number(&tokens, index) {
1301                    let control = reflect_control_point(last_quad_ctrl, current);
1302                    let end = next_point(&tokens, &mut index, relative, current)?;
1303                    let cubic_1 = (
1304                        current.0 + (control.0 - current.0) * (2.0 / 3.0),
1305                        current.1 + (control.1 - current.1) * (2.0 / 3.0),
1306                    );
1307                    let cubic_2 = (
1308                        end.0 + (control.0 - end.0) * (2.0 / 3.0),
1309                        end.1 + (control.1 - end.1) * (2.0 / 3.0),
1310                    );
1311                    let _ = writeln!(
1312                        content,
1313                        "{} {} {} {} {} {} c",
1314                        format_number(cubic_1.0),
1315                        format_number(cubic_1.1),
1316                        format_number(cubic_2.0),
1317                        format_number(cubic_2.1),
1318                        format_number(end.0),
1319                        format_number(end.1)
1320                    );
1321                    current = end;
1322                    last_cubic_ctrl = Some(cubic_2);
1323                    last_quad_ctrl = Some(control);
1324                }
1325            }
1326            'Z' => {
1327                current = subpath_start;
1328                last_cubic_ctrl = None;
1329                last_quad_ctrl = None;
1330                content.push_str("h\n");
1331            }
1332            unsupported => {
1333                return Err(GraphitePdfKitError::Render(format!(
1334                    "unsupported SVG path command `{unsupported}`"
1335                )));
1336            }
1337        }
1338    }
1339
1340    Ok(())
1341}
1342
1343fn extract_use_href(node: &SvgNode) -> Option<&str> {
1344    node.props
1345        .get("href")
1346        .or_else(|| node.props.get("xlinkHref"))
1347        .and_then(|value| value.strip_prefix('#'))
1348}
1349
1350fn has_number(tokens: &[PathToken], index: usize) -> bool {
1351    matches!(tokens.get(index), Some(PathToken::Number(_)))
1352}
1353
1354fn next_number(tokens: &[PathToken], index: &mut usize) -> Result<f64> {
1355    match tokens.get(*index) {
1356        Some(PathToken::Number(value)) => {
1357            *index += 1;
1358            Ok(*value)
1359        }
1360        _ => Err(GraphitePdfKitError::Render(
1361            "invalid SVG path command sequence".to_string(),
1362        )),
1363    }
1364}
1365
1366fn next_point(
1367    tokens: &[PathToken],
1368    index: &mut usize,
1369    relative: bool,
1370    current: (f64, f64),
1371) -> Result<(f64, f64)> {
1372    let x = next_number(tokens, index)?;
1373    let y = next_number(tokens, index)?;
1374    Ok(if relative {
1375        (current.0 + x, current.1 + y)
1376    } else {
1377        (x, y)
1378    })
1379}
1380
1381fn reflect_control_point(control: Option<(f64, f64)>, current: (f64, f64)) -> (f64, f64) {
1382    control.map_or(current, |(x, y)| (2.0 * current.0 - x, 2.0 * current.1 - y))
1383}
1384
1385fn parse_color(value: &str) -> Option<PdfColor> {
1386    let value = value.trim();
1387    if value.is_empty() {
1388        return None;
1389    }
1390
1391    if let Some(hex) = value.strip_prefix('#') {
1392        return parse_hex_color(hex);
1393    }
1394
1395    let lowercase = value.to_ascii_lowercase();
1396    if lowercase.starts_with("rgb(") && lowercase.ends_with(')') {
1397        return parse_rgb_function(&lowercase[4..lowercase.len() - 1]);
1398    }
1399
1400    match lowercase.as_str() {
1401        "black" => Some(PdfColor::new(0.0, 0.0, 0.0)),
1402        "white" => Some(PdfColor::new(1.0, 1.0, 1.0)),
1403        "red" => Some(PdfColor::new(1.0, 0.0, 0.0)),
1404        "green" => Some(PdfColor::new(0.0, 0.5, 0.0)),
1405        "blue" => Some(PdfColor::new(0.0, 0.0, 1.0)),
1406        "yellow" => Some(PdfColor::new(1.0, 1.0, 0.0)),
1407        "purple" => Some(PdfColor::new(0.5, 0.0, 0.5)),
1408        "gray" | "grey" => Some(PdfColor::new(0.5, 0.5, 0.5)),
1409        "orange" => Some(PdfColor::new(1.0, 0.647, 0.0)),
1410        "rebeccapurple" => Some(PdfColor::new(0.4, 0.2, 0.6)),
1411        _ => None,
1412    }
1413}
1414
1415fn parse_hex_color(hex: &str) -> Option<PdfColor> {
1416    let (r, g, b) = match hex.len() {
1417        3 => (
1418            u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?,
1419            u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?,
1420            u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?,
1421        ),
1422        4 => (
1423            u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?,
1424            u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?,
1425            u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?,
1426        ),
1427        6 | 8 => (
1428            u8::from_str_radix(&hex[0..2], 16).ok()?,
1429            u8::from_str_radix(&hex[2..4], 16).ok()?,
1430            u8::from_str_radix(&hex[4..6], 16).ok()?,
1431        ),
1432        _ => return None,
1433    };
1434
1435    Some(PdfColor::new(
1436        f64::from(r) / 255.0,
1437        f64::from(g) / 255.0,
1438        f64::from(b) / 255.0,
1439    ))
1440}
1441
1442fn parse_rgb_function(values: &str) -> Option<PdfColor> {
1443    let parts: Vec<&str> = values
1444        .split(',')
1445        .map(str::trim)
1446        .filter(|part| !part.is_empty())
1447        .collect();
1448    if parts.len() != 3 {
1449        return None;
1450    }
1451
1452    fn parse_component(value: &str) -> Option<f64> {
1453        if let Some(percent) = value.strip_suffix('%') {
1454            let value = percent.parse::<f64>().ok()?;
1455            Some((value / 100.0).clamp(0.0, 1.0))
1456        } else {
1457            let value = value.parse::<f64>().ok()?;
1458            Some((value / 255.0).clamp(0.0, 1.0))
1459        }
1460    }
1461
1462    Some(PdfColor::new(
1463        parse_component(parts[0])?,
1464        parse_component(parts[1])?,
1465        parse_component(parts[2])?,
1466    ))
1467}
1468
1469fn estimate_text_advance(text: &str, font_size: f64) -> f64 {
1470    text.chars().count() as f64 * font_size * 0.6
1471}
1472
1473fn escape_pdf_text(text: &str) -> String {
1474    text.chars()
1475        .map(|character| match character {
1476            '(' => String::from("\\("),
1477            ')' => String::from("\\)"),
1478            '\\' => String::from("\\\\"),
1479            '\n' => String::from("\\n"),
1480            '\r' => String::from("\\r"),
1481            '\t' => String::from("\\t"),
1482            '\x08' => String::from("\\b"),
1483            '\x0c' => String::from("\\f"),
1484            _ => character.to_string(),
1485        })
1486        .collect()
1487}
1488
1489fn format_number(value: f64) -> String {
1490    let rounded = (value * 1000.0).round() / 1000.0;
1491    let mut rendered = format!("{rounded:.3}");
1492
1493    while rendered.contains('.') && rendered.ends_with('0') {
1494        rendered.pop();
1495    }
1496    if rendered.ends_with('.') {
1497        rendered.pop();
1498    }
1499    if rendered == "-0" {
1500        String::from("0")
1501    } else {
1502        rendered
1503    }
1504}
1505
1506#[cfg(test)]
1507mod tests {
1508    use super::*;
1509    use graphitepdf_math::{MathOptions, render_math_with_options};
1510    use graphitepdf_svg::parse_svg;
1511
1512    #[test]
1513    fn renders_svg_node_to_pdf_page_content() {
1514        let svg = parse_svg(
1515            r##"
1516            <svg xmlns="http://www.w3.org/2000/svg" width="120" height="80" viewBox="0 0 120 80">
1517              <rect x="10" y="10" width="50" height="20" fill="#336699"/>
1518              <path d="M70 10 L110 10 L90 40 Z" fill="none" stroke="red" stroke-width="2"/>
1519              <text x="15" y="55" font-size="14" fill="blue">Hi</text>
1520            </svg>"##,
1521        );
1522
1523        let rendered = render_svg_node_to_page_content_with_options(
1524            &svg,
1525            &SvgRenderOptions::new().position(24.0, 48.0).font_name("F1"),
1526        )
1527        .expect("svg page content should render");
1528        let content = String::from_utf8(rendered).expect("content should be valid ASCII");
1529
1530        assert!(content.contains("24 48 cm"));
1531        assert!(content.contains("10 10 50 20 re"));
1532        assert!(content.contains("0.2 0.4 0.6 rg"));
1533        assert!(content.contains("1 0 0 RG"));
1534        assert!(content.contains("/F1 14 Tf"));
1535        assert!(content.contains("(Hi) Tj"));
1536    }
1537
1538    #[test]
1539    fn renders_math_render_to_pdf_page_content_with_trait() {
1540        let math = render_math_with_options(
1541            r"\int_0^1 x^2 \, dx",
1542            &MathOptions::new().color("rebeccapurple").height(36.0),
1543        )
1544        .expect("math should render");
1545
1546        let rendered = math
1547            .to_pdf_page_content_with_options(&SvgRenderOptions::new().position(18.0, 32.0))
1548            .expect("math should convert to page content");
1549        let content = String::from_utf8(rendered).expect("content should be valid ASCII");
1550
1551        assert!(content.starts_with("q\n"));
1552        assert!(content.contains("18 32 cm"));
1553        assert!(content.contains(" c\n") || content.contains(" l\n"));
1554        assert!(content.ends_with("Q\n"));
1555    }
1556
1557    #[test]
1558    fn preserves_aspect_ratio_when_only_one_dimension_is_overridden() {
1559        let svg = parse_svg(
1560            r#"<svg xmlns="http://www.w3.org/2000/svg" width="160" height="80" viewBox="0 0 160 80">
1561                <rect x="0" y="0" width="160" height="80" fill="black"/>
1562            </svg>"#,
1563        );
1564
1565        let rendered = render_svg_node_to_page_content_with_options(
1566            &svg,
1567            &SvgRenderOptions::new().width(200.0),
1568        )
1569        .expect("svg should render");
1570        let content = String::from_utf8(rendered).expect("content should decode");
1571
1572        assert!(content.contains("1 0 0 -1 0 100 cm"));
1573        assert!(content.contains("1.25 0 0 1.25 0 0 cm"));
1574    }
1575}