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}