1pub mod flex;
35pub mod grid;
36pub mod page_break;
37
38use std::cell::RefCell;
39use std::collections::HashMap;
40use std::sync::Arc;
41
42use serde::Serialize;
43
44use crate::font::FontContext;
45use crate::model::*;
46use crate::style::*;
47use crate::text::bidi;
48use crate::text::shaping;
49use crate::text::{BrokenLine, RunBrokenLine, StyledChar, TextLayout};
50
51#[derive(Debug, Clone, Serialize)]
53#[serde(rename_all = "camelCase")]
54pub struct BookmarkEntry {
55 pub title: String,
56 pub page_index: usize,
57 pub y: f64,
58}
59
60#[derive(Debug, Clone, Serialize)]
64#[serde(rename_all = "camelCase")]
65pub struct LayoutInfo {
66 pub pages: Vec<PageInfo>,
67}
68
69#[derive(Debug, Clone, Serialize)]
71#[serde(rename_all = "camelCase")]
72pub struct PageInfo {
73 pub width: f64,
74 pub height: f64,
75 pub content_x: f64,
76 pub content_y: f64,
77 pub content_width: f64,
78 pub content_height: f64,
79 pub elements: Vec<ElementInfo>,
80}
81
82#[derive(Debug, Clone, Serialize)]
84#[serde(rename_all = "camelCase")]
85pub struct ElementStyleInfo {
86 pub margin: Edges,
88 pub padding: Edges,
89 pub border_width: Edges,
90 #[serde(skip_serializing_if = "Option::is_none")]
91 pub width: Option<String>,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 pub height: Option<String>,
94 #[serde(skip_serializing_if = "Option::is_none")]
95 pub min_width: Option<f64>,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub min_height: Option<f64>,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub max_width: Option<f64>,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 pub max_height: Option<f64>,
102 pub flex_direction: FlexDirection,
104 pub justify_content: JustifyContent,
105 pub align_items: AlignItems,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 pub align_self: Option<AlignItems>,
108 pub flex_wrap: FlexWrap,
109 pub align_content: AlignContent,
110 pub flex_grow: f64,
111 pub flex_shrink: f64,
112 #[serde(skip_serializing_if = "Option::is_none")]
113 pub flex_basis: Option<String>,
114 pub gap: f64,
115 pub row_gap: f64,
116 pub column_gap: f64,
117 pub font_family: String,
119 pub font_size: f64,
120 pub font_weight: u32,
121 pub font_style: FontStyle,
122 pub line_height: f64,
123 pub text_align: TextAlign,
124 pub letter_spacing: f64,
125 pub text_decoration: TextDecoration,
126 pub text_transform: TextTransform,
127 pub color: Color,
129 pub background_color: Option<Color>,
130 pub border_color: EdgeValues<Color>,
131 pub border_style: EdgeValues<crate::style::BorderStyle>,
132 pub border_radius: CornerValues,
133 pub opacity: f64,
134 pub position: Position,
136 #[serde(skip_serializing_if = "Option::is_none")]
137 pub top: Option<f64>,
138 #[serde(skip_serializing_if = "Option::is_none")]
139 pub right: Option<f64>,
140 #[serde(skip_serializing_if = "Option::is_none")]
141 pub bottom: Option<f64>,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub left: Option<f64>,
144 pub overflow: Overflow,
146 pub breakable: bool,
148 pub break_before: bool,
149 pub min_widow_lines: u32,
150 pub min_orphan_lines: u32,
151}
152
153fn size_constraint_to_str(sc: &SizeConstraint) -> Option<String> {
154 match sc {
155 SizeConstraint::Auto => None,
156 SizeConstraint::Fixed(v) => Some(format!("{v}")),
157 }
158}
159
160impl ElementStyleInfo {
161 fn from_resolved(style: &ResolvedStyle) -> Self {
162 ElementStyleInfo {
163 margin: style.margin.to_edges(),
164 padding: style.padding,
165 border_width: style.border_width,
166 width: size_constraint_to_str(&style.width),
167 height: size_constraint_to_str(&style.height),
168 min_width: if style.min_width > 0.0 {
169 Some(style.min_width)
170 } else {
171 None
172 },
173 min_height: if style.min_height > 0.0 {
174 Some(style.min_height)
175 } else {
176 None
177 },
178 max_width: if style.max_width.is_finite() {
179 Some(style.max_width)
180 } else {
181 None
182 },
183 max_height: if style.max_height.is_finite() {
184 Some(style.max_height)
185 } else {
186 None
187 },
188 flex_direction: style.flex_direction,
189 justify_content: style.justify_content,
190 align_items: style.align_items,
191 align_self: style.align_self,
192 flex_wrap: style.flex_wrap,
193 align_content: style.align_content,
194 flex_grow: style.flex_grow,
195 flex_shrink: style.flex_shrink,
196 flex_basis: size_constraint_to_str(&style.flex_basis),
197 gap: style.gap,
198 row_gap: style.row_gap,
199 column_gap: style.column_gap,
200 font_family: style.font_family.clone(),
201 font_size: style.font_size,
202 font_weight: style.font_weight,
203 font_style: style.font_style,
204 line_height: style.line_height,
205 text_align: style.text_align,
206 letter_spacing: style.letter_spacing,
207 text_decoration: style.text_decoration,
208 text_transform: style.text_transform,
209 color: style.color,
210 background_color: style.background_color,
211 border_color: style.border_color,
212 border_style: style.border_style,
213 border_radius: style.border_radius,
214 opacity: style.opacity,
215 position: style.position,
216 top: style.top,
217 right: style.right,
218 bottom: style.bottom,
219 left: style.left,
220 overflow: style.overflow,
221 breakable: style.breakable,
222 break_before: style.break_before,
223 min_widow_lines: style.min_widow_lines,
224 min_orphan_lines: style.min_orphan_lines,
225 }
226 }
227}
228
229impl Default for ElementStyleInfo {
230 fn default() -> Self {
231 ElementStyleInfo {
232 margin: Edges::default(),
233 padding: Edges::default(),
234 border_width: Edges::default(),
235 width: None,
236 height: None,
237 min_width: None,
238 min_height: None,
239 max_width: None,
240 max_height: None,
241 flex_direction: FlexDirection::default(),
242 justify_content: JustifyContent::default(),
243 align_items: AlignItems::default(),
244 align_self: None,
245 flex_wrap: FlexWrap::default(),
246 align_content: AlignContent::default(),
247 flex_grow: 0.0,
248 flex_shrink: 1.0,
249 flex_basis: None,
250 gap: 0.0,
251 row_gap: 0.0,
252 column_gap: 0.0,
253 font_family: "Helvetica".to_string(),
254 font_size: 12.0,
255 font_weight: 400,
256 font_style: FontStyle::default(),
257 line_height: 1.4,
258 text_align: TextAlign::default(),
259 letter_spacing: 0.0,
260 text_decoration: TextDecoration::None,
261 text_transform: TextTransform::None,
262 color: Color::BLACK,
263 background_color: None,
264 border_color: EdgeValues::uniform(Color::BLACK),
265 border_style: EdgeValues::uniform(crate::style::BorderStyle::Solid),
266 border_radius: CornerValues::uniform(0.0),
267 opacity: 1.0,
268 position: Position::default(),
269 top: None,
270 right: None,
271 bottom: None,
272 left: None,
273 overflow: Overflow::default(),
274 breakable: false,
275 break_before: false,
276 min_widow_lines: 2,
277 min_orphan_lines: 2,
278 }
279 }
280}
281
282#[derive(Debug, Clone, Serialize)]
284#[serde(rename_all = "camelCase")]
285pub struct ElementInfo {
286 pub x: f64,
287 pub y: f64,
288 pub width: f64,
289 pub height: f64,
290 pub kind: String,
292 pub node_type: String,
294 pub style: ElementStyleInfo,
296 pub children: Vec<ElementInfo>,
298 #[serde(skip_serializing_if = "Option::is_none")]
300 pub source_location: Option<SourceLocation>,
301 #[serde(skip_serializing_if = "Option::is_none")]
303 pub text_content: Option<String>,
304 #[serde(skip_serializing_if = "Option::is_none")]
306 pub href: Option<String>,
307 #[serde(skip_serializing_if = "Option::is_none")]
309 pub bookmark: Option<String>,
310}
311
312impl LayoutInfo {
313 pub fn from_pages(pages: &[LayoutPage]) -> Self {
315 LayoutInfo {
316 pages: pages
317 .iter()
318 .map(|page| {
319 let (page_w, page_h) = page.config.size.dimensions();
320 let content_x = page.config.margin.left;
321 let content_y = page.config.margin.top;
322 let content_width = page_w - page.config.margin.horizontal();
323 let content_height = page_h - page.config.margin.vertical();
324
325 let elements = Self::build_element_tree(&page.elements);
326
327 PageInfo {
328 width: page_w,
329 height: page_h,
330 content_x,
331 content_y,
332 content_width,
333 content_height,
334 elements,
335 }
336 })
337 .collect(),
338 }
339 }
340
341 fn build_element_tree(elems: &[LayoutElement]) -> Vec<ElementInfo> {
342 elems
343 .iter()
344 .map(|elem| {
345 let kind = match &elem.draw {
346 DrawCommand::None => "None",
347 DrawCommand::Rect { .. } => "Rect",
348 DrawCommand::Text { .. } => "Text",
349 DrawCommand::Image { .. } => "Image",
350 DrawCommand::ImagePlaceholder => "ImagePlaceholder",
351 DrawCommand::Svg { .. } => "Svg",
352 DrawCommand::Barcode { .. } => "Barcode",
353 DrawCommand::QrCode { .. } => "QrCode",
354 DrawCommand::Chart { .. } => "Chart",
355 DrawCommand::Watermark { .. } => "Watermark",
356 DrawCommand::FormField { .. } => "FormField",
357 };
358 let text_content = match &elem.draw {
359 DrawCommand::Text { lines, .. } => {
360 let text: String = lines
361 .iter()
362 .flat_map(|line| {
363 line.glyphs.iter().flat_map(|g| {
364 g.cluster_text.as_deref().unwrap_or("").chars().chain(
366 if g.cluster_text.is_none() {
367 Some(g.char_value)
368 } else {
369 None
370 },
371 )
372 })
373 })
374 .collect();
375 if text.is_empty() {
376 None
377 } else {
378 Some(text)
379 }
380 }
381 _ => None,
382 };
383 let node_type = elem.node_type.clone().unwrap_or_else(|| kind.to_string());
384 let style = elem
385 .resolved_style
386 .as_ref()
387 .map(ElementStyleInfo::from_resolved)
388 .unwrap_or_default();
389 ElementInfo {
390 x: elem.x,
391 y: elem.y,
392 width: elem.width,
393 height: elem.height,
394 kind: kind.to_string(),
395 node_type,
396 style,
397 children: Self::build_element_tree(&elem.children),
398 source_location: elem.source_location.clone(),
399 text_content,
400 href: elem.href.clone(),
401 bookmark: elem.bookmark.clone(),
402 }
403 })
404 .collect()
405 }
406}
407
408#[derive(Debug, Clone)]
410pub struct LayoutPage {
411 pub width: f64,
412 pub height: f64,
413 pub elements: Vec<LayoutElement>,
414 pub(crate) fixed_header: Vec<(Node, f64)>,
416 pub(crate) fixed_footer: Vec<(Node, f64)>,
418 pub(crate) watermarks: Vec<Node>,
420 pub(crate) config: PageConfig,
422 pub(crate) page_name: Option<String>,
424}
425
426#[derive(Debug, Clone)]
428pub struct LayoutElement {
429 pub x: f64,
431 pub y: f64,
432 pub width: f64,
434 pub height: f64,
435 pub draw: DrawCommand,
437 pub children: Vec<LayoutElement>,
439 pub node_type: Option<String>,
441 pub resolved_style: Option<ResolvedStyle>,
443 pub source_location: Option<SourceLocation>,
445 pub href: Option<String>,
447 pub bookmark: Option<String>,
449 pub alt: Option<String>,
451 pub is_header_row: bool,
453 pub col_span: u32,
456 pub overflow: Overflow,
458 pub opacity: f64,
463}
464
465fn fixed_applies_on(node: &Node, page_index: usize, page_name: Option<&str>) -> bool {
468 match &node.kind {
469 NodeKind::Fixed {
470 pages,
471 page_name: only,
472 exclude_page_names,
473 ..
474 } => {
475 pages.applies(page_index)
476 && match only {
477 Some(n) => page_name == Some(n.as_str()),
478 None => true,
479 }
480 && !exclude_page_names
481 .iter()
482 .any(|n| page_name == Some(n.as_str()))
483 }
484 _ => true,
485 }
486}
487
488fn node_kind_name(kind: &NodeKind) -> &'static str {
490 match kind {
491 NodeKind::View => "View",
492 NodeKind::Text { .. } => "Text",
493 NodeKind::Heading { level: 1, .. } => "H1",
494 NodeKind::Heading { level: 2, .. } => "H2",
495 NodeKind::Heading { level: 3, .. } => "H3",
496 NodeKind::Heading { level: 4, .. } => "H4",
497 NodeKind::Heading { level: 5, .. } => "H5",
498 NodeKind::Heading { .. } => "H6",
502 NodeKind::List { .. } => "List",
503 NodeKind::ListItem => "ListItem",
504 NodeKind::Image { .. } => "Image",
505 NodeKind::Table { .. } => "Table",
506 NodeKind::TableRow { .. } => "TableRow",
507 NodeKind::TableCell { .. } => "TableCell",
508 NodeKind::Fixed {
509 position: FixedPosition::Header,
510 ..
511 } => "FixedHeader",
512 NodeKind::Fixed {
513 position: FixedPosition::Footer,
514 ..
515 } => "FixedFooter",
516 NodeKind::Page { .. } => "Page",
517 NodeKind::PageBreak => "PageBreak",
518 NodeKind::PageName { .. } => "PageName",
519 NodeKind::Svg { .. } => "Svg",
520 NodeKind::Canvas { .. } => "Canvas",
521 NodeKind::Barcode { .. } => "Barcode",
522 NodeKind::QrCode { .. } => "QrCode",
523 NodeKind::BarChart { .. } => "BarChart",
524 NodeKind::LineChart { .. } => "LineChart",
525 NodeKind::PieChart { .. } => "PieChart",
526 NodeKind::AreaChart { .. } => "AreaChart",
527 NodeKind::DotPlot { .. } => "DotPlot",
528 NodeKind::Watermark { .. } => "Watermark",
529 NodeKind::TextField { .. } => "TextField",
530 NodeKind::Checkbox { .. } => "Checkbox",
531 NodeKind::Dropdown { .. } => "Dropdown",
532 NodeKind::RadioButton { .. } => "RadioButton",
533 }
534}
535
536fn bookmark_marker(node: &Node, x: f64, y: f64) -> Option<LayoutElement> {
555 node.bookmark.as_ref().map(|title| LayoutElement {
556 x,
557 y,
558 width: 0.0,
559 height: 0.0,
560 draw: DrawCommand::None,
561 children: vec![],
562 node_type: Some("Bookmark".to_string()),
563 resolved_style: None,
564 source_location: None,
565 href: None,
566 bookmark: Some(title.clone()),
567 alt: None,
568 is_header_row: false,
569 col_span: 1,
570 overflow: Overflow::default(),
571 opacity: 1.0,
572 })
573}
574
575fn format_marker(idx: u32, ordered: bool, marker_type: ListMarkerType) -> String {
582 if !ordered {
583 return match marker_type {
587 ListMarkerType::None => String::new(),
588 _ => "•".to_string(),
589 };
590 }
591 let body = match marker_type {
592 ListMarkerType::LowerAlpha => to_alpha(idx, false),
593 ListMarkerType::UpperAlpha => to_alpha(idx, true),
594 ListMarkerType::LowerRoman => to_roman(idx, false),
595 ListMarkerType::UpperRoman => to_roman(idx, true),
596 ListMarkerType::None => return String::new(),
597 _ => idx.to_string(),
600 };
601 format!("{body}.")
602}
603
604fn to_alpha(mut n: u32, upper: bool) -> String {
607 if n == 0 {
608 return String::new();
609 }
610 let base = if upper { b'A' } else { b'a' };
611 let mut bytes: Vec<u8> = Vec::new();
612 while n > 0 {
613 let rem = ((n - 1) % 26) as u8;
614 bytes.push(base + rem);
615 n = (n - 1) / 26;
616 }
617 bytes.reverse();
618 String::from_utf8(bytes).unwrap_or_default()
619}
620
621fn to_roman(n: u32, upper: bool) -> String {
625 if n == 0 || n > 3999 {
626 return n.to_string();
627 }
628 const UPPER: &[(&str, u32)] = &[
629 ("M", 1000),
630 ("CM", 900),
631 ("D", 500),
632 ("CD", 400),
633 ("C", 100),
634 ("XC", 90),
635 ("L", 50),
636 ("XL", 40),
637 ("X", 10),
638 ("IX", 9),
639 ("V", 5),
640 ("IV", 4),
641 ("I", 1),
642 ];
643 const LOWER: &[(&str, u32)] = &[
644 ("m", 1000),
645 ("cm", 900),
646 ("d", 500),
647 ("cd", 400),
648 ("c", 100),
649 ("xc", 90),
650 ("l", 50),
651 ("xl", 40),
652 ("x", 10),
653 ("ix", 9),
654 ("v", 5),
655 ("iv", 4),
656 ("i", 1),
657 ];
658 let table = if upper { UPPER } else { LOWER };
659 let mut out = String::new();
660 let mut remaining = n;
661 for &(sym, val) in table {
662 while remaining >= val {
663 out.push_str(sym);
664 remaining -= val;
665 }
666 }
667 out
668}
669
670fn compute_marker_gutter_width(
677 ordered: bool,
678 marker_type: ListMarkerType,
679 start: u32,
680 n_items: u32,
681 style: &ResolvedStyle,
682) -> f64 {
683 if matches!(marker_type, ListMarkerType::None) {
684 return 0.0;
685 }
686 let approx_char_w = style.font_size * 0.6;
688 let gap = 6.0_f64;
689 if !ordered {
690 return approx_char_w + gap;
692 }
693 let last_idx = start + n_items.saturating_sub(1);
695 let widest = match marker_type {
696 ListMarkerType::Decimal => format_marker(last_idx, true, ListMarkerType::Decimal),
697 ListMarkerType::LowerAlpha => format_marker(last_idx, true, ListMarkerType::LowerAlpha),
698 ListMarkerType::UpperAlpha => format_marker(last_idx, true, ListMarkerType::UpperAlpha),
699 ListMarkerType::LowerRoman => format_marker(last_idx, true, ListMarkerType::LowerRoman),
700 ListMarkerType::UpperRoman => format_marker(last_idx, true, ListMarkerType::UpperRoman),
701 _ => format_marker(last_idx, true, ListMarkerType::Decimal),
702 };
703 widest.chars().count() as f64 * approx_char_w + gap
704}
705
706#[derive(Debug, Clone)]
708pub enum FormFieldType {
709 TextField {
710 value: Option<String>,
711 placeholder: Option<String>,
712 multiline: bool,
713 password: bool,
714 read_only: bool,
715 max_length: Option<u32>,
716 font_size: f64,
717 },
718 Checkbox {
719 checked: bool,
720 read_only: bool,
721 },
722 Dropdown {
723 options: Vec<String>,
724 value: Option<String>,
725 read_only: bool,
726 font_size: f64,
727 },
728 RadioButton {
729 value: String,
730 checked: bool,
731 read_only: bool,
732 },
733}
734
735#[derive(Debug, Clone)]
737pub enum DrawCommand {
738 None,
740 Rect {
742 background: Option<Color>,
743 border_width: Edges,
744 border_color: EdgeValues<Color>,
745 border_style: EdgeValues<crate::style::BorderStyle>,
746 border_radius: CornerValues,
747 opacity: f64,
748 box_shadow: Option<Box<crate::style::BoxShadow>>,
751 background_gradient: Option<Box<crate::style::Background>>,
755 },
756 Text {
758 lines: Vec<TextLine>,
759 color: Color,
760 text_decoration: TextDecoration,
761 opacity: f64,
762 },
763 Image {
765 image_data: crate::image_loader::LoadedImage,
766 },
767 ImagePlaceholder,
769 Svg {
771 commands: Vec<crate::svg::SvgCommand>,
772 width: f64,
774 height: f64,
776 viewbox_min_x: f64,
780 viewbox_min_y: f64,
781 viewbox_width: f64,
782 viewbox_height: f64,
783 clip: bool,
785 },
786 Barcode {
788 bars: Vec<u8>,
789 bar_width: f64,
790 height: f64,
791 color: Color,
792 },
793 QrCode {
795 modules: Vec<Vec<bool>>,
796 module_size: f64,
797 color: Color,
798 },
799 Chart {
801 primitives: Vec<crate::chart::ChartPrimitive>,
802 },
803 Watermark {
805 lines: Vec<TextLine>,
806 color: Color,
807 opacity: f64,
808 angle_rad: f64,
809 font_family: String,
811 },
812 FormField {
814 field_type: FormFieldType,
815 name: String,
816 },
817}
818
819#[derive(Debug, Clone)]
820pub struct TextLine {
821 pub x: f64,
822 pub y: f64,
823 pub glyphs: Vec<PositionedGlyph>,
824 pub width: f64,
825 pub height: f64,
826 pub word_spacing: f64,
828}
829
830#[derive(Debug, Clone)]
831pub struct PositionedGlyph {
832 pub glyph_id: u16,
835 pub x_offset: f64,
837 pub y_offset: f64,
839 pub x_advance: f64,
841 pub font_size: f64,
842 pub font_family: Arc<str>,
848 pub font_weight: u32,
849 pub font_style: FontStyle,
850 pub char_value: char,
852 pub color: Option<Color>,
854 pub href: Option<String>,
856 pub text_decoration: TextDecoration,
858 pub letter_spacing: f64,
860 pub cluster_text: Option<String>,
863}
864
865fn offset_element_y(el: &mut LayoutElement, dy: f64) {
868 el.y += dy;
869 if let DrawCommand::Text { ref mut lines, .. } = el.draw {
870 for line in lines.iter_mut() {
871 line.y += dy;
872 }
873 }
874 for child in &mut el.children {
875 offset_element_y(child, dy);
876 }
877}
878
879fn offset_element_x(el: &mut LayoutElement, dx: f64) {
881 el.x += dx;
882 if let DrawCommand::Text { ref mut lines, .. } = el.draw {
883 for line in lines.iter_mut() {
884 line.x += dx;
885 }
886 }
887 for child in &mut el.children {
888 offset_element_x(child, dx);
889 }
890}
891
892fn reapply_justify_content(elem: &mut LayoutElement) {
896 let style = match elem.resolved_style {
897 Some(ref s) => s,
898 None => return,
899 };
900 if matches!(style.justify_content, JustifyContent::FlexStart) {
901 return;
902 }
903 if elem.children.is_empty() {
904 return;
905 }
906
907 let padding_top = style.padding.top + style.border_width.top;
908 let padding_bottom = style.padding.bottom + style.border_width.bottom;
909 let inner_h = elem.height - padding_top - padding_bottom;
910 let content_top = elem.y + padding_top;
911
912 let last_child = &elem.children[elem.children.len() - 1];
914 let children_bottom = last_child.y + last_child.height;
915 let children_span = children_bottom - content_top;
916 let slack = inner_h - children_span;
917 if slack < 0.001 {
918 return;
919 }
920
921 let n = elem.children.len();
922 let offsets: Vec<f64> = match style.justify_content {
923 JustifyContent::FlexEnd => vec![slack; n],
924 JustifyContent::Center => vec![slack / 2.0; n],
925 JustifyContent::SpaceBetween => {
926 if n <= 1 {
927 vec![0.0; n]
928 } else {
929 let per_gap = slack / (n - 1) as f64;
930 (0..n).map(|i| i as f64 * per_gap).collect()
931 }
932 }
933 JustifyContent::SpaceAround => {
934 let space = slack / n as f64;
935 (0..n).map(|i| space / 2.0 + i as f64 * space).collect()
936 }
937 JustifyContent::SpaceEvenly => {
938 let space = slack / (n + 1) as f64;
939 (0..n).map(|i| (i + 1) as f64 * space).collect()
940 }
941 JustifyContent::FlexStart => unreachable!(),
942 };
943
944 for (i, child) in elem.children.iter_mut().enumerate() {
945 let dy = offsets[i];
946 if dy.abs() > 0.001 {
947 offset_element_y(child, dy);
948 }
949 }
950}
951
952fn apply_text_transform(text: &str, transform: TextTransform) -> String {
954 match transform {
955 TextTransform::None => text.to_string(),
956 TextTransform::Uppercase => text.to_uppercase(),
957 TextTransform::Lowercase => text.to_lowercase(),
958 TextTransform::Capitalize => {
959 let mut result = String::with_capacity(text.len());
960 let mut prev_is_whitespace = true;
961 for ch in text.chars() {
962 if prev_is_whitespace && ch.is_alphabetic() {
963 for upper in ch.to_uppercase() {
964 result.push(upper);
965 }
966 } else {
967 result.push(ch);
968 }
969 prev_is_whitespace = ch.is_whitespace();
970 }
971 result
972 }
973 }
974}
975
976pub const PAGE_NUMBER_SENTINEL: char = '\x02';
980
981pub const TOTAL_PAGES_SENTINEL: char = '\x03';
983
984fn substitute_page_placeholders(text: &str) -> String {
989 if text.contains("{{pageNumber}}") || text.contains("{{totalPages}}") {
990 text.replace("{{pageNumber}}", &PAGE_NUMBER_SENTINEL.to_string())
991 .replace("{{totalPages}}", &TOTAL_PAGES_SENTINEL.to_string())
992 } else {
993 text.to_string()
994 }
995}
996
997fn apply_char_transform(ch: char, transform: TextTransform, is_word_start: bool) -> char {
1000 match transform {
1001 TextTransform::None => ch,
1002 TextTransform::Uppercase => ch.to_uppercase().next().unwrap_or(ch),
1003 TextTransform::Lowercase => ch.to_lowercase().next().unwrap_or(ch),
1004 TextTransform::Capitalize => {
1005 if is_word_start && ch.is_alphabetic() {
1006 ch.to_uppercase().next().unwrap_or(ch)
1007 } else {
1008 ch
1009 }
1010 }
1011 }
1012}
1013
1014pub struct LayoutEngine {
1016 text_layout: TextLayout,
1017 image_dim_cache: RefCell<HashMap<String, (u32, u32)>>,
1018 warnings: RefCell<Vec<String>>,
1030}
1031
1032#[derive(Debug, Clone)]
1034struct PageCursor {
1035 config: PageConfig,
1036 base_config: PageConfig,
1039 page_index: usize,
1044 content_width: f64,
1045 content_height: f64,
1046 y: f64,
1047 elements: Vec<LayoutElement>,
1048 fixed_header: Vec<(Node, f64)>,
1049 fixed_footer: Vec<(Node, f64)>,
1050 watermarks: Vec<Node>,
1052 content_x: f64,
1053 content_y: f64,
1054 continuation_top_offset: f64,
1056 containing_block: (f64, f64, f64, f64),
1061 left_config: Option<PageConfig>,
1069 right_config: Option<PageConfig>,
1070 display_config: Option<PageConfig>,
1073 flow_anchor_left: f64,
1078 page_name: Option<String>,
1082 base_display: Option<PageConfig>,
1086 first_display: Option<PageConfig>,
1090 doc_base: PageConfig,
1093 doc_left: Option<PageConfig>,
1094 doc_right: Option<PageConfig>,
1095 named_sets: std::collections::HashMap<String, crate::model::NamedPageSet>,
1097}
1098
1099impl PageCursor {
1100 fn new(config: &PageConfig) -> Self {
1101 let (page_w, page_h) = config.size.dimensions();
1102 let content_width = page_w - config.margin.horizontal();
1103 let content_height = page_h - config.margin.vertical();
1104
1105 Self {
1106 config: config.clone(),
1107 base_config: config.clone(),
1108 page_index: 0,
1109 content_width,
1110 content_height,
1111 y: 0.0,
1112 elements: Vec::new(),
1113 fixed_header: Vec::new(),
1114 fixed_footer: Vec::new(),
1115 watermarks: Vec::new(),
1116 content_x: config.margin.left,
1117 content_y: config.margin.top,
1118 continuation_top_offset: 0.0,
1119 containing_block: (
1120 config.margin.left,
1121 config.margin.top,
1122 content_width,
1123 content_height,
1124 ),
1125 left_config: None,
1126 right_config: None,
1127 display_config: None,
1128 flow_anchor_left: config.margin.left,
1129 page_name: None,
1130 base_display: None,
1131 first_display: None,
1132 doc_base: config.clone(),
1133 doc_left: None,
1134 doc_right: None,
1135 named_sets: std::collections::HashMap::new(),
1136 }
1137 }
1138
1139 fn display_for(&self, page_number: usize) -> Option<PageConfig> {
1146 if page_number == 1 {
1147 if let Some(first) = &self.first_display {
1148 return Some(first.clone());
1149 }
1150 }
1151 let parity = if page_number % 2 == 1 {
1152 self.right_config.clone()
1153 } else {
1154 self.left_config.clone()
1155 };
1156 parity.or_else(|| self.base_display.clone())
1157 }
1158
1159 fn fixed_applies(&self, node: &Node) -> bool {
1162 fixed_applies_on(node, self.page_index, self.page_name.as_deref())
1163 }
1164
1165 fn renamed_page(&self, name: Option<String>, in_place: bool) -> Self {
1172 let set = name
1173 .as_deref()
1174 .and_then(|n| self.named_sets.get(n))
1175 .cloned();
1176 let real = set
1177 .as_ref()
1178 .map(|s| s.base.clone())
1179 .unwrap_or_else(|| self.doc_base.clone());
1180 let mut cursor = PageCursor::new(&real);
1181 cursor.page_index = if in_place {
1182 self.page_index
1183 } else {
1184 self.page_index + 1
1185 };
1186 cursor.page_name = name;
1187 cursor.doc_base = self.doc_base.clone();
1188 cursor.doc_left = self.doc_left.clone();
1189 cursor.doc_right = self.doc_right.clone();
1190 cursor.named_sets = self.named_sets.clone();
1191 match &set {
1192 Some(s) => {
1193 cursor.left_config = s.display_left.clone();
1194 cursor.right_config = s.display_right.clone();
1195 cursor.base_display = s.display.clone();
1196 cursor.first_display = s.display_first.clone();
1197 }
1198 None => {
1199 cursor.left_config = self.doc_left.clone();
1203 cursor.right_config = self.doc_right.clone();
1204 }
1205 }
1206 cursor.display_config = cursor.display_for(cursor.page_index + 1);
1207 cursor.flow_anchor_left = self.flow_anchor_left;
1208 cursor.fixed_header = self.fixed_header.clone();
1209 cursor.fixed_footer = self.fixed_footer.clone();
1210 cursor.watermarks = self.watermarks.clone();
1211 cursor.continuation_top_offset = self.continuation_top_offset;
1212
1213 let header_height: f64 = cursor
1214 .fixed_header
1215 .iter()
1216 .filter(|(n, _)| cursor.fixed_applies(n))
1217 .map(|(_, h)| *h)
1218 .sum();
1219 cursor.y = header_height + cursor.continuation_top_offset;
1220 cursor
1221 }
1222
1223 fn new_first(first: &PageConfig, base: &PageConfig) -> Self {
1226 let mut cursor = PageCursor::new(first);
1227 cursor.base_config = base.clone();
1228 cursor
1229 }
1230
1231 fn remaining_height(&self) -> f64 {
1232 let footer_height: f64 = self
1233 .fixed_footer
1234 .iter()
1235 .filter(|(n, _)| self.fixed_applies(n))
1236 .map(|(_, h)| *h)
1237 .sum();
1238 (self.content_height - self.y - footer_height).max(0.0)
1239 }
1240
1241 fn finalize(&self) -> LayoutPage {
1242 let (page_w, page_h) = self.config.size.dimensions();
1243
1244 let (elements, config) = match &self.display_config {
1250 Some(display) => {
1251 let dx = display.margin.left - self.flow_anchor_left;
1252 let mut elements = self.elements.clone();
1253 if dx != 0.0 {
1254 fn shift(els: &mut [LayoutElement], dx: f64) {
1255 for el in els {
1256 el.x += dx;
1257 shift(&mut el.children, dx);
1258 }
1259 }
1260 shift(&mut elements, dx);
1261 }
1262 (elements, display.clone())
1263 }
1264 None => (self.elements.clone(), self.config.clone()),
1265 };
1266
1267 LayoutPage {
1268 width: page_w,
1269 height: page_h,
1270 elements,
1271 fixed_header: self.fixed_header.clone(),
1272 fixed_footer: self.fixed_footer.clone(),
1273 watermarks: self.watermarks.clone(),
1274 config,
1275 page_name: self.page_name.clone(),
1276 }
1277 }
1278
1279 fn new_page(&self) -> Self {
1280 let mut cursor = PageCursor::new(&self.base_config);
1283 cursor.page_index = self.page_index + 1;
1284 cursor.page_name = self.page_name.clone();
1285 cursor.left_config = self.left_config.clone();
1286 cursor.right_config = self.right_config.clone();
1287 cursor.base_display = self.base_display.clone();
1288 cursor.first_display = self.first_display.clone();
1289 cursor.doc_base = self.doc_base.clone();
1290 cursor.doc_left = self.doc_left.clone();
1291 cursor.doc_right = self.doc_right.clone();
1292 cursor.named_sets = self.named_sets.clone();
1293 cursor.display_config = cursor.display_for(cursor.page_index + 1);
1294 cursor.flow_anchor_left = self.flow_anchor_left;
1295 cursor.fixed_header = self.fixed_header.clone();
1296 cursor.fixed_footer = self.fixed_footer.clone();
1297 cursor.watermarks = self.watermarks.clone();
1298 cursor.continuation_top_offset = self.continuation_top_offset;
1299
1300 let header_height: f64 = cursor
1301 .fixed_header
1302 .iter()
1303 .filter(|(n, _)| cursor.fixed_applies(n))
1304 .map(|(_, h)| *h)
1305 .sum();
1306 cursor.y = header_height + cursor.continuation_top_offset;
1307
1308 cursor
1309 }
1310}
1311
1312impl Default for LayoutEngine {
1313 fn default() -> Self {
1314 Self::new()
1315 }
1316}
1317
1318impl LayoutEngine {
1319 pub fn new() -> Self {
1320 Self {
1321 text_layout: TextLayout::new(),
1322 image_dim_cache: RefCell::new(HashMap::new()),
1323 warnings: RefCell::new(Vec::new()),
1324 }
1325 }
1326
1327 fn get_image_dimensions(&self, src: &str) -> Option<(u32, u32)> {
1329 if let Some(dims) = self.image_dim_cache.borrow().get(src) {
1330 return Some(*dims);
1331 }
1332 if let Ok(dims) = crate::image_loader::load_image_dimensions(src) {
1333 self.image_dim_cache
1334 .borrow_mut()
1335 .insert(src.to_string(), dims);
1336 Some(dims)
1337 } else {
1338 None
1339 }
1340 }
1341
1342 fn defect(&self, msg: String) {
1345 let mut w = self.warnings.borrow_mut();
1346 if !w.contains(&msg) {
1349 w.push(msg);
1350 }
1351 }
1352
1353 pub fn take_warnings(&self) -> Vec<String> {
1357 std::mem::take(&mut *self.warnings.borrow_mut())
1358 }
1359
1360 pub fn layout(&self, document: &Document, font_context: &FontContext) -> Vec<LayoutPage> {
1362 self.warnings.borrow_mut().clear();
1363 let mut pages: Vec<LayoutPage> = Vec::new();
1364 let mut cursor = match &document.first_page {
1365 Some(first) => PageCursor::new_first(first, &document.default_page),
1366 None => PageCursor::new(&document.default_page),
1367 };
1368 cursor.left_config = document.left_page.clone();
1374 cursor.right_config = document.right_page.clone();
1375 cursor.doc_base = document.default_page.clone();
1377 cursor.doc_left = document.left_page.clone();
1378 cursor.doc_right = document.right_page.clone();
1379 cursor.named_sets = document.named_pages.clone();
1380 if document.first_page.is_none() {
1381 cursor.display_config = cursor.display_for(1);
1382 }
1383
1384 let base = document.default_style.clone().unwrap_or_default();
1386 let root_style = Style {
1387 lang: base.lang.clone().or(document.metadata.lang.clone()),
1388 ..base
1389 }
1390 .resolve(None, cursor.content_width);
1391
1392 for node in &document.children {
1393 match &node.kind {
1394 NodeKind::Page { config } => {
1395 if !cursor.elements.is_empty() || cursor.y > 0.0 {
1396 pages.push(cursor.finalize());
1397 }
1398 cursor = PageCursor::new(config);
1399 cursor.page_index = pages.len();
1400
1401 let mut page_root = node.style.resolve(Some(&root_style), cursor.content_width);
1408 page_root.height = SizeConstraint::Fixed(cursor.content_height);
1409
1410 let cx = cursor.content_x;
1411 let cw = cursor.content_width;
1412 self.layout_children(
1413 &node.children,
1414 &node.style,
1415 &mut cursor,
1416 &mut pages,
1417 cx,
1418 cw,
1419 Some(&page_root),
1420 font_context,
1421 );
1422 }
1423 NodeKind::PageBreak => {
1424 pages.push(cursor.finalize());
1425 cursor = cursor.new_page();
1426 }
1427 _ => {
1428 let cx = cursor.content_x;
1429 let cw = cursor.content_width;
1430 self.layout_node(
1431 node,
1432 &mut cursor,
1433 &mut pages,
1434 cx,
1435 cw,
1436 Some(&root_style),
1437 font_context,
1438 None,
1439 None,
1440 );
1441 }
1442 }
1443 }
1444
1445 if !cursor.elements.is_empty() || cursor.y > 0.0 {
1446 pages.push(cursor.finalize());
1447 }
1448
1449 self.inject_fixed_elements(&mut pages, font_context);
1450
1451 pages
1452 }
1453
1454 #[allow(clippy::too_many_arguments)]
1455 fn layout_node(
1456 &self,
1457 node: &Node,
1458 cursor: &mut PageCursor,
1459 pages: &mut Vec<LayoutPage>,
1460 x: f64,
1461 available_width: f64,
1462 parent_style: Option<&ResolvedStyle>,
1463 font_context: &FontContext,
1464 cross_axis_height: Option<f64>,
1465 forced_outer_width: Option<f64>,
1466 ) {
1467 let mut style = node.style.resolve(parent_style, available_width);
1468
1469 if let Some(h) = cross_axis_height {
1472 if matches!(style.height, SizeConstraint::Auto) {
1473 style.height = SizeConstraint::Fixed(h);
1474 }
1475 }
1476
1477 if let Some(w) = forced_outer_width {
1482 style.width = SizeConstraint::Fixed(w);
1483 }
1484
1485 if style.break_before && !cursor.elements.is_empty() {
1496 pages.push(cursor.finalize());
1497 *cursor = cursor.new_page();
1498 }
1499
1500 let elem_start = cursor.elements.len();
1503
1504 match &node.kind {
1505 NodeKind::PageBreak => {
1506 pages.push(cursor.finalize());
1507 *cursor = cursor.new_page();
1508 }
1509
1510 NodeKind::PageName { name } => {
1511 if cursor.page_name.as_deref() != name.as_deref() {
1517 let fresh_y: f64 = cursor
1520 .fixed_header
1521 .iter()
1522 .filter(|(n, _)| cursor.fixed_applies(n))
1523 .map(|(_, h)| *h)
1524 .sum::<f64>()
1525 + cursor.continuation_top_offset;
1526 let has_content = !cursor.elements.is_empty() || cursor.y > fresh_y + 0.01;
1527 if has_content {
1528 pages.push(cursor.finalize());
1529 }
1530 *cursor = cursor.renamed_page(name.clone(), !has_content);
1531 }
1532 }
1533
1534 NodeKind::Fixed {
1535 position, pages, ..
1536 } => {
1537 let height = self.measure_node_height(node, available_width, &style, font_context);
1538 match position {
1539 FixedPosition::Header => {
1540 cursor.fixed_header.push((node.clone(), height));
1541 let _ = pages;
1545 if cursor.fixed_applies(node) {
1546 cursor.y += height;
1547 }
1548 }
1549 FixedPosition::Footer => {
1550 cursor.fixed_footer.push((node.clone(), height));
1551 }
1552 }
1553 }
1554
1555 NodeKind::Watermark { .. } => {
1556 cursor.watermarks.push(node.clone());
1558 }
1559
1560 NodeKind::TextField {
1561 name,
1562 value,
1563 placeholder,
1564 width: field_w,
1565 height: field_h,
1566 multiline,
1567 password,
1568 read_only,
1569 max_length,
1570 font_size,
1571 } => {
1572 self.layout_form_field(
1573 node,
1574 &style,
1575 cursor,
1576 pages,
1577 x,
1578 *field_w,
1579 *field_h,
1580 DrawCommand::FormField {
1581 field_type: FormFieldType::TextField {
1582 value: value.clone(),
1583 placeholder: placeholder.clone(),
1584 multiline: *multiline,
1585 password: *password,
1586 read_only: *read_only,
1587 max_length: *max_length,
1588 font_size: *font_size,
1589 },
1590 name: name.clone(),
1591 },
1592 "TextField",
1593 );
1594 }
1595
1596 NodeKind::Checkbox {
1597 name,
1598 checked,
1599 width: field_w,
1600 height: field_h,
1601 read_only,
1602 } => {
1603 self.layout_form_field(
1604 node,
1605 &style,
1606 cursor,
1607 pages,
1608 x,
1609 *field_w,
1610 *field_h,
1611 DrawCommand::FormField {
1612 field_type: FormFieldType::Checkbox {
1613 checked: *checked,
1614 read_only: *read_only,
1615 },
1616 name: name.clone(),
1617 },
1618 "Checkbox",
1619 );
1620 }
1621
1622 NodeKind::Dropdown {
1623 name,
1624 options,
1625 value,
1626 width: field_w,
1627 height: field_h,
1628 read_only,
1629 font_size,
1630 } => {
1631 self.layout_form_field(
1632 node,
1633 &style,
1634 cursor,
1635 pages,
1636 x,
1637 *field_w,
1638 *field_h,
1639 DrawCommand::FormField {
1640 field_type: FormFieldType::Dropdown {
1641 options: options.clone(),
1642 value: value.clone(),
1643 read_only: *read_only,
1644 font_size: *font_size,
1645 },
1646 name: name.clone(),
1647 },
1648 "Dropdown",
1649 );
1650 }
1651
1652 NodeKind::RadioButton {
1653 name,
1654 value,
1655 checked,
1656 width: field_w,
1657 height: field_h,
1658 read_only,
1659 } => {
1660 self.layout_form_field(
1661 node,
1662 &style,
1663 cursor,
1664 pages,
1665 x,
1666 *field_w,
1667 *field_h,
1668 DrawCommand::FormField {
1669 field_type: FormFieldType::RadioButton {
1670 value: value.clone(),
1671 checked: *checked,
1672 read_only: *read_only,
1673 },
1674 name: name.clone(),
1675 },
1676 "RadioButton",
1677 );
1678 }
1679
1680 NodeKind::Text {
1681 content,
1682 href,
1683 runs,
1684 } => {
1685 self.layout_text(
1686 content,
1687 href.as_deref(),
1688 runs,
1689 &style,
1690 cursor,
1691 pages,
1692 x,
1693 available_width,
1694 font_context,
1695 node.source_location.as_ref(),
1696 node.bookmark.as_deref(),
1697 None,
1698 );
1699 }
1700
1701 NodeKind::Heading {
1702 content,
1703 href,
1704 runs,
1705 ..
1706 } => {
1707 let heading_role = node_kind_name(&node.kind); self.layout_text(
1713 content,
1714 href.as_deref(),
1715 runs,
1716 &style,
1717 cursor,
1718 pages,
1719 x,
1720 available_width,
1721 font_context,
1722 node.source_location.as_ref(),
1723 node.bookmark.as_deref(),
1724 Some(heading_role),
1725 );
1726 }
1727
1728 NodeKind::List {
1729 ordered,
1730 marker_type,
1731 start,
1732 } => {
1733 self.layout_list(
1734 node,
1735 *ordered,
1736 *marker_type,
1737 *start,
1738 &style,
1739 cursor,
1740 pages,
1741 x,
1742 available_width,
1743 font_context,
1744 );
1745 }
1746
1747 NodeKind::ListItem => {
1748 self.layout_view(
1753 node,
1754 &style,
1755 cursor,
1756 pages,
1757 x,
1758 available_width,
1759 font_context,
1760 );
1761 }
1762
1763 NodeKind::Image { width, height, .. } => {
1764 self.layout_image(
1765 node,
1766 &style,
1767 cursor,
1768 pages,
1769 x,
1770 available_width,
1771 *width,
1772 *height,
1773 );
1774 }
1775
1776 NodeKind::Table { columns } => {
1777 self.layout_table(
1778 node,
1779 &style,
1780 columns,
1781 cursor,
1782 pages,
1783 x,
1784 available_width,
1785 font_context,
1786 );
1787 }
1788
1789 NodeKind::View | NodeKind::Page { .. } => {
1790 self.layout_view(
1791 node,
1792 &style,
1793 cursor,
1794 pages,
1795 x,
1796 available_width,
1797 font_context,
1798 );
1799 }
1800
1801 NodeKind::TableRow { .. } | NodeKind::TableCell { .. } => {
1802 self.layout_view(
1803 node,
1804 &style,
1805 cursor,
1806 pages,
1807 x,
1808 available_width,
1809 font_context,
1810 );
1811 }
1812
1813 NodeKind::Svg {
1814 width: svg_w,
1815 height: svg_h,
1816 view_box,
1817 content,
1818 } => {
1819 self.layout_svg(
1820 node,
1821 &style,
1822 cursor,
1823 pages,
1824 x,
1825 available_width,
1826 *svg_w,
1827 *svg_h,
1828 view_box.as_deref(),
1829 content,
1830 );
1831 }
1832
1833 NodeKind::Barcode {
1834 data,
1835 format,
1836 width: explicit_width,
1837 height: bar_height,
1838 } => {
1839 self.layout_barcode(
1840 node,
1841 &style,
1842 cursor,
1843 pages,
1844 x,
1845 available_width,
1846 data,
1847 *format,
1848 *explicit_width,
1849 *bar_height,
1850 );
1851 }
1852
1853 NodeKind::QrCode {
1854 data,
1855 size: explicit_size,
1856 } => {
1857 self.layout_qrcode(
1858 node,
1859 &style,
1860 cursor,
1861 pages,
1862 x,
1863 available_width,
1864 data,
1865 *explicit_size,
1866 );
1867 }
1868
1869 NodeKind::Canvas {
1870 width: canvas_w,
1871 height: canvas_h,
1872 operations,
1873 } => {
1874 self.layout_canvas(
1875 node,
1876 &style,
1877 cursor,
1878 pages,
1879 x,
1880 available_width,
1881 *canvas_w,
1882 *canvas_h,
1883 operations,
1884 );
1885 }
1886
1887 NodeKind::BarChart {
1888 data,
1889 width: chart_w,
1890 height: chart_h,
1891 color,
1892 show_labels,
1893 show_values,
1894 show_grid,
1895 title,
1896 } => {
1897 let config = crate::chart::bar::BarChartConfig {
1898 color: color.clone(),
1899 show_labels: *show_labels,
1900 show_values: *show_values,
1901 show_grid: *show_grid,
1902 title: title.clone(),
1903 };
1904 let primitives = crate::chart::bar::build(*chart_w, *chart_h, data, &config);
1905 self.layout_chart(
1906 node, &style, cursor, pages, x, *chart_w, *chart_h, primitives, "BarChart",
1907 );
1908 }
1909
1910 NodeKind::LineChart {
1911 series,
1912 labels,
1913 width: chart_w,
1914 height: chart_h,
1915 show_points,
1916 show_grid,
1917 title,
1918 } => {
1919 let config = crate::chart::line::LineChartConfig {
1920 show_points: *show_points,
1921 show_grid: *show_grid,
1922 title: title.clone(),
1923 };
1924 let primitives =
1925 crate::chart::line::build(*chart_w, *chart_h, series, labels, &config);
1926 self.layout_chart(
1927 node,
1928 &style,
1929 cursor,
1930 pages,
1931 x,
1932 *chart_w,
1933 *chart_h,
1934 primitives,
1935 "LineChart",
1936 );
1937 }
1938
1939 NodeKind::PieChart {
1940 data,
1941 width: chart_w,
1942 height: chart_h,
1943 donut,
1944 show_legend,
1945 title,
1946 } => {
1947 let config = crate::chart::pie::PieChartConfig {
1948 donut: *donut,
1949 show_legend: *show_legend,
1950 title: title.clone(),
1951 };
1952 let primitives = crate::chart::pie::build(*chart_w, *chart_h, data, &config);
1953 self.layout_chart(
1954 node, &style, cursor, pages, x, *chart_w, *chart_h, primitives, "PieChart",
1955 );
1956 }
1957
1958 NodeKind::AreaChart {
1959 series,
1960 labels,
1961 width: chart_w,
1962 height: chart_h,
1963 show_grid,
1964 title,
1965 } => {
1966 let config = crate::chart::area::AreaChartConfig {
1967 show_grid: *show_grid,
1968 title: title.clone(),
1969 };
1970 let primitives =
1971 crate::chart::area::build(*chart_w, *chart_h, series, labels, &config);
1972 self.layout_chart(
1973 node,
1974 &style,
1975 cursor,
1976 pages,
1977 x,
1978 *chart_w,
1979 *chart_h,
1980 primitives,
1981 "AreaChart",
1982 );
1983 }
1984
1985 NodeKind::DotPlot {
1986 groups,
1987 width: chart_w,
1988 height: chart_h,
1989 x_min,
1990 x_max,
1991 y_min,
1992 y_max,
1993 x_label,
1994 y_label,
1995 show_legend,
1996 dot_size,
1997 } => {
1998 let config = crate::chart::dot::DotPlotConfig {
1999 x_min: *x_min,
2000 x_max: *x_max,
2001 y_min: *y_min,
2002 y_max: *y_max,
2003 x_label: x_label.clone(),
2004 y_label: y_label.clone(),
2005 show_legend: *show_legend,
2006 dot_size: *dot_size,
2007 };
2008 let primitives = crate::chart::dot::build(*chart_w, *chart_h, groups, &config);
2009 self.layout_chart(
2010 node, &style, cursor, pages, x, *chart_w, *chart_h, primitives, "DotPlot",
2011 );
2012 }
2013 }
2014
2015 if matches!(style.position, Position::Relative)
2023 && (style.top.is_some()
2024 || style.left.is_some()
2025 || style.right.is_some()
2026 || style.bottom.is_some())
2027 {
2028 let dx = style.left.unwrap_or(0.0) - style.right.unwrap_or(0.0);
2029 let dy = style.top.unwrap_or(0.0) - style.bottom.unwrap_or(0.0);
2030 for el in &mut cursor.elements[elem_start..] {
2031 if dx != 0.0 {
2032 offset_element_x(el, dx);
2033 }
2034 if dy != 0.0 {
2035 offset_element_y(el, dy);
2036 }
2037 }
2038 }
2039 }
2040
2041 #[allow(clippy::too_many_arguments)]
2042 fn layout_view(
2043 &self,
2044 node: &Node,
2045 style: &ResolvedStyle,
2046 cursor: &mut PageCursor,
2047 pages: &mut Vec<LayoutPage>,
2048 x: f64,
2049 available_width: f64,
2050 font_context: &FontContext,
2051 ) {
2052 let padding = &style.padding;
2053 let margin = &style.margin.to_edges();
2054 let border = &style.border_width;
2055
2056 let outer_width = match style.width {
2057 SizeConstraint::Fixed(w) => w,
2058 SizeConstraint::Auto => available_width - margin.horizontal(),
2059 }
2060 .min(style.max_width)
2062 .max(style.min_width);
2063 let inner_width = outer_width - padding.horizontal() - border.horizontal();
2064
2065 let children_height =
2066 self.measure_children_height(&node.children, inner_width, style, font_context);
2067 let total_height = match style.height {
2068 SizeConstraint::Fixed(h) => h,
2069 SizeConstraint::Auto => children_height + padding.vertical() + border.vertical(),
2070 }
2071 .max(style.min_height);
2072
2073 let node_x = x + margin.left;
2074
2075 let fits = total_height <= cursor.remaining_height() - margin.vertical();
2076
2077 if fits || !style.breakable {
2078 if !fits && !style.breakable {
2079 pages.push(cursor.finalize());
2080 *cursor = cursor.new_page();
2081 }
2082
2083 let rect_y = cursor.content_y + cursor.y + margin.top;
2085 let snapshot = cursor.elements.len();
2086
2087 if let Some(marker) = bookmark_marker(node, node_x, rect_y) {
2092 cursor.elements.push(marker);
2093 }
2094
2095 let saved_y = cursor.y;
2096 cursor.y += margin.top + padding.top + border.top;
2097
2098 let children_x = node_x + padding.left + border.left;
2099 let is_grid =
2100 matches!(style.display, Display::Grid) && style.grid_template_columns.is_some();
2101 if is_grid {
2102 self.layout_grid_children(
2103 &node.children,
2104 style,
2105 cursor,
2106 pages,
2107 children_x,
2108 inner_width,
2109 font_context,
2110 );
2111 } else {
2112 self.layout_children(
2113 &node.children,
2114 &node.style,
2115 cursor,
2116 pages,
2117 children_x,
2118 inner_width,
2119 Some(style),
2120 font_context,
2121 );
2122 }
2123
2124 let child_elements: Vec<LayoutElement> = cursor.elements.drain(snapshot..).collect();
2126
2127 let rect_element = LayoutElement {
2128 x: node_x,
2129 y: rect_y,
2130 width: outer_width,
2131 height: total_height,
2132 draw: DrawCommand::Rect {
2133 background: style.background_color,
2134 border_width: style.border_width,
2135 border_color: style.border_color,
2136 border_style: style.border_style,
2137 border_radius: style.border_radius,
2138 opacity: 1.0,
2139 box_shadow: style.box_shadow.map(Box::new),
2140 background_gradient: style.background.clone().map(Box::new),
2141 },
2142 children: child_elements,
2143 node_type: Some(node_kind_name(&node.kind).to_string()),
2144 resolved_style: Some(style.clone()),
2145 source_location: node.source_location.clone(),
2146 href: node.href.clone(),
2147 bookmark: None,
2150 alt: None,
2151 is_header_row: false,
2152 col_span: 1,
2153 overflow: style.overflow,
2154 opacity: style.opacity,
2155 };
2156 cursor.elements.push(rect_element);
2157
2158 cursor.y = saved_y + total_height + margin.vertical();
2159 } else {
2160 self.layout_breakable_view(
2161 node,
2162 style,
2163 cursor,
2164 pages,
2165 node_x,
2166 outer_width,
2167 inner_width,
2168 font_context,
2169 );
2170 }
2171 }
2172
2173 #[allow(clippy::too_many_arguments)]
2174 fn layout_breakable_view(
2175 &self,
2176 node: &Node,
2177 style: &ResolvedStyle,
2178 cursor: &mut PageCursor,
2179 pages: &mut Vec<LayoutPage>,
2180 node_x: f64,
2181 outer_width: f64,
2182 inner_width: f64,
2183 font_context: &FontContext,
2184 ) {
2185 let padding = &style.padding;
2186 let border = &style.border_width;
2187 let margin = &style.margin.to_edges();
2188
2189 let initial_page_count = pages.len();
2191 let snapshot = cursor.elements.len();
2192 let rect_start_y = cursor.content_y + cursor.y + margin.top;
2193
2194 if let Some(marker) = bookmark_marker(node, node_x, rect_start_y) {
2202 cursor.elements.push(marker);
2203 }
2204
2205 cursor.y += margin.top + padding.top + border.top;
2206 let prev_continuation_offset = cursor.continuation_top_offset;
2207 cursor.continuation_top_offset = padding.top + border.top;
2208
2209 let children_x = node_x + padding.left + border.left;
2210 let is_grid =
2211 matches!(style.display, Display::Grid) && style.grid_template_columns.is_some();
2212 if is_grid {
2213 self.layout_grid_children(
2214 &node.children,
2215 style,
2216 cursor,
2217 pages,
2218 children_x,
2219 inner_width,
2220 font_context,
2221 );
2222 } else {
2223 self.layout_children(
2224 &node.children,
2225 &node.style,
2226 cursor,
2227 pages,
2228 children_x,
2229 inner_width,
2230 Some(style),
2231 font_context,
2232 );
2233 }
2234
2235 cursor.continuation_top_offset = prev_continuation_offset;
2236
2237 let has_visual = style.background_color.is_some()
2239 || style.border_width.top > 0.0
2240 || style.border_width.right > 0.0
2241 || style.border_width.bottom > 0.0
2242 || style.border_width.left > 0.0;
2243 let needs_wrapper = has_visual || style.flex_grow > 0.0;
2245
2246 if !needs_wrapper {
2247 cursor.y += padding.bottom + border.bottom + margin.bottom;
2249 return;
2250 }
2251
2252 let draw_cmd = DrawCommand::Rect {
2253 background: style.background_color,
2254 border_width: style.border_width,
2255 border_color: style.border_color,
2256 border_style: style.border_style,
2257 border_radius: style.border_radius,
2258 opacity: 1.0,
2259 box_shadow: style.box_shadow.map(Box::new),
2260 background_gradient: style.background.clone().map(Box::new),
2261 };
2262
2263 if pages.len() == initial_page_count {
2264 let child_elements: Vec<LayoutElement> = cursor.elements.drain(snapshot..).collect();
2266 let rect_height =
2267 cursor.content_y + cursor.y + padding.bottom + border.bottom - rect_start_y;
2268 cursor.elements.push(LayoutElement {
2269 x: node_x,
2270 y: rect_start_y,
2271 width: outer_width,
2272 height: rect_height,
2273 draw: draw_cmd,
2274 children: child_elements,
2275 node_type: Some(node_kind_name(&node.kind).to_string()),
2276 resolved_style: Some(style.clone()),
2277 source_location: node.source_location.clone(),
2278 href: node.href.clone(),
2279 bookmark: None,
2284 alt: None,
2285 is_header_row: false,
2286 col_span: 1,
2287 overflow: style.overflow,
2288 opacity: style.opacity,
2289 });
2290 } else {
2291 let page = &mut pages[initial_page_count];
2295 let footer_h: f64 = page.fixed_footer.iter().map(|(_, h)| *h).sum();
2296 let page_content_bottom =
2297 page.config.margin.top + (page.height - page.config.margin.vertical()) - footer_h;
2298 let our_elements: Vec<LayoutElement> = page.elements.drain(snapshot..).collect();
2299 if !our_elements.is_empty() {
2300 let rect_height = page_content_bottom - rect_start_y;
2301 page.elements.push(LayoutElement {
2302 x: node_x,
2303 y: rect_start_y,
2304 width: outer_width,
2305 height: rect_height,
2306 draw: draw_cmd.clone(),
2307 children: our_elements,
2308 node_type: Some(node_kind_name(&node.kind).to_string()),
2309 resolved_style: Some(style.clone()),
2310 source_location: node.source_location.clone(),
2311 href: node.href.clone(),
2312 bookmark: None,
2314 alt: None,
2315 is_header_row: false,
2316 col_span: 1,
2317 overflow: Overflow::default(),
2318 opacity: 1.0,
2319 });
2320 }
2321
2322 for page in &mut pages[initial_page_count + 1..] {
2324 let header_h: f64 = page.fixed_header.iter().map(|(_, h)| *h).sum();
2325 let content_top = page.config.margin.top + header_h;
2326 let footer_h: f64 = page.fixed_footer.iter().map(|(_, h)| *h).sum();
2327 let content_bottom = page.config.margin.top
2328 + (page.height - page.config.margin.vertical())
2329 - footer_h;
2330 let all_elements: Vec<LayoutElement> = std::mem::take(&mut page.elements);
2331 if !all_elements.is_empty() {
2332 page.elements.push(LayoutElement {
2333 x: node_x,
2334 y: content_top,
2335 width: outer_width,
2336 height: content_bottom - content_top,
2337 draw: draw_cmd.clone(),
2338 children: all_elements,
2339 node_type: Some(node_kind_name(&node.kind).to_string()),
2340 resolved_style: Some(style.clone()),
2341 source_location: node.source_location.clone(),
2342 href: None,
2343 bookmark: None,
2344 alt: None,
2345 is_header_row: false,
2346 col_span: 1,
2347 overflow: Overflow::default(),
2348 opacity: 1.0,
2349 });
2350 }
2351 }
2352
2353 let all_elements: Vec<LayoutElement> = std::mem::take(&mut cursor.elements);
2355 if !all_elements.is_empty() {
2356 let header_h: f64 = cursor.fixed_header.iter().map(|(_, h)| *h).sum();
2357 let content_top = cursor.content_y + header_h;
2358 let rect_height =
2359 cursor.content_y + cursor.y + padding.bottom + border.bottom - content_top;
2360 cursor.elements.push(LayoutElement {
2361 x: node_x,
2362 y: content_top,
2363 width: outer_width,
2364 height: rect_height,
2365 draw: draw_cmd,
2366 children: all_elements,
2367 node_type: Some(node_kind_name(&node.kind).to_string()),
2368 resolved_style: Some(style.clone()),
2369 source_location: node.source_location.clone(),
2370 href: None,
2371 bookmark: None,
2372 alt: None,
2373 is_header_row: false,
2374 col_span: 1,
2375 overflow: Overflow::default(),
2376 opacity: 1.0,
2377 });
2378 }
2379 }
2380
2381 cursor.y += padding.bottom + border.bottom + margin.bottom;
2382 }
2383
2384 #[allow(clippy::too_many_arguments)]
2385 fn layout_children(
2386 &self,
2387 children: &[Node],
2388 _parent_raw_style: &Style,
2389 cursor: &mut PageCursor,
2390 pages: &mut Vec<LayoutPage>,
2391 content_x: f64,
2392 available_width: f64,
2393 parent_style: Option<&ResolvedStyle>,
2394 font_context: &FontContext,
2395 ) {
2396 let parent_box_y = cursor.content_y + cursor.y;
2398 let parent_box_x = content_x;
2399
2400 let parent_positioned = parent_style.map(|s| s.positioned).unwrap_or(false);
2406 let saved_cb = cursor.containing_block;
2407 if parent_positioned {
2408 let cb_height = parent_style
2409 .and_then(|ps| match ps.height {
2410 SizeConstraint::Fixed(h) => {
2411 Some(h - ps.padding.vertical() - ps.border_width.vertical())
2412 }
2413 SizeConstraint::Auto => None,
2414 })
2415 .unwrap_or(saved_cb.3);
2416 cursor.containing_block = (parent_box_x, parent_box_y, available_width, cb_height);
2417 }
2418
2419 let (flow_children, abs_children): (Vec<&Node>, Vec<&Node>) = children
2421 .iter()
2422 .partition(|child| !matches!(child.style.position, Some(Position::Absolute)));
2423
2424 let direction = parent_style
2425 .map(|s| s.flex_direction)
2426 .unwrap_or(FlexDirection::Column);
2427
2428 let row_gap = parent_style.map(|s| s.row_gap).unwrap_or(0.0);
2429 let column_gap = parent_style.map(|s| s.column_gap).unwrap_or(0.0);
2430
2431 match direction {
2433 FlexDirection::Column | FlexDirection::ColumnReverse => {
2434 let items: Vec<&Node> = if matches!(direction, FlexDirection::ColumnReverse) {
2435 flow_children.into_iter().rev().collect()
2436 } else {
2437 flow_children
2438 };
2439
2440 let justify = parent_style
2441 .map(|s| s.justify_content)
2442 .unwrap_or(JustifyContent::FlexStart);
2443 let align = parent_style
2444 .map(|s| s.align_items)
2445 .unwrap_or(AlignItems::Stretch);
2446
2447 let start_y = cursor.y;
2448 let initial_pages = pages.len();
2449
2450 let mut child_ranges: Vec<(usize, usize)> = Vec::new();
2452
2453 for (i, child) in items.iter().enumerate() {
2454 if i > 0 {
2455 cursor.y += row_gap;
2456 }
2457 let child_start = cursor.elements.len();
2458
2459 let child_margin = &child.style.resolve(parent_style, available_width).margin;
2462 let has_auto_h = child_margin.has_auto_horizontal();
2463
2464 let (child_x, layout_w) = if has_auto_h {
2471 let child_style = child.style.resolve(parent_style, available_width);
2472 let has_explicit_width =
2473 matches!(child_style.width, SizeConstraint::Fixed(_));
2474 let intrinsic = self
2475 .measure_intrinsic_width(child, &child_style, font_context)
2476 .min(available_width);
2477 let w = match child_style.width {
2478 SizeConstraint::Fixed(fw) => fw,
2479 SizeConstraint::Auto if child_style.max_width.is_finite() => {
2484 (available_width - child_margin.horizontal())
2485 .min(child_style.max_width)
2486 }
2487 SizeConstraint::Auto => intrinsic,
2488 }
2489 .min(child_style.max_width)
2490 .max(child_style.min_width);
2491 let lw = if has_explicit_width {
2492 available_width
2493 } else {
2494 w
2495 };
2496 let fixed_h = child_margin.horizontal();
2497 let slack = (available_width - w - fixed_h).max(0.0);
2498 let auto_left = child_margin.left.is_auto();
2499 let auto_right = child_margin.right.is_auto();
2500 let ml = match (auto_left, auto_right) {
2501 (true, true) => slack / 2.0,
2502 (true, false) => slack,
2503 (false, true) => 0.0,
2504 (false, false) => 0.0,
2505 };
2506 (content_x + child_margin.left.resolve() + ml, lw)
2507 } else if !matches!(align, AlignItems::Stretch | AlignItems::FlexStart) {
2508 let child_style = child.style.resolve(parent_style, available_width);
2509 let has_explicit_width =
2510 matches!(child_style.width, SizeConstraint::Fixed(_));
2511 let intrinsic = self
2512 .measure_intrinsic_width(child, &child_style, font_context)
2513 .min(available_width);
2514 let w = match child_style.width {
2515 SizeConstraint::Fixed(fw) => fw,
2516 SizeConstraint::Auto => intrinsic,
2517 };
2518 let lw = if has_explicit_width {
2519 available_width
2520 } else {
2521 w
2522 };
2523 match align {
2524 AlignItems::Center => (content_x + (available_width - w) / 2.0, lw),
2525 AlignItems::FlexEnd => (content_x + available_width - w, lw),
2526 _ => (content_x, available_width),
2527 }
2528 } else {
2529 (content_x, available_width)
2530 };
2531
2532 self.layout_node(
2533 child,
2534 cursor,
2535 pages,
2536 child_x,
2537 layout_w,
2538 parent_style,
2539 font_context,
2540 None,
2541 None,
2542 );
2543
2544 child_ranges.push((child_start, cursor.elements.len()));
2545 }
2546
2547 let container_inner_h: Option<f64> = parent_style
2550 .and_then(|ps| match ps.height {
2551 SizeConstraint::Fixed(h) => {
2552 Some(h - ps.padding.vertical() - ps.border_width.vertical())
2553 }
2554 SizeConstraint::Auto => None,
2555 })
2556 .or_else(|| {
2557 if parent_style.is_none() {
2559 Some(cursor.content_height - start_y)
2560 } else {
2561 None
2562 }
2563 });
2564
2565 if let Some(inner_h) = container_inner_h {
2566 if pages.len() == initial_pages {
2567 let child_styles: Vec<ResolvedStyle> = items
2568 .iter()
2569 .map(|child| child.style.resolve(parent_style, available_width))
2570 .collect();
2571 let total_grow: f64 = child_styles.iter().map(|s| s.flex_grow).sum();
2572 if total_grow > 0.0 {
2573 let children_total = cursor.y - start_y;
2574 let slack = (inner_h - children_total).max(0.0);
2575 if slack > 0.0 {
2576 let mut cumulative_shift = 0.0_f64;
2577 for (i, cs) in child_styles.iter().enumerate() {
2578 let (start, end) = child_ranges[i];
2579 if cumulative_shift > 0.001 {
2580 for j in start..end {
2581 offset_element_y(
2582 &mut cursor.elements[j],
2583 cumulative_shift,
2584 );
2585 }
2586 }
2587 if cs.flex_grow > 0.0 {
2588 let extra = slack * (cs.flex_grow / total_grow);
2589 if start < end {
2591 let elem = &mut cursor.elements[end - 1];
2592 elem.height += extra;
2593 reapply_justify_content(elem);
2594 }
2595 cumulative_shift += extra;
2596 }
2597 }
2598 cursor.y += cumulative_shift;
2599 }
2600 }
2601 }
2602 }
2603
2604 if let Some(inner_h) = container_inner_h {
2611 if pages.len() == initial_pages {
2612 let auto_styles: Vec<ResolvedStyle> = items
2613 .iter()
2614 .map(|child| child.style.resolve(parent_style, available_width))
2615 .collect();
2616 let total_autos: usize = auto_styles
2617 .iter()
2618 .map(|s| {
2619 s.margin.top.is_auto() as usize + s.margin.bottom.is_auto() as usize
2620 })
2621 .sum();
2622 if total_autos > 0 {
2623 let children_total = cursor.y - start_y;
2624 let total_slack = (inner_h - children_total).max(0.0);
2625 if total_slack > 0.0 {
2626 let per_auto = total_slack / total_autos as f64;
2627 let mut cumulative_shift = 0.0_f64;
2628 for (i, cs) in auto_styles.iter().enumerate() {
2629 let (start, end) = child_ranges[i];
2630 let mt_auto = cs.margin.top.is_auto();
2631 let mb_auto = cs.margin.bottom.is_auto();
2632 let this_child_shift =
2636 cumulative_shift + if mt_auto { per_auto } else { 0.0 };
2637 if this_child_shift > 0.001 {
2638 for j in start..end {
2639 offset_element_y(
2640 &mut cursor.elements[j],
2641 this_child_shift,
2642 );
2643 }
2644 }
2645 cumulative_shift =
2648 this_child_shift + if mb_auto { per_auto } else { 0.0 };
2649 }
2650 cursor.y += cumulative_shift;
2651 }
2652 }
2653 }
2654 }
2655
2656 let needs_justify =
2658 !matches!(justify, JustifyContent::FlexStart) && pages.len() == initial_pages;
2659 if needs_justify {
2660 let justify_inner_h = container_inner_h.or_else(|| {
2662 parent_style.and_then(|ps| match ps.height {
2663 SizeConstraint::Fixed(h) => {
2664 Some(h - ps.padding.vertical() - ps.border_width.vertical())
2665 }
2666 SizeConstraint::Auto => None,
2667 })
2668 });
2669 if let Some(inner_h) = justify_inner_h {
2670 let children_total = cursor.y - start_y;
2671 let slack = inner_h - children_total;
2672 if slack > 0.0 {
2673 let n = child_ranges.len();
2674 let offsets: Vec<f64> = match justify {
2675 JustifyContent::FlexEnd => vec![slack; n],
2676 JustifyContent::Center => vec![slack / 2.0; n],
2677 JustifyContent::SpaceBetween => {
2678 if n <= 1 {
2679 vec![0.0; n]
2680 } else {
2681 let per_gap = slack / (n - 1) as f64;
2682 (0..n).map(|i| i as f64 * per_gap).collect()
2683 }
2684 }
2685 JustifyContent::SpaceAround => {
2686 let space = slack / n as f64;
2687 (0..n).map(|i| space / 2.0 + i as f64 * space).collect()
2688 }
2689 JustifyContent::SpaceEvenly => {
2690 let space = slack / (n + 1) as f64;
2691 (0..n).map(|i| (i + 1) as f64 * space).collect()
2692 }
2693 JustifyContent::FlexStart => vec![0.0; n],
2694 };
2695 for (i, &(start, end)) in child_ranges.iter().enumerate() {
2696 let dy = offsets[i];
2697 if dy.abs() > 0.001 {
2698 for j in start..end {
2699 offset_element_y(&mut cursor.elements[j], dy);
2700 }
2701 }
2702 }
2703 cursor.y += *offsets.last().unwrap_or(&0.0);
2704 }
2705 }
2706 }
2707 }
2708
2709 FlexDirection::Row | FlexDirection::RowReverse => {
2710 let flow_owned: Vec<Node> = flow_children.into_iter().cloned().collect();
2711 self.layout_flex_row(
2712 &flow_owned,
2713 cursor,
2714 pages,
2715 content_x,
2716 available_width,
2717 parent_style,
2718 column_gap,
2719 row_gap,
2720 font_context,
2721 );
2722 }
2723 }
2724
2725 let (cb_x, cb_y, cb_w, cb_h) = if parent_positioned {
2731 let ph = parent_style
2732 .and_then(|ps| match ps.height {
2733 SizeConstraint::Fixed(h) => {
2734 Some(h - ps.padding.vertical() - ps.border_width.vertical())
2735 }
2736 SizeConstraint::Auto => None,
2737 })
2738 .unwrap_or(cursor.content_y + cursor.y - parent_box_y);
2739 (parent_box_x, parent_box_y, available_width, ph)
2740 } else {
2741 cursor.containing_block
2742 };
2743
2744 for abs_child in &abs_children {
2746 let abs_style = abs_child.style.resolve(parent_style, cb_w);
2747
2748 let child_width = match abs_style.width {
2750 SizeConstraint::Fixed(w) => w,
2751 SizeConstraint::Auto => {
2752 if let (Some(l), Some(r)) = (abs_style.left, abs_style.right) {
2754 (cb_w - l - r).max(0.0)
2755 } else {
2756 self.measure_intrinsic_width(abs_child, &abs_style, font_context)
2757 }
2758 }
2759 };
2760
2761 let child_height = match abs_style.height {
2762 SizeConstraint::Fixed(h) => h,
2763 SizeConstraint::Auto => {
2764 self.measure_node_height(abs_child, child_width, &abs_style, font_context)
2765 }
2766 };
2767
2768 let abs_x = if let Some(l) = abs_style.left {
2770 cb_x + l
2771 } else if let Some(r) = abs_style.right {
2772 cb_x + cb_w - r - child_width
2773 } else {
2774 cb_x
2775 };
2776
2777 let abs_y = if let Some(t) = abs_style.top {
2778 cb_y + t
2779 } else if let Some(b) = abs_style.bottom {
2780 cb_y + cb_h - b - child_height
2781 } else {
2782 cb_y
2783 };
2784
2785 let mut abs_cursor = PageCursor::new(&cursor.config);
2787 abs_cursor.y = 0.0;
2788 abs_cursor.content_x = abs_x;
2789 abs_cursor.content_y = abs_y;
2790
2791 self.layout_node(
2792 abs_child,
2793 &mut abs_cursor,
2794 &mut Vec::new(),
2795 abs_x,
2796 child_width,
2797 parent_style,
2798 font_context,
2799 None,
2800 None,
2801 );
2802
2803 cursor.elements.extend(abs_cursor.elements);
2805 }
2806
2807 cursor.containing_block = saved_cb;
2809 }
2810
2811 #[allow(clippy::too_many_arguments)]
2812 fn layout_flex_row(
2813 &self,
2814 children: &[Node],
2815 cursor: &mut PageCursor,
2816 pages: &mut Vec<LayoutPage>,
2817 content_x: f64,
2818 available_width: f64,
2819 parent_style: Option<&ResolvedStyle>,
2820 column_gap: f64,
2821 row_gap: f64,
2822 font_context: &FontContext,
2823 ) {
2824 if children.is_empty() {
2825 return;
2826 }
2827
2828 let flex_wrap = parent_style
2829 .map(|s| s.flex_wrap)
2830 .unwrap_or(FlexWrap::NoWrap);
2831
2832 let items: Vec<FlexItem> = children
2835 .iter()
2836 .map(|child| {
2837 let style = child.style.resolve(parent_style, available_width);
2838 let base_width = match style.flex_basis {
2839 SizeConstraint::Fixed(w) => w,
2840 SizeConstraint::Auto => match style.width {
2841 SizeConstraint::Fixed(w) => w,
2842 SizeConstraint::Auto => {
2843 self.measure_intrinsic_width(child, &style, font_context)
2844 }
2845 },
2846 };
2847 let min_content_width = self.measure_min_content_width(child, &style, font_context);
2848 FlexItem {
2849 node: child,
2850 style,
2851 base_width,
2852 min_content_width,
2853 }
2854 })
2855 .collect();
2856
2857 let base_widths: Vec<f64> = items.iter().map(|i| i.base_width).collect();
2859 let lines = match flex_wrap {
2860 FlexWrap::NoWrap => {
2861 vec![flex::WrapLine {
2862 start: 0,
2863 end: items.len(),
2864 }]
2865 }
2866 FlexWrap::Wrap => flex::partition_into_lines(&base_widths, column_gap, available_width),
2867 FlexWrap::WrapReverse => {
2868 let mut l = flex::partition_into_lines(&base_widths, column_gap, available_width);
2869 l.reverse();
2870 l
2871 }
2872 };
2873
2874 if lines.is_empty() {
2875 return;
2876 }
2877
2878 let justify = parent_style.map(|s| s.justify_content).unwrap_or_default();
2880
2881 let mut final_widths: Vec<f64> = items.iter().map(|i| i.base_width).collect();
2883
2884 let initial_pages_count = pages.len();
2885 let flex_start_y = cursor.y;
2886 let mut line_infos: Vec<(usize, usize, f64)> = Vec::new();
2887
2888 for (line_idx, line) in lines.iter().enumerate() {
2889 let line_items = &items[line.start..line.end];
2890 let line_count = line.end - line.start;
2891 let line_gap = column_gap * (line_count as f64 - 1.0).max(0.0);
2892 let distributable = available_width - line_gap;
2893
2894 let total_base: f64 = line_items.iter().map(|i| i.base_width).sum();
2896 let remaining = distributable - total_base;
2897
2898 if remaining > 0.0 {
2899 let total_grow: f64 = line_items.iter().map(|i| i.style.flex_grow).sum();
2900 if total_grow > 0.0 {
2901 for (j, item) in line_items.iter().enumerate() {
2902 final_widths[line.start + j] =
2903 item.base_width + remaining * (item.style.flex_grow / total_grow);
2904 }
2905 }
2906 } else if remaining < 0.0 {
2907 let total_shrink: f64 = line_items
2908 .iter()
2909 .map(|i| i.style.flex_shrink * i.base_width)
2910 .sum();
2911 if total_shrink > 0.0 {
2912 for (j, item) in line_items.iter().enumerate() {
2913 let factor = (item.style.flex_shrink * item.base_width) / total_shrink;
2914 let w = item.base_width + remaining * factor;
2915 let floor = item.style.min_width.max(item.min_content_width);
2916 final_widths[line.start + j] = w.max(floor);
2917 }
2918 }
2919 }
2920
2921 let line_height: f64 = line_items
2923 .iter()
2924 .enumerate()
2925 .map(|(j, item)| {
2926 let fw = final_widths[line.start + j];
2927 self.measure_node_height(item.node, fw, &item.style, font_context)
2928 + item.style.margin.vertical()
2929 })
2930 .fold(0.0f64, f64::max);
2931
2932 if line_height > cursor.remaining_height() && cursor.y > 0.0 {
2939 pages.push(cursor.finalize());
2940 *cursor = cursor.new_page();
2941 }
2942
2943 if line_idx > 0 {
2945 cursor.y += row_gap;
2946 }
2947
2948 let row_start_y = cursor.y;
2949
2950 let actual_total: f64 = (line.start..line.end).map(|i| final_widths[i]).sum();
2952 let slack = available_width - actual_total - line_gap;
2953
2954 let (start_offset, between_extra) = match justify {
2955 JustifyContent::FlexStart => (0.0, 0.0),
2956 JustifyContent::FlexEnd => (slack, 0.0),
2957 JustifyContent::Center => (slack / 2.0, 0.0),
2958 JustifyContent::SpaceBetween => {
2959 if line_count > 1 {
2960 (0.0, slack / (line_count as f64 - 1.0))
2961 } else {
2962 (0.0, 0.0)
2963 }
2964 }
2965 JustifyContent::SpaceAround => {
2966 let s = slack / line_count as f64;
2967 (s / 2.0, s)
2968 }
2969 JustifyContent::SpaceEvenly => {
2970 let s = slack / (line_count as f64 + 1.0);
2971 (s, s)
2972 }
2973 };
2974
2975 let line_elem_start = cursor.elements.len();
2976 let mut x = content_x + start_offset;
2977
2978 for (j, item) in line_items.iter().enumerate() {
2979 if j > 0 {
2980 x += column_gap + between_extra;
2981 }
2982
2983 let fw = final_widths[line.start + j];
2984
2985 let align = item
2986 .style
2987 .align_self
2988 .unwrap_or(parent_style.map(|s| s.align_items).unwrap_or_default());
2989
2990 let item_height =
2991 self.measure_node_height(item.node, fw, &item.style, font_context);
2992
2993 let has_auto_v = item.style.margin.has_auto_vertical();
2995 let y_offset = if has_auto_v {
2996 let fixed_v = item.style.margin.vertical();
2997 let slack = (line_height - item_height - fixed_v).max(0.0);
2998 let auto_top = item.style.margin.top.is_auto();
2999 let auto_bottom = item.style.margin.bottom.is_auto();
3000 match (auto_top, auto_bottom) {
3001 (true, true) => slack / 2.0,
3002 (true, false) => slack,
3003 (false, true) => 0.0,
3004 (false, false) => 0.0,
3005 }
3006 } else {
3007 match align {
3008 AlignItems::FlexStart => 0.0,
3009 AlignItems::FlexEnd => {
3010 line_height - item_height - item.style.margin.vertical()
3011 }
3012 AlignItems::Center => {
3013 (line_height - item_height - item.style.margin.vertical()) / 2.0
3014 }
3015 AlignItems::Stretch => 0.0,
3016 AlignItems::Baseline => 0.0,
3017 }
3018 };
3019
3020 let cross_h = if matches!(align, AlignItems::Stretch)
3024 && matches!(item.style.height, SizeConstraint::Auto)
3025 && !has_auto_v
3026 {
3027 let stretch_h = line_height - item.style.margin.vertical();
3028 if stretch_h > item_height {
3029 Some(stretch_h)
3030 } else {
3031 None
3032 }
3033 } else {
3034 None
3035 };
3036
3037 let saved_y = cursor.y;
3038 cursor.y = row_start_y + y_offset;
3039
3040 self.layout_node(
3041 item.node,
3042 cursor,
3043 pages,
3044 x,
3045 available_width,
3046 parent_style,
3047 font_context,
3048 cross_h,
3049 Some(fw),
3050 );
3051
3052 cursor.y = saved_y;
3053 x += fw;
3054 }
3055
3056 cursor.y = row_start_y + line_height;
3057 line_infos.push((line_elem_start, cursor.elements.len(), line_height));
3058 }
3059
3060 if pages.len() == initial_pages_count && !line_infos.is_empty() {
3062 let align_content = parent_style.map(|s| s.align_content).unwrap_or_default();
3063 if !matches!(align_content, AlignContent::FlexStart)
3064 && !matches!(flex_wrap, FlexWrap::NoWrap)
3065 {
3066 if let Some(parent) = parent_style {
3067 if let SizeConstraint::Fixed(container_h) = parent.height {
3068 let inner_h = container_h
3069 - parent.padding.vertical()
3070 - parent.border_width.vertical();
3071 let total_used = cursor.y - flex_start_y;
3072 let slack = inner_h - total_used;
3073 if slack > 0.0 {
3074 let n = line_infos.len();
3075 let offsets: Vec<f64> = match align_content {
3076 AlignContent::FlexEnd => vec![slack; n],
3077 AlignContent::Center => vec![slack / 2.0; n],
3078 AlignContent::SpaceBetween => {
3079 if n <= 1 {
3080 vec![0.0; n]
3081 } else {
3082 let per_gap = slack / (n - 1) as f64;
3083 (0..n).map(|i| i as f64 * per_gap).collect()
3084 }
3085 }
3086 AlignContent::SpaceAround => {
3087 let space = slack / n as f64;
3088 (0..n).map(|i| space / 2.0 + i as f64 * space).collect()
3089 }
3090 AlignContent::SpaceEvenly => {
3091 let space = slack / (n + 1) as f64;
3092 (0..n).map(|i| (i + 1) as f64 * space).collect()
3093 }
3094 AlignContent::Stretch => {
3095 let extra = slack / n as f64;
3096 (0..n).map(|i| i as f64 * extra).collect()
3097 }
3098 AlignContent::FlexStart => vec![0.0; n],
3099 };
3100 for (i, &(start, end, _)) in line_infos.iter().enumerate() {
3101 let dy = offsets[i];
3102 if dy.abs() > 0.001 {
3103 for j in start..end {
3104 offset_element_y(&mut cursor.elements[j], dy);
3105 }
3106 }
3107 }
3108 cursor.y += *offsets.last().unwrap_or(&0.0);
3109 }
3110 }
3111 }
3112 }
3113 }
3114 }
3115
3116 #[allow(clippy::too_many_arguments)]
3119 fn layout_list(
3120 &self,
3121 node: &Node,
3122 ordered: bool,
3123 marker_type: ListMarkerType,
3124 start: u32,
3125 style: &ResolvedStyle,
3126 cursor: &mut PageCursor,
3127 pages: &mut Vec<LayoutPage>,
3128 x: f64,
3129 available_width: f64,
3130 font_context: &FontContext,
3131 ) {
3132 let margin = &style.margin.to_edges();
3133 let padding = &style.padding;
3134
3135 cursor.y += margin.top;
3136
3137 let list_x = x + margin.left;
3138 let outer_width = available_width - margin.horizontal();
3139 let inner_width = outer_width - padding.horizontal();
3140
3141 let n_items = node
3144 .children
3145 .iter()
3146 .filter(|c| matches!(c.kind, NodeKind::ListItem))
3147 .count() as u32;
3148
3149 let marker_gutter =
3150 compute_marker_gutter_width(ordered, marker_type, start, n_items, style);
3151
3152 let list_inner_x = list_x + padding.left;
3153 let content_x = list_inner_x + marker_gutter;
3154 let content_width = (inner_width - marker_gutter).max(0.0);
3155
3156 let snapshot = cursor.elements.len();
3159 let list_start_y = cursor.content_y + cursor.y;
3160 cursor.y += padding.top;
3161
3162 let mut item_index: u32 = 0;
3163 for child in &node.children {
3164 if !matches!(child.kind, NodeKind::ListItem) {
3165 continue;
3166 }
3167 let marker_idx = start + item_index;
3168 self.layout_list_item(
3169 child,
3170 marker_idx,
3171 ordered,
3172 marker_type,
3173 marker_gutter,
3174 style,
3175 cursor,
3176 pages,
3177 list_inner_x,
3178 content_x,
3179 content_width,
3180 font_context,
3181 );
3182 item_index += 1;
3183 }
3184
3185 cursor.y += padding.bottom;
3186
3187 let item_elements: Vec<LayoutElement> = cursor.elements.drain(snapshot..).collect();
3189 let list_height = cursor.content_y + cursor.y - list_start_y;
3190 cursor.elements.push(LayoutElement {
3191 x: list_x,
3192 y: list_start_y,
3193 width: outer_width,
3194 height: list_height,
3195 draw: DrawCommand::None,
3196 children: item_elements,
3197 node_type: Some("List".to_string()),
3198 resolved_style: Some(style.clone()),
3199 source_location: node.source_location.clone(),
3200 href: None,
3201 bookmark: node.bookmark.clone(),
3202 alt: None,
3203 is_header_row: false,
3204 col_span: 1,
3205 overflow: style.overflow,
3206 opacity: style.opacity,
3207 });
3208
3209 cursor.y += margin.bottom;
3210 }
3211
3212 #[allow(clippy::too_many_arguments)]
3213 fn layout_list_item(
3214 &self,
3215 item: &Node,
3216 marker_idx: u32,
3217 ordered: bool,
3218 marker_type: ListMarkerType,
3219 marker_gutter: f64,
3220 parent_style: &ResolvedStyle,
3221 cursor: &mut PageCursor,
3222 pages: &mut Vec<LayoutPage>,
3223 list_inner_x: f64,
3224 content_x: f64,
3225 content_width: f64,
3226 font_context: &FontContext,
3227 ) {
3228 let item_style = item.style.resolve(Some(parent_style), content_width);
3229 let item_margin = item_style.margin.to_edges();
3230
3231 cursor.y += item_margin.top;
3232 let item_start_y = cursor.content_y + cursor.y;
3233 let item_snapshot = cursor.elements.len();
3234
3235 let marker_str = format_marker(marker_idx, ordered, marker_type);
3239 if !marker_str.is_empty() {
3240 let saved_y = cursor.y;
3241 self.layout_text(
3242 &marker_str,
3243 None,
3244 &[],
3245 &item_style,
3246 cursor,
3247 pages,
3248 list_inner_x,
3249 marker_gutter,
3250 font_context,
3251 None,
3252 None,
3253 Some("Lbl"),
3254 );
3255 cursor.y = saved_y;
3256 }
3257
3258 self.layout_children(
3264 &item.children,
3265 &item.style,
3266 cursor,
3267 pages,
3268 content_x,
3269 content_width,
3270 Some(&item_style),
3271 font_context,
3272 );
3273
3274 let item_children: Vec<LayoutElement> = cursor.elements.drain(item_snapshot..).collect();
3277 let item_height = cursor.content_y + cursor.y - item_start_y;
3278 let item_width = content_x + content_width - list_inner_x;
3279 cursor.elements.push(LayoutElement {
3280 x: list_inner_x,
3281 y: item_start_y,
3282 width: item_width,
3283 height: item_height,
3284 draw: DrawCommand::None,
3285 children: item_children,
3286 node_type: Some("ListItem".to_string()),
3287 resolved_style: Some(item_style.clone()),
3288 source_location: item.source_location.clone(),
3289 href: None,
3290 bookmark: item.bookmark.clone(),
3291 alt: None,
3292 is_header_row: false,
3293 col_span: 1,
3294 overflow: item_style.overflow,
3295 opacity: item_style.opacity,
3296 });
3297
3298 cursor.y += item_margin.bottom;
3299 }
3300
3301 #[allow(clippy::too_many_arguments)]
3302 fn layout_table(
3303 &self,
3304 node: &Node,
3305 style: &ResolvedStyle,
3306 column_defs: &[ColumnDef],
3307 cursor: &mut PageCursor,
3308 pages: &mut Vec<LayoutPage>,
3309 x: f64,
3310 available_width: f64,
3311 font_context: &FontContext,
3312 ) {
3313 let padding = &style.padding;
3314 let margin = &style.margin.to_edges();
3315 let border = &style.border_width;
3316
3317 let table_x = x + margin.left;
3318 let table_width = match style.width {
3319 SizeConstraint::Fixed(w) => w,
3320 SizeConstraint::Auto => available_width - margin.horizontal(),
3321 };
3322 let inner_width = table_width - padding.horizontal() - border.horizontal();
3323
3324 let col_widths = self.resolve_column_widths(
3325 column_defs,
3326 inner_width,
3327 &node.children,
3328 style,
3329 font_context,
3330 );
3331
3332 let mut header_rows: Vec<&Node> = Vec::new();
3333 let mut body_rows: Vec<&Node> = Vec::new();
3334
3335 for child in &node.children {
3336 match &child.kind {
3337 NodeKind::TableRow { is_header: true } => header_rows.push(child),
3338 _ => body_rows.push(child),
3339 }
3340 }
3341
3342 if !style.breakable {
3348 let total_height: f64 = node
3349 .children
3350 .iter()
3351 .map(|r| self.measure_table_row_height(r, &col_widths, style, font_context))
3352 .sum::<f64>()
3353 + padding.vertical()
3354 + border.vertical();
3355 let fresh_page_available = cursor.content_height
3356 - cursor.fixed_header.iter().map(|(_, h)| *h).sum::<f64>()
3357 - cursor.fixed_footer.iter().map(|(_, h)| *h).sum::<f64>();
3358 if total_height > cursor.remaining_height()
3359 && total_height <= fresh_page_available
3360 && cursor.y > 0.0
3361 {
3362 pages.push(cursor.finalize());
3363 *cursor = cursor.new_page();
3364 }
3365 }
3366
3367 let initial_page_count = pages.len();
3374 let snapshot = cursor.elements.len();
3375 let rect_start_y = cursor.content_y + cursor.y + margin.top;
3376
3377 cursor.y += margin.top + padding.top + border.top;
3378
3379 let cell_x_start = table_x + padding.left + border.left;
3380
3381 if !header_rows.is_empty() {
3406 let total_header_h: f64 = header_rows
3407 .iter()
3408 .map(|r| self.measure_table_row_height(r, &col_widths, style, font_context))
3409 .sum();
3410 let first_body_h = body_rows
3411 .first()
3412 .map(|r| self.measure_table_row_height(r, &col_widths, style, font_context))
3413 .unwrap_or(0.0);
3414
3415 let needed = total_header_h + first_body_h;
3416 let fresh_page_available = cursor.content_height
3417 - cursor.fixed_header.iter().map(|(_, h)| *h).sum::<f64>()
3418 - cursor.fixed_footer.iter().map(|(_, h)| *h).sum::<f64>();
3419
3420 if needed > cursor.remaining_height() && needed <= fresh_page_available {
3421 pages.push(cursor.finalize());
3422 *cursor = cursor.new_page();
3423 cursor.y += padding.top + border.top;
3424 }
3425 }
3426
3427 for header_row in &header_rows {
3428 self.layout_table_row(
3429 header_row,
3430 &col_widths,
3431 style,
3432 cursor,
3433 cell_x_start,
3434 font_context,
3435 pages,
3436 );
3437 }
3438
3439 for body_row in &body_rows {
3440 let row_height =
3441 self.measure_table_row_height(body_row, &col_widths, style, font_context);
3442
3443 if row_height > cursor.remaining_height() {
3444 pages.push(cursor.finalize());
3445 *cursor = cursor.new_page();
3446
3447 cursor.y += padding.top + border.top;
3448 for header_row in &header_rows {
3449 self.layout_table_row(
3450 header_row,
3451 &col_widths,
3452 style,
3453 cursor,
3454 cell_x_start,
3455 font_context,
3456 pages,
3457 );
3458 }
3459 }
3460
3461 self.layout_table_row(
3462 body_row,
3463 &col_widths,
3464 style,
3465 cursor,
3466 cell_x_start,
3467 font_context,
3468 pages,
3469 );
3470 }
3471
3472 let has_visual = style.background_color.is_some()
3476 || style.background.is_some()
3477 || style.border_width.top > 0.0
3478 || style.border_width.right > 0.0
3479 || style.border_width.bottom > 0.0
3480 || style.border_width.left > 0.0;
3481 let draw_cmd = if has_visual {
3482 DrawCommand::Rect {
3483 background: style.background_color,
3484 border_width: style.border_width,
3485 border_color: style.border_color,
3486 border_style: style.border_style,
3487 border_radius: style.border_radius,
3488 opacity: 1.0,
3489 box_shadow: style.box_shadow.map(Box::new),
3490 background_gradient: style.background.clone().map(Box::new),
3491 }
3492 } else {
3493 DrawCommand::None
3494 };
3495 let make_wrapper =
3496 |y: f64, height: f64, children: Vec<LayoutElement>, draw| LayoutElement {
3497 x: table_x,
3498 y,
3499 width: table_width,
3500 height,
3501 draw,
3502 children,
3503 node_type: Some(node_kind_name(&node.kind).to_string()),
3504 resolved_style: Some(style.clone()),
3505 source_location: node.source_location.clone(),
3506 href: node.href.clone(),
3507 bookmark: None,
3508 alt: None,
3509 is_header_row: false,
3510 col_span: 1,
3511 overflow: Overflow::default(),
3512 opacity: style.opacity,
3513 };
3514
3515 let table_bottom_y = cursor.content_y + cursor.y + padding.bottom + border.bottom;
3516
3517 if pages.len() == initial_page_count {
3518 let child_elements: Vec<LayoutElement> = cursor.elements.drain(snapshot..).collect();
3520 cursor.elements.push(make_wrapper(
3521 rect_start_y,
3522 table_bottom_y - rect_start_y,
3523 child_elements,
3524 draw_cmd,
3525 ));
3526 } else {
3527 let page = &mut pages[initial_page_count];
3546 let footer_h: f64 = page.fixed_footer.iter().map(|(_, h)| *h).sum();
3547 let page_content_bottom =
3548 page.config.margin.top + (page.height - page.config.margin.vertical()) - footer_h;
3549 let our_elements: Vec<LayoutElement> = page.elements.drain(snapshot..).collect();
3550 if !our_elements.is_empty() {
3551 page.elements.push(make_wrapper(
3552 rect_start_y,
3553 page_content_bottom - rect_start_y,
3554 our_elements,
3555 draw_cmd.clone(),
3556 ));
3557 }
3558
3559 for page in &mut pages[initial_page_count + 1..] {
3561 let header_h: f64 = page.fixed_header.iter().map(|(_, h)| *h).sum();
3562 let content_top = page.config.margin.top + header_h;
3563 let footer_h: f64 = page.fixed_footer.iter().map(|(_, h)| *h).sum();
3564 let content_bottom = page.config.margin.top
3565 + (page.height - page.config.margin.vertical())
3566 - footer_h;
3567 let all_elements: Vec<LayoutElement> = std::mem::take(&mut page.elements);
3568 if !all_elements.is_empty() {
3569 page.elements.push(make_wrapper(
3570 content_top,
3571 content_bottom - content_top,
3572 all_elements,
3573 draw_cmd.clone(),
3574 ));
3575 }
3576 }
3577
3578 let all_elements: Vec<LayoutElement> = std::mem::take(&mut cursor.elements);
3580 if !all_elements.is_empty() {
3581 let header_h: f64 = cursor.fixed_header.iter().map(|(_, h)| *h).sum();
3582 let content_top = cursor.content_y + header_h;
3583 cursor.elements.push(make_wrapper(
3584 content_top,
3585 table_bottom_y - content_top,
3586 all_elements,
3587 draw_cmd,
3588 ));
3589 }
3590 }
3591
3592 cursor.y += padding.bottom + border.bottom + margin.bottom;
3593 }
3594
3595 fn subtree_forces_break(node: &Node) -> bool {
3605 if matches!(node.kind, NodeKind::PageBreak | NodeKind::PageName { .. }) {
3606 return true;
3607 }
3608 if node.style.break_before == Some(true) {
3609 return true;
3610 }
3611 node.children.iter().any(Self::subtree_forces_break)
3612 }
3613
3614 #[allow(clippy::too_many_arguments)]
3615 fn layout_table_row(
3616 &self,
3617 row: &Node,
3618 col_widths: &[f64],
3619 parent_style: &ResolvedStyle,
3620 cursor: &mut PageCursor,
3621 start_x: f64,
3622 font_context: &FontContext,
3623 pages: &mut Vec<LayoutPage>,
3624 ) {
3625 let row_style = row
3626 .style
3627 .resolve(Some(parent_style), col_widths.iter().sum());
3628
3629 let row_height = self.measure_table_row_height(row, col_widths, parent_style, font_context);
3630 let row_bl = self.row_baseline(row, &row_style, col_widths);
3631 let row_y = cursor.content_y + cursor.y;
3632 let total_width: f64 = col_widths.iter().sum();
3633
3634 let is_header = matches!(row.kind, NodeKind::TableRow { is_header: true });
3635
3636 let row_snapshot = cursor.elements.len();
3638
3639 let row_may_break =
3651 row_height > cursor.remaining_height() || Self::subtree_forces_break(row);
3652
3653 let mut all_overflow_pages: Vec<LayoutPage> = Vec::new();
3654 let mut cell_x = start_x;
3655 let mut col_idx = 0usize;
3661 for cell in row.children.iter() {
3662 let span = match &cell.kind {
3663 NodeKind::TableCell { col_span, .. } => (*col_span).max(1) as usize,
3664 _ => 1,
3665 };
3666 let col_width: f64 = col_widths.iter().skip(col_idx).take(span).copied().sum();
3667 col_idx += span;
3668
3669 let cell_style = cell.style.resolve(Some(&row_style), col_width);
3670
3671 let cell_snapshot = cursor.elements.len();
3673
3674 let inner_width =
3675 col_width - cell_style.padding.horizontal() - cell_style.border_width.horizontal();
3676
3677 let content_x = cell_x + cell_style.padding.left + cell_style.border_width.left;
3678 let saved_y = cursor.y;
3679 cursor.y += cell_style.padding.top + cell_style.border_width.top;
3680
3681 if !matches!(cell_style.vertical_align, crate::style::VerticalAlign::Top) {
3685 let content_h: f64 = cell
3686 .children
3687 .iter()
3688 .map(|ch| {
3689 let ch_style = ch.style.resolve(Some(&cell_style), inner_width);
3690 self.measure_node_height(ch, inner_width, &ch_style, font_context)
3691 })
3692 .sum();
3693 let inner_row =
3694 row_height - cell_style.padding.vertical() - cell_style.border_width.vertical();
3695 let slack = (inner_row - content_h).max(0.0);
3696 cursor.y += match cell_style.vertical_align {
3697 crate::style::VerticalAlign::Middle => slack / 2.0,
3698 crate::style::VerticalAlign::Bottom => slack,
3699 crate::style::VerticalAlign::Baseline => row_bl
3704 .map(|b| {
3705 let d = self.cell_baseline_distance(cell, &cell_style, inner_width);
3706 (b - d).max(0.0)
3707 })
3708 .unwrap_or(0.0),
3709 crate::style::VerticalAlign::Top => 0.0,
3710 };
3711 }
3712
3713 let cursor_before_cell = if row_may_break {
3718 Some(cursor.clone())
3719 } else {
3720 None
3721 };
3722 let mut cell_pages: Vec<LayoutPage> = Vec::new();
3723 for child in &cell.children {
3724 self.layout_node(
3725 child,
3726 cursor,
3727 &mut cell_pages,
3728 content_x,
3729 inner_width,
3730 Some(&cell_style),
3731 font_context,
3732 None,
3733 None,
3734 );
3735 }
3736
3737 if !cell_pages.is_empty() {
3739 let post_break_elements = std::mem::take(&mut cursor.elements);
3740 if let Some(last_page) = cell_pages.last_mut() {
3741 last_page.elements.extend(post_break_elements);
3742 }
3743 if !is_header {
3749 all_overflow_pages.extend(cell_pages);
3750 }
3751 *cursor = cursor_before_cell
3757 .expect("table cell broke but no rollback checkpoint was taken (row_may_break under-approximated)");
3758 }
3759
3760 cursor.y = saved_y;
3761
3762 let cell_children: Vec<LayoutElement> =
3764 cursor.elements.drain(cell_snapshot..).collect();
3765
3766 cursor.elements.push(LayoutElement {
3768 x: cell_x,
3769 y: row_y,
3770 width: col_width,
3771 height: row_height,
3772 draw: if cell_style.background_color.is_some()
3773 || cell_style.border_width.horizontal() > 0.0
3774 || cell_style.border_width.vertical() > 0.0
3775 {
3776 DrawCommand::Rect {
3777 background: cell_style.background_color,
3778 border_width: cell_style.border_width,
3779 border_color: cell_style.border_color,
3780 border_style: cell_style.border_style,
3781 border_radius: cell_style.border_radius,
3782 opacity: 1.0,
3783 box_shadow: cell_style.box_shadow.map(Box::new),
3784 background_gradient: cell_style.background.clone().map(Box::new),
3785 }
3786 } else {
3787 DrawCommand::None
3788 },
3789 children: cell_children,
3790 node_type: Some("TableCell".to_string()),
3791 resolved_style: Some(cell_style.clone()),
3792 source_location: cell.source_location.clone(),
3793 href: None,
3794 bookmark: cell.bookmark.clone(),
3795 alt: None,
3796 is_header_row: is_header,
3797 col_span: span as u32,
3798 overflow: Overflow::default(),
3799 opacity: 1.0,
3800 });
3801
3802 cell_x += col_width;
3803 }
3804
3805 let row_children: Vec<LayoutElement> = cursor.elements.drain(row_snapshot..).collect();
3807 cursor.elements.push(LayoutElement {
3808 x: start_x,
3809 y: row_y,
3810 width: total_width,
3811 height: row_height,
3812 draw: if let Some(bg) = row_style.background_color {
3813 DrawCommand::Rect {
3814 background: Some(bg),
3815 border_width: Edges::default(),
3816 border_color: EdgeValues::uniform(Color::BLACK),
3817 border_style: EdgeValues::uniform(crate::style::BorderStyle::Solid),
3818 border_radius: CornerValues::uniform(0.0),
3819 opacity: 1.0,
3820 box_shadow: row_style.box_shadow.map(Box::new),
3821 background_gradient: row_style.background.clone().map(Box::new),
3822 }
3823 } else {
3824 DrawCommand::None
3825 },
3826 children: row_children,
3827 node_type: Some("TableRow".to_string()),
3828 resolved_style: Some(row_style.clone()),
3829 source_location: row.source_location.clone(),
3830 href: None,
3831 bookmark: row.bookmark.clone(),
3832 alt: None,
3833 is_header_row: is_header,
3834 col_span: 1,
3835 overflow: row_style.overflow,
3836 opacity: row_style.opacity,
3837 });
3838
3839 pages.extend(all_overflow_pages);
3841
3842 cursor.y += row_height;
3843 }
3844
3845 #[allow(clippy::too_many_arguments)]
3846 #[allow(clippy::too_many_arguments)]
3847 fn layout_text(
3848 &self,
3849 content: &str,
3850 href: Option<&str>,
3851 runs: &[TextRun],
3852 style: &ResolvedStyle,
3853 cursor: &mut PageCursor,
3854 pages: &mut Vec<LayoutPage>,
3855 x: f64,
3856 available_width: f64,
3857 font_context: &FontContext,
3858 source_location: Option<&SourceLocation>,
3859 bookmark: Option<&str>,
3860 node_type_override: Option<&str>,
3864 ) {
3865 let margin = &style.margin.to_edges();
3866 let text_x = x + margin.left;
3867 let text_width = match style.width {
3874 SizeConstraint::Fixed(w) => (w - margin.horizontal()).max(0.0),
3875 SizeConstraint::Auto => available_width - margin.horizontal(),
3876 };
3877
3878 cursor.y += margin.top;
3879
3880 if !runs.is_empty() {
3882 self.layout_text_runs(
3883 runs,
3884 href,
3885 style,
3886 cursor,
3887 pages,
3888 text_x,
3889 text_width,
3890 font_context,
3891 source_location,
3892 bookmark,
3893 node_type_override,
3894 );
3895 cursor.y += margin.bottom;
3896 return;
3897 }
3898
3899 let content = substitute_page_placeholders(content);
3900 let transformed = apply_text_transform(&content, style.text_transform);
3901 let justify = matches!(style.text_align, TextAlign::Justify);
3902 let lines = match style.line_breaking {
3903 LineBreaking::Optimal => self.text_layout.break_into_lines_optimal(
3904 font_context,
3905 &transformed,
3906 text_width,
3907 style.font_size,
3908 &style.font_family,
3909 style.font_weight,
3910 style.font_style,
3911 style.letter_spacing,
3912 style.hyphens,
3913 style.lang.as_deref(),
3914 justify,
3915 ),
3916 LineBreaking::Greedy => self.text_layout.break_into_lines(
3917 font_context,
3918 &transformed,
3919 text_width,
3920 style.font_size,
3921 &style.font_family,
3922 style.font_weight,
3923 style.font_style,
3924 style.letter_spacing,
3925 style.hyphens,
3926 style.lang.as_deref(),
3927 ),
3928 };
3929
3930 let lines = match style.text_overflow {
3932 TextOverflow::Ellipsis => self.text_layout.truncate_with_ellipsis(
3933 font_context,
3934 lines,
3935 text_width,
3936 style.font_size,
3937 &style.font_family,
3938 style.font_weight,
3939 style.font_style,
3940 style.letter_spacing,
3941 ),
3942 TextOverflow::Clip => self.text_layout.truncate_clip(
3943 font_context,
3944 lines,
3945 text_width,
3946 style.font_size,
3947 &style.font_family,
3948 style.font_weight,
3949 style.font_style,
3950 style.letter_spacing,
3951 ),
3952 TextOverflow::Wrap => lines,
3953 };
3954
3955 let line_height = style.font_size * style.line_height;
3956
3957 let line_heights: Vec<f64> = vec![line_height; lines.len()];
3959 let decision = page_break::decide_break(
3960 cursor.remaining_height(),
3961 &line_heights,
3962 true,
3963 style.min_orphan_lines as usize,
3964 style.min_widow_lines as usize,
3965 );
3966
3967 let mut snapshot = cursor.elements.len();
3969 let mut container_start_y = cursor.content_y + cursor.y;
3970 let mut is_first_element = true;
3971
3972 if matches!(decision, page_break::BreakDecision::MoveToNextPage) {
3974 pages.push(cursor.finalize());
3975 *cursor = cursor.new_page();
3976 snapshot = cursor.elements.len();
3977 container_start_y = cursor.content_y + cursor.y;
3978 }
3979
3980 let forced_break_at = match decision {
3982 page_break::BreakDecision::Split {
3983 items_on_current_page,
3984 } => Some(items_on_current_page),
3985 _ => None,
3986 };
3987 let mut first_break_done = false;
3988
3989 for (line_idx, line) in lines.iter().enumerate() {
3990 let needs_break = if let Some(break_at) = forced_break_at {
3992 if !first_break_done && line_idx == break_at {
3993 true
3994 } else {
3995 line_height > cursor.remaining_height()
3996 }
3997 } else {
3998 line_height > cursor.remaining_height()
3999 };
4000
4001 if needs_break {
4002 first_break_done = true;
4003 let line_elements: Vec<LayoutElement> = cursor.elements.drain(snapshot..).collect();
4005 if !line_elements.is_empty() {
4006 let container_height = cursor.content_y + cursor.y - container_start_y;
4007 cursor.elements.push(LayoutElement {
4008 x: text_x,
4009 y: container_start_y,
4010 width: text_width,
4011 height: container_height,
4012 draw: DrawCommand::None,
4013 children: line_elements,
4014 node_type: Some(node_type_override.unwrap_or("Text").to_string()),
4015 resolved_style: Some(style.clone()),
4016 source_location: source_location.cloned(),
4017 href: href.map(|s| s.to_string()),
4018 bookmark: if is_first_element {
4019 bookmark.map(|s| s.to_string())
4020 } else {
4021 None
4022 },
4023 alt: None,
4024 is_header_row: false,
4025 col_span: 1,
4026 overflow: Overflow::default(),
4027 opacity: 1.0,
4028 });
4029 is_first_element = false;
4030 }
4031
4032 pages.push(cursor.finalize());
4033 *cursor = cursor.new_page();
4034
4035 snapshot = cursor.elements.len();
4037 container_start_y = cursor.content_y + cursor.y;
4038 }
4039
4040 let glyphs = self.build_positioned_glyphs_single_style(line, style, href, font_context);
4041
4042 let rendered_width = if glyphs.is_empty() {
4046 line.width
4047 } else {
4048 let last = &glyphs[glyphs.len() - 1];
4049 (last.x_offset + last.x_advance).max(line.width * 0.5)
4050 };
4051
4052 let line_x = match style.text_align {
4053 TextAlign::Left => text_x,
4054 TextAlign::Right => text_x + text_width - rendered_width,
4055 TextAlign::Center => text_x + (text_width - rendered_width) / 2.0,
4056 TextAlign::Justify => text_x,
4057 };
4058
4059 let is_last_line = line_idx == lines.len() - 1;
4067 let user_ws = style.word_spacing;
4068 let (justified_width, word_spacing) =
4069 if matches!(style.text_align, TextAlign::Justify) && !is_last_line {
4070 let last_non_space = glyphs.iter().rposition(|g| g.char_value != ' ');
4071 let (natural_width, space_count) = if let Some(idx) = last_non_space {
4072 let w: f64 = glyphs[..=idx].iter().map(|g| g.x_advance).sum();
4073 let s = glyphs[..=idx]
4074 .iter()
4075 .filter(|g| g.char_value == ' ')
4076 .count();
4077 (w, s)
4078 } else {
4079 (0.0, 0)
4080 };
4081 let slack = text_width - natural_width;
4082 let ws = if space_count > 0 && slack.abs() > 0.01 {
4083 slack / space_count as f64
4084 } else {
4085 0.0
4086 };
4087 (text_width, user_ws + ws)
4088 } else {
4089 (rendered_width, user_ws)
4090 };
4091
4092 let text_line = TextLine {
4093 x: line_x,
4094 y: cursor.content_y + cursor.y + style.font_size,
4095 glyphs,
4096 width: justified_width,
4097 height: line_height,
4098 word_spacing,
4099 };
4100
4101 cursor.elements.push(LayoutElement {
4102 x: line_x,
4103 y: cursor.content_y + cursor.y,
4104 width: justified_width,
4105 height: line_height,
4106 draw: DrawCommand::Text {
4107 lines: vec![text_line],
4108 color: style.color,
4109 text_decoration: style.text_decoration,
4110 opacity: 1.0,
4111 },
4112 children: vec![],
4113 node_type: Some("TextLine".to_string()),
4114 resolved_style: Some(style.clone()),
4115 source_location: None,
4116 href: href.map(|s| s.to_string()),
4117 bookmark: None,
4118 alt: None,
4119 is_header_row: false,
4120 col_span: 1,
4121 overflow: Overflow::default(),
4122 opacity: 1.0,
4123 });
4124
4125 cursor.y += line_height;
4126 }
4127
4128 let line_elements: Vec<LayoutElement> = cursor.elements.drain(snapshot..).collect();
4130 if !line_elements.is_empty() {
4131 let container_height = cursor.content_y + cursor.y - container_start_y;
4132 cursor.elements.push(LayoutElement {
4133 x: text_x,
4134 y: container_start_y,
4135 width: text_width,
4136 height: container_height,
4137 draw: DrawCommand::None,
4138 children: line_elements,
4139 node_type: Some(node_type_override.unwrap_or("Text").to_string()),
4140 resolved_style: Some(style.clone()),
4141 source_location: source_location.cloned(),
4142 href: href.map(|s| s.to_string()),
4143 bookmark: if is_first_element {
4144 bookmark.map(|s| s.to_string())
4145 } else {
4146 None
4147 },
4148 alt: None,
4149 is_header_row: false,
4150 col_span: 1,
4151 overflow: Overflow::default(),
4152 opacity: 1.0,
4153 });
4154 }
4155
4156 cursor.y += margin.bottom;
4157 }
4158
4159 #[allow(clippy::too_many_arguments)]
4161 #[allow(clippy::too_many_arguments)]
4162 fn layout_text_runs(
4163 &self,
4164 runs: &[TextRun],
4165 parent_href: Option<&str>,
4166 style: &ResolvedStyle,
4167 cursor: &mut PageCursor,
4168 pages: &mut Vec<LayoutPage>,
4169 text_x: f64,
4170 text_width: f64,
4171 font_context: &FontContext,
4172 source_location: Option<&SourceLocation>,
4173 bookmark: Option<&str>,
4174 node_type_override: Option<&str>,
4176 ) {
4177 let mut styled_chars: Vec<StyledChar> = Vec::new();
4179 for run in runs {
4180 let run_style = run.style.resolve(Some(style), text_width);
4181 let run_href = run.href.as_deref().or(parent_href);
4182 let transform = run_style.text_transform;
4183 let run_content = substitute_page_placeholders(&run.content);
4184 let mut prev_is_whitespace = true;
4185 for ch in run_content.chars() {
4186 let transformed_ch = apply_char_transform(ch, transform, prev_is_whitespace);
4187 prev_is_whitespace = ch.is_whitespace();
4188 styled_chars.push(StyledChar {
4189 ch: transformed_ch,
4190 font_family: run_style.font_family.clone(),
4191 font_size: run_style.font_size,
4192 font_weight: run_style.font_weight,
4193 font_style: run_style.font_style,
4194 color: run_style.color,
4195 href: run_href.map(|s| s.to_string()),
4196 text_decoration: run_style.text_decoration,
4197 letter_spacing: run_style.letter_spacing,
4198 });
4199 }
4200 }
4201
4202 let justify = matches!(style.text_align, TextAlign::Justify);
4204 let broken_lines = match style.line_breaking {
4205 LineBreaking::Optimal => self.text_layout.break_runs_into_lines_optimal(
4206 font_context,
4207 &styled_chars,
4208 text_width,
4209 style.hyphens,
4210 style.lang.as_deref(),
4211 justify,
4212 ),
4213 LineBreaking::Greedy => self.text_layout.break_runs_into_lines(
4214 font_context,
4215 &styled_chars,
4216 text_width,
4217 style.hyphens,
4218 style.lang.as_deref(),
4219 ),
4220 };
4221
4222 let broken_lines = match style.text_overflow {
4224 TextOverflow::Ellipsis => {
4225 self.text_layout
4226 .truncate_runs_with_ellipsis(font_context, broken_lines, text_width)
4227 }
4228 TextOverflow::Clip => {
4229 self.text_layout
4230 .truncate_runs_clip(font_context, broken_lines, text_width)
4231 }
4232 TextOverflow::Wrap => broken_lines,
4233 };
4234
4235 let line_height = style.font_size * style.line_height;
4236
4237 let line_heights: Vec<f64> = vec![line_height; broken_lines.len()];
4239 let decision = page_break::decide_break(
4240 cursor.remaining_height(),
4241 &line_heights,
4242 true,
4243 style.min_orphan_lines as usize,
4244 style.min_widow_lines as usize,
4245 );
4246
4247 let mut snapshot = cursor.elements.len();
4248 let mut container_start_y = cursor.content_y + cursor.y;
4249 let mut is_first_element = true;
4250
4251 if matches!(decision, page_break::BreakDecision::MoveToNextPage) {
4252 pages.push(cursor.finalize());
4253 *cursor = cursor.new_page();
4254 snapshot = cursor.elements.len();
4255 container_start_y = cursor.content_y + cursor.y;
4256 }
4257
4258 let forced_break_at = match decision {
4259 page_break::BreakDecision::Split {
4260 items_on_current_page,
4261 } => Some(items_on_current_page),
4262 _ => None,
4263 };
4264 let mut first_break_done = false;
4265
4266 for (line_idx, run_line) in broken_lines.iter().enumerate() {
4267 let needs_break = if let Some(break_at) = forced_break_at {
4268 if !first_break_done && line_idx == break_at {
4269 true
4270 } else {
4271 line_height > cursor.remaining_height()
4272 }
4273 } else {
4274 line_height > cursor.remaining_height()
4275 };
4276
4277 if needs_break {
4278 first_break_done = true;
4279 let line_elements: Vec<LayoutElement> = cursor.elements.drain(snapshot..).collect();
4280 if !line_elements.is_empty() {
4281 let container_height = cursor.content_y + cursor.y - container_start_y;
4282 cursor.elements.push(LayoutElement {
4283 x: text_x,
4284 y: container_start_y,
4285 width: text_width,
4286 height: container_height,
4287 draw: DrawCommand::None,
4288 children: line_elements,
4289 node_type: Some(node_type_override.unwrap_or("Text").to_string()),
4290 resolved_style: Some(style.clone()),
4291 source_location: source_location.cloned(),
4292 href: parent_href.map(|s| s.to_string()),
4293 bookmark: if is_first_element {
4294 bookmark.map(|s| s.to_string())
4295 } else {
4296 None
4297 },
4298 alt: None,
4299 is_header_row: false,
4300 col_span: 1,
4301 overflow: Overflow::default(),
4302 opacity: 1.0,
4303 });
4304 is_first_element = false;
4305 }
4306
4307 pages.push(cursor.finalize());
4308 *cursor = cursor.new_page();
4309
4310 snapshot = cursor.elements.len();
4311 container_start_y = cursor.content_y + cursor.y;
4312 }
4313
4314 let line_x = match style.text_align {
4315 TextAlign::Left => text_x,
4316 TextAlign::Right => text_x + text_width - run_line.width,
4317 TextAlign::Center => text_x + (text_width - run_line.width) / 2.0,
4318 TextAlign::Justify => text_x,
4319 };
4320
4321 let glyphs = self.build_positioned_glyphs_runs(run_line, font_context, style.direction);
4322
4323 let is_last_line = line_idx == broken_lines.len() - 1;
4330 let user_ws = style.word_spacing;
4331 let (justified_width, word_spacing) =
4332 if matches!(style.text_align, TextAlign::Justify) && !is_last_line {
4333 let last_non_space = glyphs.iter().rposition(|g| g.char_value != ' ');
4334 let (natural_width, space_count) = if let Some(idx) = last_non_space {
4335 let w: f64 = glyphs[..=idx].iter().map(|g| g.x_advance).sum();
4336 let s = glyphs[..=idx]
4337 .iter()
4338 .filter(|g| g.char_value == ' ')
4339 .count();
4340 (w, s)
4341 } else {
4342 (0.0, 0)
4343 };
4344 let slack = text_width - natural_width;
4345 let ws = if space_count > 0 && slack.abs() > 0.01 {
4346 slack / space_count as f64
4347 } else {
4348 0.0
4349 };
4350 (text_width, user_ws + ws)
4351 } else {
4352 (run_line.width, user_ws)
4353 };
4354
4355 let text_line = TextLine {
4356 x: line_x,
4357 y: cursor.content_y + cursor.y + style.font_size,
4358 glyphs,
4359 width: justified_width,
4360 height: line_height,
4361 word_spacing,
4362 };
4363
4364 let text_dec = run_line
4366 .chars
4367 .iter()
4368 .find(|sc| !matches!(sc.text_decoration, TextDecoration::None))
4369 .map(|sc| sc.text_decoration)
4370 .unwrap_or(style.text_decoration);
4371
4372 cursor.elements.push(LayoutElement {
4373 x: line_x,
4374 y: cursor.content_y + cursor.y,
4375 width: justified_width,
4376 height: line_height,
4377 draw: DrawCommand::Text {
4378 lines: vec![text_line],
4379 color: style.color,
4380 text_decoration: text_dec,
4381 opacity: 1.0,
4382 },
4383 children: vec![],
4384 node_type: Some("TextLine".to_string()),
4385 resolved_style: Some(style.clone()),
4386 source_location: None,
4387 href: parent_href.map(|s| s.to_string()),
4388 bookmark: None,
4389 alt: None,
4390 is_header_row: false,
4391 col_span: 1,
4392 overflow: Overflow::default(),
4393 opacity: 1.0,
4394 });
4395
4396 cursor.y += line_height;
4397 }
4398
4399 let line_elements: Vec<LayoutElement> = cursor.elements.drain(snapshot..).collect();
4400 if !line_elements.is_empty() {
4401 let container_height = cursor.content_y + cursor.y - container_start_y;
4402 cursor.elements.push(LayoutElement {
4403 x: text_x,
4404 y: container_start_y,
4405 width: text_width,
4406 height: container_height,
4407 draw: DrawCommand::None,
4408 children: line_elements,
4409 node_type: Some(node_type_override.unwrap_or("Text").to_string()),
4410 resolved_style: Some(style.clone()),
4411 source_location: source_location.cloned(),
4412 href: parent_href.map(|s| s.to_string()),
4413 bookmark: if is_first_element {
4414 bookmark.map(|s| s.to_string())
4415 } else {
4416 None
4417 },
4418 alt: None,
4419 is_header_row: false,
4420 col_span: 1,
4421 overflow: Overflow::default(),
4422 opacity: 1.0,
4423 });
4424 }
4425 }
4426
4427 fn build_positioned_glyphs_single_style(
4431 &self,
4432 line: &BrokenLine,
4433 style: &ResolvedStyle,
4434 href: Option<&str>,
4435 font_context: &FontContext,
4436 ) -> Vec<PositionedGlyph> {
4437 let italic = matches!(style.font_style, FontStyle::Italic | FontStyle::Oblique);
4438 let line_text: String = line.chars.iter().collect();
4439 let direction = style.direction;
4440 let has_bidi = !bidi::is_pure_ltr(&line_text, direction);
4442
4443 let font_runs = crate::font::fallback::segment_by_font(
4446 &line.chars,
4447 &style.font_family,
4448 style.font_weight,
4449 italic,
4450 font_context.registry(),
4451 );
4452 let needs_per_char_fallback = font_runs.len() > 1
4453 || (font_runs.len() == 1 && font_runs[0].family != style.font_family);
4454
4455 if needs_per_char_fallback {
4457 let bidi_runs = if has_bidi {
4458 bidi::analyze_bidi(&line_text, direction)
4459 } else {
4460 vec![crate::text::bidi::BidiRun {
4461 char_start: 0,
4462 char_end: line.chars.len(),
4463 level: unicode_bidi::Level::ltr(),
4464 is_rtl: false,
4465 }]
4466 };
4467
4468 let mut all_glyphs = Vec::new();
4469 let mut bidi_levels = Vec::new();
4470 let mut x = 0.0_f64;
4471
4472 for bidi_run in &bidi_runs {
4474 for font_run in &font_runs {
4476 let start = font_run.start.max(bidi_run.char_start);
4478 let end = font_run.end.min(bidi_run.char_end);
4479 if start >= end {
4480 continue;
4481 }
4482
4483 let sub_chars: Vec<char> = line.chars[start..end].to_vec();
4484 let sub_text: String = sub_chars.iter().collect();
4485 let resolved_family = &font_run.family;
4486
4487 if let Some(font_data) =
4488 font_context.font_data(resolved_family, style.font_weight, italic)
4489 {
4490 if let Some(shaped) = shaping::shape_text_with_direction(
4491 &sub_text,
4492 font_data,
4493 bidi_run.is_rtl,
4494 ) {
4495 let units_per_em = font_context.units_per_em(
4496 resolved_family,
4497 style.font_weight,
4498 italic,
4499 );
4500 let scale = style.font_size / units_per_em as f64;
4501
4502 for sg in &shaped {
4503 let cluster = sg.cluster as usize;
4504 let char_value = sub_chars.get(cluster).copied().unwrap_or(' ');
4505
4506 let cluster_text = if shaped.len() < sub_chars.len() {
4507 let cluster_end =
4508 self.find_cluster_end(&shaped, sg, sub_chars.len());
4509 if cluster_end > cluster + 1 {
4510 Some(
4511 sub_chars[cluster..cluster_end]
4512 .iter()
4513 .collect::<String>(),
4514 )
4515 } else {
4516 None
4517 }
4518 } else {
4519 None
4520 };
4521
4522 let glyph_x = x + sg.x_offset as f64 * scale;
4523 let glyph_y = sg.y_offset as f64 * scale;
4524 let advance = sg.x_advance as f64 * scale + style.letter_spacing;
4525
4526 all_glyphs.push(PositionedGlyph {
4527 glyph_id: sg.glyph_id,
4528 x_offset: glyph_x,
4529 y_offset: glyph_y,
4530 x_advance: advance,
4531 font_size: style.font_size,
4532 font_family: Arc::from(resolved_family.as_str()),
4533 font_weight: style.font_weight,
4534 font_style: style.font_style,
4535 char_value,
4536 color: Some(style.color),
4537 href: href.map(|s| s.to_string()),
4538 text_decoration: style.text_decoration,
4539 letter_spacing: style.letter_spacing,
4540 cluster_text,
4541 });
4542 bidi_levels.push(bidi_run.level);
4543 x += advance;
4544 }
4545 continue;
4546 }
4547 }
4548
4549 for i in start..end {
4551 let ch = line.chars[i];
4552 let glyph_x = x;
4553 let char_width = font_context.char_width(
4554 ch,
4555 resolved_family,
4556 style.font_weight,
4557 italic,
4558 style.font_size,
4559 );
4560 let advance = char_width + style.letter_spacing;
4561 all_glyphs.push(PositionedGlyph {
4562 glyph_id: ch as u16,
4563 x_offset: glyph_x,
4564 y_offset: 0.0,
4565 x_advance: advance,
4566 font_size: style.font_size,
4567 font_family: Arc::from(resolved_family.as_str()),
4568 font_weight: style.font_weight,
4569 font_style: style.font_style,
4570 char_value: ch,
4571 color: Some(style.color),
4572 href: href.map(|s| s.to_string()),
4573 text_decoration: style.text_decoration,
4574 letter_spacing: style.letter_spacing,
4575 cluster_text: None,
4576 });
4577 bidi_levels.push(bidi_run.level);
4578 x += advance;
4579 }
4580 }
4581 }
4582
4583 if has_bidi && !all_glyphs.is_empty() {
4585 all_glyphs = bidi::reorder_line_glyphs(all_glyphs, &bidi_levels);
4586 bidi::reposition_after_reorder(&mut all_glyphs, 0.0);
4587 }
4588 return all_glyphs;
4589 }
4590
4591 if let Some(font_data) =
4594 font_context.font_data(&style.font_family, style.font_weight, italic)
4595 {
4596 if has_bidi {
4597 let bidi_runs = bidi::analyze_bidi(&line_text, direction);
4599 let units_per_em =
4600 font_context.units_per_em(&style.font_family, style.font_weight, italic);
4601 let scale = style.font_size / units_per_em as f64;
4602
4603 let mut all_glyphs = Vec::new();
4604 let mut bidi_levels = Vec::new();
4605 let mut x = 0.0_f64;
4606
4607 for run in &bidi_runs {
4608 let run_chars: Vec<char> = line.chars[run.char_start..run.char_end].to_vec();
4609 let run_text: String = run_chars.iter().collect();
4610
4611 if let Some(shaped) =
4612 shaping::shape_text_with_direction(&run_text, font_data, run.is_rtl)
4613 {
4614 for sg in &shaped {
4615 let cluster = sg.cluster as usize;
4616 let char_value = run_chars.get(cluster).copied().unwrap_or(' ');
4617
4618 let cluster_text = if shaped.len() < run_chars.len() {
4619 let cluster_end =
4620 self.find_cluster_end(&shaped, sg, run_chars.len());
4621 if cluster_end > cluster + 1 {
4622 Some(run_chars[cluster..cluster_end].iter().collect::<String>())
4623 } else {
4624 None
4625 }
4626 } else {
4627 None
4628 };
4629
4630 let glyph_x = x + sg.x_offset as f64 * scale;
4631 let glyph_y = sg.y_offset as f64 * scale;
4632 let advance = sg.x_advance as f64 * scale + style.letter_spacing;
4633
4634 all_glyphs.push(PositionedGlyph {
4635 glyph_id: sg.glyph_id,
4636 x_offset: glyph_x,
4637 y_offset: glyph_y,
4638 x_advance: advance,
4639 font_size: style.font_size,
4640 font_family: Arc::from(style.font_family.as_str()),
4641 font_weight: style.font_weight,
4642 font_style: style.font_style,
4643 char_value,
4644 color: Some(style.color),
4645 href: href.map(|s| s.to_string()),
4646 text_decoration: style.text_decoration,
4647 letter_spacing: style.letter_spacing,
4648 cluster_text,
4649 });
4650 bidi_levels.push(run.level);
4651
4652 x += advance;
4653 }
4654 }
4655 }
4656
4657 let mut glyphs = bidi::reorder_line_glyphs(all_glyphs, &bidi_levels);
4659 bidi::reposition_after_reorder(&mut glyphs, 0.0);
4660 return glyphs;
4661 }
4662
4663 if let Some(shaped) = shaping::shape_text(&line_text, font_data) {
4665 let units_per_em =
4666 font_context.units_per_em(&style.font_family, style.font_weight, italic);
4667 let scale = style.font_size / units_per_em as f64;
4668
4669 return self.shaped_glyphs_to_positioned(
4670 &shaped,
4671 &line.chars,
4672 &line.char_positions,
4673 scale,
4674 style.font_size,
4675 &style.font_family,
4676 style.font_weight,
4677 style.font_style,
4678 Some(style.color),
4679 href,
4680 style.text_decoration,
4681 style.letter_spacing,
4682 );
4683 }
4684 }
4685
4686 let mut glyphs: Vec<PositionedGlyph> = line
4688 .chars
4689 .iter()
4690 .enumerate()
4691 .map(|(j, ch)| {
4692 let glyph_x = line.char_positions.get(j).copied().unwrap_or(0.0);
4693 let char_width = font_context.char_width(
4694 *ch,
4695 &style.font_family,
4696 style.font_weight,
4697 italic,
4698 style.font_size,
4699 );
4700 PositionedGlyph {
4701 glyph_id: *ch as u16,
4702 x_offset: glyph_x,
4703 y_offset: 0.0,
4704 x_advance: char_width,
4705 font_size: style.font_size,
4706 font_family: Arc::from(style.font_family.as_str()),
4707 font_weight: style.font_weight,
4708 font_style: style.font_style,
4709 char_value: *ch,
4710 color: Some(style.color),
4711 href: href.map(|s| s.to_string()),
4712 text_decoration: style.text_decoration,
4713 letter_spacing: style.letter_spacing,
4714 cluster_text: None,
4715 }
4716 })
4717 .collect();
4718
4719 if has_bidi && !glyphs.is_empty() {
4721 let bidi_runs = bidi::analyze_bidi(&line_text, direction);
4722 let mut levels = Vec::with_capacity(glyphs.len());
4723 let mut char_idx = 0;
4724 for run in &bidi_runs {
4725 for _ in run.char_start..run.char_end {
4726 if char_idx < glyphs.len() {
4727 levels.push(run.level);
4728 char_idx += 1;
4729 }
4730 }
4731 }
4732 while levels.len() < glyphs.len() {
4734 levels.push(unicode_bidi::Level::ltr());
4735 }
4736 glyphs = bidi::reorder_line_glyphs(glyphs, &levels);
4737 bidi::reposition_after_reorder(&mut glyphs, 0.0);
4738 }
4739
4740 glyphs
4741 }
4742
4743 fn build_positioned_glyphs_runs(
4748 &self,
4749 run_line: &RunBrokenLine,
4750 font_context: &FontContext,
4751 direction: Direction,
4752 ) -> Vec<PositionedGlyph> {
4753 let chars = &run_line.chars;
4754 if chars.is_empty() {
4755 return vec![];
4756 }
4757
4758 let resolved_families: Vec<String> = chars
4761 .iter()
4762 .map(|sc| {
4763 if !sc.font_family.contains(',') {
4764 sc.font_family.clone()
4765 } else {
4766 let italic = matches!(sc.font_style, FontStyle::Italic | FontStyle::Oblique);
4767 let (_, family) = font_context.registry().resolve_for_char(
4768 &sc.font_family,
4769 sc.ch,
4770 sc.font_weight,
4771 italic,
4772 );
4773 family
4774 }
4775 })
4776 .collect();
4777
4778 let line_text: String = chars.iter().map(|c| c.ch).collect();
4779 let has_bidi = !bidi::is_pure_ltr(&line_text, direction);
4780 let bidi_runs = if has_bidi {
4781 Some(bidi::analyze_bidi(&line_text, direction))
4782 } else {
4783 None
4784 };
4785
4786 let mut glyphs = Vec::new();
4787 let mut bidi_levels = Vec::new();
4788 let mut i = 0;
4789
4790 while i < chars.len() {
4791 let sc = &chars[i];
4792 let italic = matches!(sc.font_style, FontStyle::Italic | FontStyle::Oblique);
4793 let resolved_family = &resolved_families[i];
4794
4795 let is_rtl = bidi_runs.as_ref().is_some_and(|runs| {
4797 runs.iter()
4798 .any(|r| i >= r.char_start && i < r.char_end && r.is_rtl)
4799 });
4800
4801 if let Some(font_data) = font_context.font_data(resolved_family, sc.font_weight, italic)
4803 {
4804 let run_start = i;
4806 let mut run_end = i + 1;
4807 while run_end < chars.len() {
4808 let next = &chars[run_end];
4809 let next_italic =
4810 matches!(next.font_style, FontStyle::Italic | FontStyle::Oblique);
4811 let next_is_rtl = bidi_runs.as_ref().is_some_and(|runs| {
4812 runs.iter()
4813 .any(|r| run_end >= r.char_start && run_end < r.char_end && r.is_rtl)
4814 });
4815 if resolved_families[run_end] == *resolved_family
4817 && next.font_weight == sc.font_weight
4818 && next_italic == italic
4819 && (next.font_size - sc.font_size).abs() < 0.001
4820 && next_is_rtl == is_rtl
4821 {
4822 run_end += 1;
4823 } else {
4824 break;
4825 }
4826 }
4827
4828 let run_text: String = chars[run_start..run_end].iter().map(|c| c.ch).collect();
4829 if let Some(shaped) =
4830 shaping::shape_text_with_direction(&run_text, font_data, is_rtl)
4831 {
4832 let units_per_em =
4833 font_context.units_per_em(resolved_family, sc.font_weight, italic);
4834 let scale = sc.font_size / units_per_em as f64;
4835
4836 let run_chars: Vec<char> =
4838 chars[run_start..run_end].iter().map(|c| c.ch).collect();
4839 let run_positions: Vec<f64> = (run_start..run_end)
4840 .map(|j| run_line.char_positions.get(j).copied().unwrap_or(0.0))
4841 .collect();
4842
4843 let mut run_glyphs = self.shaped_glyphs_to_positioned_runs(
4845 &shaped,
4846 &chars[run_start..run_end],
4847 &run_chars,
4848 &run_positions,
4849 scale,
4850 );
4851 let resolved_family_arc: Arc<str> = Arc::from(resolved_family.as_str());
4853 for g in &mut run_glyphs {
4854 g.font_family = resolved_family_arc.clone();
4855 }
4856 let run_level = if is_rtl {
4858 unicode_bidi::Level::rtl()
4859 } else {
4860 unicode_bidi::Level::ltr()
4861 };
4862 for _ in &run_glyphs {
4863 bidi_levels.push(run_level);
4864 }
4865 glyphs.extend(run_glyphs);
4866 i = run_end;
4867 continue;
4868 }
4869 }
4870
4871 let glyph_x = run_line.char_positions.get(i).copied().unwrap_or(0.0);
4873 let char_width = font_context.char_width(
4874 sc.ch,
4875 resolved_family,
4876 sc.font_weight,
4877 italic,
4878 sc.font_size,
4879 );
4880 glyphs.push(PositionedGlyph {
4881 glyph_id: sc.ch as u16,
4882 x_offset: glyph_x,
4883 y_offset: 0.0,
4884 x_advance: char_width,
4885 font_size: sc.font_size,
4886 font_family: Arc::from(resolved_family.as_str()),
4887 font_weight: sc.font_weight,
4888 font_style: sc.font_style,
4889 char_value: sc.ch,
4890 color: Some(sc.color),
4891 href: sc.href.clone(),
4892 text_decoration: sc.text_decoration,
4893 letter_spacing: sc.letter_spacing,
4894 cluster_text: None,
4895 });
4896 bidi_levels.push(if is_rtl {
4897 unicode_bidi::Level::rtl()
4898 } else {
4899 unicode_bidi::Level::ltr()
4900 });
4901 i += 1;
4902 }
4903
4904 if has_bidi && !glyphs.is_empty() {
4906 glyphs = bidi::reorder_line_glyphs(glyphs, &bidi_levels);
4907 bidi::reposition_after_reorder(&mut glyphs, 0.0);
4908 }
4909
4910 glyphs
4911 }
4912
4913 #[allow(clippy::too_many_arguments)]
4915 fn shaped_glyphs_to_positioned(
4916 &self,
4917 shaped: &[shaping::ShapedGlyph],
4918 chars: &[char],
4919 _char_positions: &[f64],
4920 scale: f64,
4921 font_size: f64,
4922 font_family: &str,
4923 font_weight: u32,
4924 font_style: FontStyle,
4925 color: Option<Color>,
4926 href: Option<&str>,
4927 text_decoration: TextDecoration,
4928 letter_spacing: f64,
4929 ) -> Vec<PositionedGlyph> {
4930 let mut result = Vec::with_capacity(shaped.len());
4931 let mut x = 0.0_f64;
4932
4933 for sg in shaped {
4934 let cluster = sg.cluster as usize;
4935 let char_value = chars.get(cluster).copied().unwrap_or(' ');
4936
4937 let cluster_text = if shaped.len() < chars.len() {
4939 let cluster_end = self.find_cluster_end(shaped, sg, chars.len());
4942 if cluster_end > cluster + 1 {
4943 Some(chars[cluster..cluster_end].iter().collect::<String>())
4944 } else {
4945 None
4946 }
4947 } else {
4948 None
4949 };
4950
4951 let glyph_x = x + sg.x_offset as f64 * scale;
4953 let glyph_y = sg.y_offset as f64 * scale;
4954 let advance = sg.x_advance as f64 * scale + letter_spacing;
4955
4956 result.push(PositionedGlyph {
4957 glyph_id: sg.glyph_id,
4958 x_offset: glyph_x,
4959 y_offset: glyph_y,
4960 x_advance: advance,
4961 font_size,
4962 font_family: Arc::from(font_family),
4963 font_weight,
4964 font_style,
4965 char_value,
4966 color,
4967 href: href.map(|s| s.to_string()),
4968 text_decoration,
4969 letter_spacing,
4970 cluster_text,
4971 });
4972
4973 x += advance;
4974 }
4975
4976 result
4977 }
4978
4979 fn shaped_glyphs_to_positioned_runs(
4981 &self,
4982 shaped: &[shaping::ShapedGlyph],
4983 styled_chars: &[StyledChar],
4984 chars: &[char],
4985 char_positions: &[f64],
4986 scale: f64,
4987 ) -> Vec<PositionedGlyph> {
4988 let mut result = Vec::with_capacity(shaped.len());
4989 let base_x = char_positions.first().copied().unwrap_or(0.0);
4991 let mut x = 0.0_f64;
4992
4993 for sg in shaped {
4994 let cluster = sg.cluster as usize;
4995 let sc = styled_chars.get(cluster).unwrap_or(&styled_chars[0]);
4996 let char_value = chars.get(cluster).copied().unwrap_or(' ');
4997
4998 let cluster_text = if shaped.len() < chars.len() {
4999 let cluster_end = self.find_cluster_end(shaped, sg, chars.len());
5000 if cluster_end > cluster + 1 {
5001 Some(chars[cluster..cluster_end].iter().collect::<String>())
5002 } else {
5003 None
5004 }
5005 } else {
5006 None
5007 };
5008
5009 let glyph_x = base_x + x + sg.x_offset as f64 * scale;
5010 let glyph_y = sg.y_offset as f64 * scale;
5011 let advance = sg.x_advance as f64 * scale + sc.letter_spacing;
5012
5013 result.push(PositionedGlyph {
5014 glyph_id: sg.glyph_id,
5015 x_offset: glyph_x,
5016 y_offset: glyph_y,
5017 x_advance: advance,
5018 font_size: sc.font_size,
5019 font_family: Arc::from(sc.font_family.as_str()),
5020 font_weight: sc.font_weight,
5021 font_style: sc.font_style,
5022 char_value,
5023 color: Some(sc.color),
5024 href: sc.href.clone(),
5025 text_decoration: sc.text_decoration,
5026 letter_spacing: sc.letter_spacing,
5027 cluster_text,
5028 });
5029
5030 x += advance;
5031 }
5032
5033 result
5034 }
5035
5036 fn find_cluster_end(
5038 &self,
5039 shaped: &[shaping::ShapedGlyph],
5040 current: &shaping::ShapedGlyph,
5041 num_chars: usize,
5042 ) -> usize {
5043 for sg in shaped {
5045 if sg.cluster > current.cluster {
5046 return sg.cluster as usize;
5047 }
5048 }
5049 num_chars
5051 }
5052
5053 #[allow(clippy::too_many_arguments)]
5054 fn layout_image(
5055 &self,
5056 node: &Node,
5057 style: &ResolvedStyle,
5058 cursor: &mut PageCursor,
5059 pages: &mut Vec<LayoutPage>,
5060 x: f64,
5061 available_width: f64,
5062 explicit_width: Option<f64>,
5063 explicit_height: Option<f64>,
5064 ) {
5065 let margin = &style.margin.to_edges();
5066
5067 let src = match &node.kind {
5069 NodeKind::Image { src, .. } => src.as_str(),
5070 _ => "",
5071 };
5072
5073 let loaded = if !src.is_empty() {
5074 crate::image_loader::load_image(src).ok()
5075 } else {
5076 None
5077 };
5078
5079 let (img_width, img_height) = if let Some(ref img) = loaded {
5081 let intrinsic_w = img.width_px as f64;
5082 let intrinsic_h = img.height_px as f64;
5083 let aspect = if intrinsic_w > 0.0 {
5084 intrinsic_h / intrinsic_w
5085 } else {
5086 0.75
5087 };
5088
5089 match (explicit_width, explicit_height) {
5090 (Some(w), Some(h)) => (w, h),
5091 (Some(w), None) => (w, w * aspect),
5092 (None, Some(h)) => (h / aspect, h),
5093 (None, None) => {
5094 let max_w = available_width - margin.horizontal();
5095 let w = intrinsic_w.min(max_w);
5096 (w, w * aspect)
5097 }
5098 }
5099 } else {
5100 let w = explicit_width.unwrap_or(available_width - margin.horizontal());
5102 let h = explicit_height.unwrap_or(w * 0.75);
5103 (w, h)
5104 };
5105
5106 let total_height = img_height + margin.vertical();
5107
5108 if total_height > cursor.remaining_height() {
5109 pages.push(cursor.finalize());
5110 *cursor = cursor.new_page();
5111 }
5112
5113 cursor.y += margin.top;
5114
5115 let draw = if let Some(image_data) = loaded {
5116 DrawCommand::Image { image_data }
5117 } else {
5118 DrawCommand::ImagePlaceholder
5119 };
5120
5121 cursor.elements.push(LayoutElement {
5122 x: x + margin.left,
5123 y: cursor.content_y + cursor.y,
5124 width: img_width,
5125 height: img_height,
5126 draw,
5127 children: vec![],
5128 node_type: Some(node_kind_name(&node.kind).to_string()),
5129 resolved_style: Some(style.clone()),
5130 source_location: node.source_location.clone(),
5131 href: node.href.clone(),
5132 bookmark: node.bookmark.clone(),
5133 alt: node.alt.clone(),
5134 is_header_row: false,
5135 col_span: 1,
5136 overflow: style.overflow,
5137 opacity: style.opacity,
5138 });
5139
5140 cursor.y += img_height + margin.bottom;
5141 }
5142
5143 #[allow(clippy::too_many_arguments)]
5145 fn layout_svg(
5146 &self,
5147 node: &Node,
5148 style: &ResolvedStyle,
5149 cursor: &mut PageCursor,
5150 pages: &mut Vec<LayoutPage>,
5151 x: f64,
5152 _available_width: f64,
5153 svg_width: f64,
5154 svg_height: f64,
5155 view_box: Option<&str>,
5156 content: &str,
5157 ) {
5158 let margin = &style.margin.to_edges();
5159 let total_height = svg_height + margin.vertical();
5160
5161 if total_height > cursor.remaining_height() {
5162 pages.push(cursor.finalize());
5163 *cursor = cursor.new_page();
5164 }
5165
5166 cursor.y += margin.top;
5167
5168 let vb = view_box
5169 .and_then(crate::svg::parse_view_box)
5170 .unwrap_or(crate::svg::ViewBox {
5171 min_x: 0.0,
5172 min_y: 0.0,
5173 width: svg_width,
5174 height: svg_height,
5175 });
5176
5177 let commands = crate::svg::parse_svg(content, vb, svg_width, svg_height);
5178
5179 cursor.elements.push(LayoutElement {
5180 x: x + margin.left,
5181 y: cursor.content_y + cursor.y,
5182 width: svg_width,
5183 height: svg_height,
5184 draw: DrawCommand::Svg {
5185 commands,
5186 width: svg_width,
5187 height: svg_height,
5188 viewbox_min_x: vb.min_x,
5189 viewbox_min_y: vb.min_y,
5190 viewbox_width: vb.width,
5191 viewbox_height: vb.height,
5192 clip: false,
5193 },
5194 children: vec![],
5195 node_type: Some("Svg".to_string()),
5196 resolved_style: Some(style.clone()),
5197 source_location: node.source_location.clone(),
5198 href: node.href.clone(),
5199 bookmark: node.bookmark.clone(),
5200 alt: node.alt.clone(),
5201 is_header_row: false,
5202 col_span: 1,
5203 overflow: style.overflow,
5204 opacity: style.opacity,
5205 });
5206
5207 cursor.y += svg_height + margin.bottom;
5208 }
5209
5210 fn canvas_ops_to_svg_commands(operations: &[CanvasOp]) -> Vec<crate::svg::SvgCommand> {
5212 use crate::svg::SvgCommand;
5213
5214 let mut commands = Vec::new();
5215 let mut cur_x = 0.0_f64;
5216 let mut cur_y = 0.0_f64;
5217
5218 for op in operations {
5219 match op {
5220 CanvasOp::MoveTo { x, y } => {
5221 commands.push(SvgCommand::MoveTo(*x, *y));
5222 cur_x = *x;
5223 cur_y = *y;
5224 }
5225 CanvasOp::LineTo { x, y } => {
5226 commands.push(SvgCommand::LineTo(*x, *y));
5227 cur_x = *x;
5228 cur_y = *y;
5229 }
5230 CanvasOp::BezierCurveTo {
5231 cp1x,
5232 cp1y,
5233 cp2x,
5234 cp2y,
5235 x,
5236 y,
5237 } => {
5238 commands.push(SvgCommand::CurveTo(*cp1x, *cp1y, *cp2x, *cp2y, *x, *y));
5239 cur_x = *x;
5240 cur_y = *y;
5241 }
5242 CanvasOp::QuadraticCurveTo { cpx, cpy, x, y } => {
5243 let cp1x = cur_x + 2.0 / 3.0 * (*cpx - cur_x);
5245 let cp1y = cur_y + 2.0 / 3.0 * (*cpy - cur_y);
5246 let cp2x = *x + 2.0 / 3.0 * (*cpx - *x);
5247 let cp2y = *y + 2.0 / 3.0 * (*cpy - *y);
5248 commands.push(SvgCommand::CurveTo(cp1x, cp1y, cp2x, cp2y, *x, *y));
5249 cur_x = *x;
5250 cur_y = *y;
5251 }
5252 CanvasOp::ClosePath => {
5253 commands.push(SvgCommand::ClosePath);
5254 }
5255 CanvasOp::Rect {
5256 x,
5257 y,
5258 width,
5259 height,
5260 } => {
5261 commands.push(SvgCommand::MoveTo(*x, *y));
5262 commands.push(SvgCommand::LineTo(*x + *width, *y));
5263 commands.push(SvgCommand::LineTo(*x + *width, *y + *height));
5264 commands.push(SvgCommand::LineTo(*x, *y + *height));
5265 commands.push(SvgCommand::ClosePath);
5266 cur_x = *x;
5267 cur_y = *y;
5268 }
5269 CanvasOp::Circle { cx, cy, r } => {
5270 commands.extend(crate::svg::ellipse_commands(*cx, *cy, *r, *r));
5271 }
5272 CanvasOp::Ellipse { cx, cy, rx, ry } => {
5273 commands.extend(crate::svg::ellipse_commands(*cx, *cy, *rx, *ry));
5274 }
5275 CanvasOp::Arc {
5276 cx,
5277 cy,
5278 r,
5279 start_angle,
5280 end_angle,
5281 counterclockwise,
5282 } => {
5283 let steps = 32;
5287 let mut sweep = end_angle - start_angle;
5288 if !counterclockwise && sweep < 0.0 {
5289 sweep += 2.0 * std::f64::consts::PI;
5290 }
5291 if *counterclockwise && sweep > 0.0 {
5292 sweep -= 2.0 * std::f64::consts::PI;
5293 }
5294 for i in 0..=steps {
5295 let t = *start_angle + sweep * (i as f64 / steps as f64);
5296 let px = cx + r * t.cos();
5297 let py = cy + r * t.sin();
5298 if i == 0 {
5299 commands.push(SvgCommand::MoveTo(px, py));
5300 } else {
5301 commands.push(SvgCommand::LineTo(px, py));
5302 }
5303 }
5304 }
5305 CanvasOp::Stroke => commands.push(SvgCommand::Stroke),
5306 CanvasOp::Fill => commands.push(SvgCommand::Fill),
5307 CanvasOp::FillAndStroke => commands.push(SvgCommand::FillAndStroke),
5308 CanvasOp::SetFillColor { r, g, b } => {
5309 commands.push(SvgCommand::SetFill(r / 255.0, g / 255.0, b / 255.0));
5311 }
5312 CanvasOp::SetStrokeColor { r, g, b } => {
5313 commands.push(SvgCommand::SetStroke(r / 255.0, g / 255.0, b / 255.0));
5314 }
5315 CanvasOp::SetLineWidth { width } => {
5316 commands.push(SvgCommand::SetStrokeWidth(*width));
5317 }
5318 CanvasOp::SetLineCap { cap } => {
5319 commands.push(SvgCommand::SetLineCap(*cap));
5320 }
5321 CanvasOp::SetLineJoin { join } => {
5322 commands.push(SvgCommand::SetLineJoin(*join));
5323 }
5324 CanvasOp::Save => commands.push(SvgCommand::SaveState),
5325 CanvasOp::Restore => commands.push(SvgCommand::RestoreState),
5326 }
5327 }
5328
5329 commands
5330 }
5331
5332 #[allow(clippy::too_many_arguments)]
5334 fn layout_canvas(
5335 &self,
5336 node: &Node,
5337 style: &ResolvedStyle,
5338 cursor: &mut PageCursor,
5339 pages: &mut Vec<LayoutPage>,
5340 x: f64,
5341 _available_width: f64,
5342 canvas_width: f64,
5343 canvas_height: f64,
5344 operations: &[CanvasOp],
5345 ) {
5346 let margin = style.margin.to_edges();
5347 let total_height = canvas_height + margin.top + margin.bottom;
5348
5349 if cursor.remaining_height() < total_height && cursor.y > 0.0 {
5351 pages.push(cursor.finalize());
5352 *cursor = cursor.new_page();
5353 }
5354
5355 cursor.y += margin.top;
5356
5357 let svg_commands = Self::canvas_ops_to_svg_commands(operations);
5358
5359 cursor.elements.push(LayoutElement {
5360 x: x + margin.left,
5361 y: cursor.content_y + cursor.y,
5362 width: canvas_width,
5363 height: canvas_height,
5364 draw: DrawCommand::Svg {
5365 commands: svg_commands,
5366 width: canvas_width,
5367 height: canvas_height,
5368 viewbox_min_x: 0.0,
5371 viewbox_min_y: 0.0,
5372 viewbox_width: canvas_width,
5373 viewbox_height: canvas_height,
5374 clip: true,
5375 },
5376 children: vec![],
5377 node_type: Some("Canvas".to_string()),
5378 resolved_style: Some(style.clone()),
5379 source_location: node.source_location.clone(),
5380 href: node.href.clone(),
5381 bookmark: node.bookmark.clone(),
5382 alt: node.alt.clone(),
5383 is_header_row: false,
5384 col_span: 1,
5385 overflow: style.overflow,
5386 opacity: style.opacity,
5387 });
5388
5389 cursor.y += canvas_height + margin.bottom;
5390 }
5391
5392 #[allow(clippy::too_many_arguments)]
5394 #[allow(clippy::too_many_arguments)]
5396 fn layout_chart(
5397 &self,
5398 node: &Node,
5399 style: &ResolvedStyle,
5400 cursor: &mut PageCursor,
5401 pages: &mut Vec<LayoutPage>,
5402 x: f64,
5403 chart_width: f64,
5404 chart_height: f64,
5405 primitives: Vec<crate::chart::ChartPrimitive>,
5406 node_type_name: &str,
5407 ) {
5408 let margin = &style.margin.to_edges();
5409 let total_height = chart_height + margin.vertical();
5410
5411 if total_height > cursor.remaining_height() {
5412 pages.push(cursor.finalize());
5413 *cursor = cursor.new_page();
5414 }
5415
5416 cursor.y += margin.top;
5417
5418 let draw = DrawCommand::Chart { primitives };
5419
5420 cursor.elements.push(LayoutElement {
5421 x: x + margin.left,
5422 y: cursor.content_y + cursor.y,
5423 width: chart_width,
5424 height: chart_height,
5425 draw,
5426 children: vec![],
5427 node_type: Some(node_type_name.to_string()),
5428 resolved_style: Some(style.clone()),
5429 source_location: node.source_location.clone(),
5430 href: node.href.clone(),
5431 bookmark: node.bookmark.clone(),
5432 alt: node.alt.clone(),
5433 is_header_row: false,
5434 col_span: 1,
5435 overflow: style.overflow,
5436 opacity: style.opacity,
5437 });
5438
5439 cursor.y += chart_height + margin.bottom;
5440 }
5441
5442 #[allow(clippy::too_many_arguments)]
5444 fn layout_form_field(
5445 &self,
5446 node: &Node,
5447 style: &ResolvedStyle,
5448 cursor: &mut PageCursor,
5449 pages: &mut Vec<LayoutPage>,
5450 x: f64,
5451 field_width: f64,
5452 field_height: f64,
5453 draw: DrawCommand,
5454 node_type_name: &str,
5455 ) {
5456 let margin = &style.margin.to_edges();
5457 let total_height = field_height + margin.vertical();
5458
5459 if total_height > cursor.remaining_height() {
5460 pages.push(cursor.finalize());
5461 *cursor = cursor.new_page();
5462 }
5463
5464 cursor.y += margin.top;
5465
5466 cursor.elements.push(LayoutElement {
5467 x: x + margin.left,
5468 y: cursor.content_y + cursor.y,
5469 width: field_width,
5470 height: field_height,
5471 draw,
5472 children: vec![],
5473 node_type: Some(node_type_name.to_string()),
5474 resolved_style: Some(style.clone()),
5475 source_location: node.source_location.clone(),
5476 href: node.href.clone(),
5477 bookmark: node.bookmark.clone(),
5478 alt: node.alt.clone(),
5479 is_header_row: false,
5480 col_span: 1,
5481 overflow: style.overflow,
5482 opacity: style.opacity,
5483 });
5484
5485 cursor.y += field_height + margin.bottom;
5486 }
5487
5488 #[allow(clippy::too_many_arguments)]
5489 fn layout_barcode(
5490 &self,
5491 node: &Node,
5492 style: &ResolvedStyle,
5493 cursor: &mut PageCursor,
5494 pages: &mut Vec<LayoutPage>,
5495 x: f64,
5496 available_width: f64,
5497 data: &str,
5498 format: crate::barcode::BarcodeFormat,
5499 explicit_width: Option<f64>,
5500 bar_height: f64,
5501 ) {
5502 let margin = &style.margin.to_edges();
5503 let display_width = explicit_width.unwrap_or(available_width - margin.horizontal());
5504 let total_height = bar_height + margin.vertical();
5505
5506 if total_height > cursor.remaining_height() {
5507 pages.push(cursor.finalize());
5508 *cursor = cursor.new_page();
5509 }
5510
5511 cursor.y += margin.top;
5512
5513 let draw = match crate::barcode::generate_barcode(data, format) {
5514 Ok(barcode_data) => {
5515 let bar_width = if barcode_data.bars.is_empty() {
5516 0.0
5517 } else {
5518 display_width / barcode_data.bars.len() as f64
5519 };
5520 DrawCommand::Barcode {
5521 bars: barcode_data.bars,
5522 bar_width,
5523 height: bar_height,
5524 color: style.color,
5525 }
5526 }
5527 Err(_) => DrawCommand::None,
5528 };
5529
5530 cursor.elements.push(LayoutElement {
5531 x: x + margin.left,
5532 y: cursor.content_y + cursor.y,
5533 width: display_width,
5534 height: bar_height,
5535 draw,
5536 children: vec![],
5537 node_type: Some("Barcode".to_string()),
5538 resolved_style: Some(style.clone()),
5539 source_location: node.source_location.clone(),
5540 href: node.href.clone(),
5541 bookmark: node.bookmark.clone(),
5542 alt: node.alt.clone(),
5543 is_header_row: false,
5544 col_span: 1,
5545 overflow: style.overflow,
5546 opacity: style.opacity,
5547 });
5548
5549 cursor.y += bar_height + margin.bottom;
5550 }
5551
5552 #[allow(clippy::too_many_arguments)]
5554 fn layout_qrcode(
5555 &self,
5556 node: &Node,
5557 style: &ResolvedStyle,
5558 cursor: &mut PageCursor,
5559 pages: &mut Vec<LayoutPage>,
5560 x: f64,
5561 available_width: f64,
5562 data: &str,
5563 explicit_size: Option<f64>,
5564 ) {
5565 let margin = &style.margin.to_edges();
5566 let display_size = explicit_size.unwrap_or(available_width - margin.horizontal());
5567 let total_height = display_size + margin.vertical();
5568
5569 if total_height > cursor.remaining_height() {
5570 pages.push(cursor.finalize());
5571 *cursor = cursor.new_page();
5572 }
5573
5574 cursor.y += margin.top;
5575
5576 let draw = match crate::qrcode::generate_qr(data) {
5577 Ok(matrix) => {
5578 let module_size = display_size / matrix.size as f64;
5579 DrawCommand::QrCode {
5580 modules: matrix.modules,
5581 module_size,
5582 color: style.color,
5583 }
5584 }
5585 Err(_) => DrawCommand::None,
5586 };
5587
5588 cursor.elements.push(LayoutElement {
5589 x: x + margin.left,
5590 y: cursor.content_y + cursor.y,
5591 width: display_size,
5592 height: display_size,
5593 draw,
5594 children: vec![],
5595 node_type: Some("QrCode".to_string()),
5596 resolved_style: Some(style.clone()),
5597 source_location: node.source_location.clone(),
5598 href: node.href.clone(),
5599 bookmark: node.bookmark.clone(),
5600 alt: node.alt.clone(),
5601 is_header_row: false,
5602 col_span: 1,
5603 overflow: style.overflow,
5604 opacity: style.opacity,
5605 });
5606
5607 cursor.y += display_size + margin.bottom;
5608 }
5609
5610 fn measure_node_height(
5613 &self,
5614 node: &Node,
5615 available_width: f64,
5616 style: &ResolvedStyle,
5617 font_context: &FontContext,
5618 ) -> f64 {
5619 match &node.kind {
5620 NodeKind::Text { content, runs, .. } | NodeKind::Heading { content, runs, .. } => {
5625 let measure_width = match style.width {
5628 SizeConstraint::Fixed(w) => (w - style.margin.horizontal()).max(0.0),
5629 SizeConstraint::Auto => available_width - style.margin.horizontal(),
5630 };
5631 if !runs.is_empty() {
5632 let mut styled_chars: Vec<StyledChar> = Vec::new();
5634 for run in runs {
5635 let run_style = run.style.resolve(Some(style), measure_width);
5636 let run_content = substitute_page_placeholders(&run.content);
5637 for ch in run_content.chars() {
5638 styled_chars.push(StyledChar {
5639 ch,
5640 font_family: run_style.font_family.clone(),
5641 font_size: run_style.font_size,
5642 font_weight: run_style.font_weight,
5643 font_style: run_style.font_style,
5644 color: run_style.color,
5645 href: None,
5646 text_decoration: run_style.text_decoration,
5647 letter_spacing: run_style.letter_spacing,
5648 });
5649 }
5650 }
5651 let broken_lines = self.text_layout.break_runs_into_lines(
5652 font_context,
5653 &styled_chars,
5654 measure_width,
5655 style.hyphens,
5656 style.lang.as_deref(),
5657 );
5658 let line_height = style.font_size * style.line_height;
5659 (broken_lines.len() as f64) * line_height + style.padding.vertical()
5660 } else {
5661 let content = substitute_page_placeholders(content);
5662 let lines = self.text_layout.break_into_lines(
5663 font_context,
5664 &content,
5665 measure_width,
5666 style.font_size,
5667 &style.font_family,
5668 style.font_weight,
5669 style.font_style,
5670 style.letter_spacing,
5671 style.hyphens,
5672 style.lang.as_deref(),
5673 );
5674 let line_height = style.font_size * style.line_height;
5675 (lines.len() as f64) * line_height + style.padding.vertical()
5676 }
5677 }
5678 NodeKind::Image {
5679 src,
5680 width: explicit_w,
5681 height: explicit_h,
5682 } => {
5683 if let SizeConstraint::Fixed(h) = style.height {
5685 return h + style.padding.vertical();
5686 }
5687 if let Some(h) = explicit_h {
5689 return *h + style.padding.vertical();
5690 }
5691 let aspect = self
5693 .get_image_dimensions(src)
5694 .map(|(w, h)| if w > 0 { h as f64 / w as f64 } else { 0.75 })
5695 .unwrap_or(0.75);
5696 let w = if let SizeConstraint::Fixed(w) = style.width {
5697 w
5698 } else {
5699 explicit_w.unwrap_or(available_width - style.margin.horizontal())
5700 };
5701 w * aspect + style.padding.vertical()
5702 }
5703 NodeKind::Svg { height, .. } => *height + style.margin.vertical(),
5704 NodeKind::Barcode { height, .. } => *height + style.margin.vertical(),
5705 NodeKind::QrCode { size, .. } => {
5706 let display_size = size.unwrap_or(available_width - style.margin.horizontal());
5707 display_size + style.margin.vertical()
5708 }
5709 NodeKind::Canvas { height, .. } => *height + style.margin.vertical(),
5710 NodeKind::BarChart { height, .. }
5711 | NodeKind::LineChart { height, .. }
5712 | NodeKind::PieChart { height, .. }
5713 | NodeKind::AreaChart { height, .. }
5714 | NodeKind::DotPlot { height, .. } => *height + style.margin.vertical(),
5715 NodeKind::TextField { height, .. }
5716 | NodeKind::Checkbox { height, .. }
5717 | NodeKind::Dropdown { height, .. }
5718 | NodeKind::RadioButton { height, .. } => *height + style.margin.vertical(),
5719 NodeKind::Watermark { .. } => 0.0, NodeKind::Table { columns } => {
5721 if let SizeConstraint::Fixed(h) = style.height {
5728 return h;
5729 }
5730 let outer_width = match style.width {
5731 SizeConstraint::Fixed(w) => w,
5732 SizeConstraint::Auto => available_width - style.margin.horizontal(),
5733 };
5734 let inner_width =
5735 outer_width - style.padding.horizontal() - style.border_width.horizontal();
5736 let col_widths = self.resolve_column_widths(
5737 columns,
5738 inner_width,
5739 &node.children,
5740 style,
5741 font_context,
5742 );
5743 let row_gap = style.row_gap;
5744 let mut total = 0.0;
5745 for (i, row) in node.children.iter().enumerate() {
5746 if i > 0 {
5747 total += row_gap;
5748 }
5749 total += self.measure_table_row_height(row, &col_widths, style, font_context);
5750 }
5751 total + style.padding.vertical() + style.border_width.vertical()
5752 }
5753 NodeKind::TableRow { .. } => {
5754 let n = node.children.len().max(1);
5759 let usable = (available_width - style.margin.horizontal()).max(0.0);
5760 let col_w = usable / n as f64;
5761 let col_widths = vec![col_w; n];
5762 self.measure_table_row_height(node, &col_widths, style, font_context)
5763 }
5764 _ => {
5765 if let SizeConstraint::Fixed(h) = style.height {
5767 return h;
5768 }
5769 let outer_width = match style.width {
5773 SizeConstraint::Fixed(w) => w,
5774 SizeConstraint::Auto => available_width - style.margin.horizontal(),
5775 }
5776 .min(style.max_width)
5777 .max(style.min_width);
5778 let inner_width =
5779 outer_width - style.padding.horizontal() - style.border_width.horizontal();
5780 let children_height =
5781 self.measure_children_height(&node.children, inner_width, style, font_context);
5782 (children_height + style.padding.vertical() + style.border_width.vertical())
5783 .max(style.min_height)
5784 }
5785 }
5786 }
5787
5788 fn measure_children_height(
5789 &self,
5790 children: &[Node],
5791 available_width: f64,
5792 parent_style: &ResolvedStyle,
5793 font_context: &FontContext,
5794 ) -> f64 {
5795 if matches!(parent_style.display, Display::Grid) {
5797 if let Some(template_cols) = &parent_style.grid_template_columns {
5798 let num_columns = template_cols.len();
5799 if num_columns > 0 && !children.is_empty() {
5800 let col_gap = parent_style.column_gap;
5801 let row_gap = parent_style.row_gap;
5802
5803 let content_sizes: Vec<f64> = template_cols
5804 .iter()
5805 .map(|track| {
5806 if matches!(track, GridTrackSize::Auto) {
5807 available_width / num_columns as f64
5808 } else {
5809 0.0
5810 }
5811 })
5812 .collect();
5813
5814 let col_widths = grid::resolve_tracks(
5815 template_cols,
5816 available_width,
5817 col_gap,
5818 &content_sizes,
5819 );
5820
5821 let placements: Vec<Option<&GridPlacement>> = children
5822 .iter()
5823 .map(|child| child.style.grid_placement.as_ref())
5824 .collect();
5825
5826 let item_placements = grid::place_items(&placements, num_columns);
5827 let num_rows = grid::compute_num_rows(&item_placements);
5828
5829 if num_rows == 0 {
5830 return 0.0;
5831 }
5832
5833 let mut row_heights = vec![0.0_f64; num_rows];
5834 for placement in &item_placements {
5835 let cell_width = grid::span_width(
5836 placement.col_start,
5837 placement.col_end,
5838 &col_widths,
5839 col_gap,
5840 );
5841 let child = &children[placement.child_index];
5842 let child_style = child.style.resolve(Some(parent_style), cell_width);
5843 let h =
5844 self.measure_node_height(child, cell_width, &child_style, font_context);
5845 let span = placement.row_end - placement.row_start;
5846 let per_row = h / span as f64;
5847 for rh in row_heights
5848 .iter_mut()
5849 .take(placement.row_end.min(num_rows))
5850 .skip(placement.row_start)
5851 {
5852 if per_row > *rh {
5853 *rh = per_row;
5854 }
5855 }
5856 }
5857
5858 let total_row_gap = row_gap * (num_rows as f64 - 1.0).max(0.0);
5859 return row_heights.iter().sum::<f64>() + total_row_gap;
5860 }
5861 }
5862 }
5863
5864 let direction = parent_style.flex_direction;
5865 let row_gap = parent_style.row_gap;
5866 let column_gap = parent_style.column_gap;
5867
5868 match direction {
5869 FlexDirection::Row | FlexDirection::RowReverse => {
5870 let styles: Vec<ResolvedStyle> = children
5873 .iter()
5874 .map(|child| child.style.resolve(Some(parent_style), available_width))
5875 .collect();
5876
5877 let base_widths: Vec<f64> = children
5878 .iter()
5879 .zip(&styles)
5880 .map(|(child, style)| match style.flex_basis {
5881 SizeConstraint::Fixed(w) => w,
5882 SizeConstraint::Auto => match style.width {
5883 SizeConstraint::Fixed(w) => w,
5884 SizeConstraint::Auto => self
5885 .measure_intrinsic_width(child, style, font_context)
5886 .min(available_width),
5887 },
5888 })
5889 .collect();
5890
5891 let lines = match parent_style.flex_wrap {
5892 FlexWrap::NoWrap => {
5893 vec![flex::WrapLine {
5894 start: 0,
5895 end: children.len(),
5896 }]
5897 }
5898 FlexWrap::Wrap | FlexWrap::WrapReverse => {
5899 flex::partition_into_lines(&base_widths, column_gap, available_width)
5900 }
5901 };
5902
5903 let mut final_widths = base_widths.clone();
5905 for line in &lines {
5906 let line_count = line.end - line.start;
5907 let line_gap = column_gap * (line_count as f64 - 1.0).max(0.0);
5908 let distributable = available_width - line_gap;
5909 let total_base: f64 = base_widths[line.start..line.end].iter().sum();
5910 let remaining = distributable - total_base;
5911
5912 if remaining > 0.0 {
5913 let total_grow: f64 = styles[line.start..line.end]
5914 .iter()
5915 .map(|s| s.flex_grow)
5916 .sum();
5917 if total_grow > 0.0 {
5918 for (j, s) in styles[line.start..line.end].iter().enumerate() {
5919 final_widths[line.start + j] = base_widths[line.start + j]
5920 + remaining * (s.flex_grow / total_grow);
5921 }
5922 }
5923 } else if remaining < 0.0 {
5924 let total_shrink: f64 = styles[line.start..line.end]
5925 .iter()
5926 .enumerate()
5927 .map(|(j, s)| s.flex_shrink * base_widths[line.start + j])
5928 .sum();
5929 if total_shrink > 0.0 {
5930 for (j, s) in styles[line.start..line.end].iter().enumerate() {
5931 let factor =
5932 (s.flex_shrink * base_widths[line.start + j]) / total_shrink;
5933 let w = base_widths[line.start + j] + remaining * factor;
5934 final_widths[line.start + j] = w.max(s.min_width);
5935 }
5936 }
5937 }
5938 }
5939
5940 let mut total = 0.0;
5941 for (i, line) in lines.iter().enumerate() {
5942 let line_height: f64 = children[line.start..line.end]
5943 .iter()
5944 .enumerate()
5945 .map(|(j, child)| {
5946 let fw = final_widths[line.start + j];
5947 let child_style = child.style.resolve(Some(parent_style), fw);
5948 self.measure_node_height(child, fw, &child_style, font_context)
5949 + child_style.margin.vertical()
5950 })
5951 .fold(0.0f64, f64::max);
5952 total += line_height;
5953 if i > 0 {
5954 total += row_gap;
5955 }
5956 }
5957 total
5958 }
5959 FlexDirection::Column | FlexDirection::ColumnReverse => {
5960 let mut total = 0.0;
5961 for (i, child) in children.iter().enumerate() {
5962 let child_style = child.style.resolve(Some(parent_style), available_width);
5963 let child_height = self.measure_node_height(
5964 child,
5965 available_width,
5966 &child_style,
5967 font_context,
5968 );
5969 total += child_height + child_style.margin.vertical();
5970 if i > 0 {
5971 total += row_gap;
5972 }
5973 }
5974 total
5975 }
5976 }
5977 }
5978
5979 fn measure_intrinsic_width(
5981 &self,
5982 node: &Node,
5983 style: &ResolvedStyle,
5984 font_context: &FontContext,
5985 ) -> f64 {
5986 match &node.kind {
5987 NodeKind::Svg { width, .. } => {
5988 *width + style.padding.horizontal() + style.margin.horizontal()
5989 }
5990 NodeKind::Text { content, runs, .. } | NodeKind::Heading { content, runs, .. } => {
5991 let text_width = if !runs.is_empty() {
5997 runs.iter()
5998 .map(|run| {
5999 let run_style = run.style.resolve(Some(style), 0.0);
6000 let run_content = substitute_page_placeholders(&run.content);
6001 let transformed =
6002 apply_text_transform(&run_content, run_style.text_transform);
6003 let italic = matches!(
6004 run_style.font_style,
6005 FontStyle::Italic | FontStyle::Oblique
6006 );
6007 transformed
6011 .split('\n')
6012 .map(|segment| {
6013 font_context.measure_string(
6014 segment,
6015 &run_style.font_family,
6016 run_style.font_weight,
6017 italic,
6018 run_style.font_size,
6019 run_style.letter_spacing,
6020 )
6021 })
6022 .fold(0.0f64, f64::max)
6023 })
6024 .sum()
6025 } else {
6026 let content = substitute_page_placeholders(content);
6027 let transformed = apply_text_transform(&content, style.text_transform);
6028 let italic = matches!(style.font_style, FontStyle::Italic | FontStyle::Oblique);
6029 transformed
6030 .split('\n')
6031 .map(|segment| {
6032 font_context.measure_string(
6033 segment,
6034 &style.font_family,
6035 style.font_weight,
6036 italic,
6037 style.font_size,
6038 style.letter_spacing,
6039 )
6040 })
6041 .fold(0.0f64, f64::max)
6042 };
6043 text_width + 0.01 + style.padding.horizontal() + style.margin.horizontal()
6046 }
6047 NodeKind::Image {
6048 src, width, height, ..
6049 } => {
6050 let w = if let SizeConstraint::Fixed(w) = style.width {
6051 w
6052 } else if let Some(w) = width {
6053 *w
6054 } else if let Some((iw, ih)) = self.get_image_dimensions(src) {
6055 let pixel_w = iw as f64;
6056 let pixel_h = ih as f64;
6057 let aspect = if pixel_w > 0.0 {
6058 pixel_h / pixel_w
6059 } else {
6060 0.75
6061 };
6062 let constrained_h = match style.height {
6064 SizeConstraint::Fixed(h) => Some(h),
6065 SizeConstraint::Auto => *height,
6066 };
6067 if let Some(h) = constrained_h {
6068 h / aspect
6069 } else {
6070 pixel_w
6071 }
6072 } else {
6073 100.0
6074 };
6075 w + style.padding.horizontal() + style.margin.horizontal()
6076 }
6077 NodeKind::Barcode { width, .. } => {
6078 let w = width.unwrap_or(0.0);
6079 w + style.padding.horizontal() + style.margin.horizontal()
6080 }
6081 NodeKind::QrCode { size, .. } => {
6082 let display_size = size.unwrap_or(0.0);
6083 display_size + style.padding.horizontal() + style.margin.horizontal()
6084 }
6085 NodeKind::Canvas { width, .. } => {
6086 *width + style.padding.horizontal() + style.margin.horizontal()
6087 }
6088 NodeKind::BarChart { width, .. }
6089 | NodeKind::LineChart { width, .. }
6090 | NodeKind::PieChart { width, .. }
6091 | NodeKind::AreaChart { width, .. }
6092 | NodeKind::DotPlot { width, .. } => {
6093 *width + style.padding.horizontal() + style.margin.horizontal()
6094 }
6095 NodeKind::TextField { width, .. } | NodeKind::Dropdown { width, .. } => {
6096 *width + style.padding.horizontal() + style.margin.horizontal()
6097 }
6098 NodeKind::Checkbox { width, .. } | NodeKind::RadioButton { width, .. } => {
6099 *width + style.padding.horizontal() + style.margin.horizontal()
6100 }
6101 NodeKind::Watermark { .. } => 0.0, _ => {
6103 if node.children.is_empty() {
6105 style.padding.horizontal() + style.margin.horizontal()
6106 } else {
6107 let direction = style.flex_direction;
6108 let gap = style.gap;
6109 let mut total = 0.0f64;
6110 for (i, child) in node.children.iter().enumerate() {
6111 let child_style = child.style.resolve(Some(style), 0.0);
6112 let child_width =
6113 self.measure_intrinsic_width(child, &child_style, font_context);
6114 match direction {
6115 FlexDirection::Row | FlexDirection::RowReverse => {
6116 total += child_width;
6117 if i > 0 {
6118 total += gap;
6119 }
6120 }
6121 _ => {
6122 total = total.max(child_width);
6123 }
6124 }
6125 }
6126 total
6127 + style.padding.horizontal()
6128 + style.margin.horizontal()
6129 + style.border_width.horizontal()
6130 }
6131 }
6132 }
6133 }
6134
6135 pub fn measure_min_content_width(
6139 &self,
6140 node: &Node,
6141 style: &ResolvedStyle,
6142 font_context: &FontContext,
6143 ) -> f64 {
6144 match &node.kind {
6145 NodeKind::Text { content, runs, .. } | NodeKind::Heading { content, runs, .. } => {
6146 let word_width = if !runs.is_empty() {
6147 runs.iter()
6149 .map(|run| {
6150 let run_style = run.style.resolve(Some(style), 0.0);
6151 let run_content = substitute_page_placeholders(&run.content);
6152 let transformed =
6153 apply_text_transform(&run_content, run_style.text_transform);
6154 self.text_layout.measure_widest_word(
6155 font_context,
6156 &transformed,
6157 run_style.font_size,
6158 &run_style.font_family,
6159 run_style.font_weight,
6160 run_style.font_style,
6161 run_style.letter_spacing,
6162 style.hyphens,
6163 style.lang.as_deref(),
6164 )
6165 })
6166 .fold(0.0f64, f64::max)
6167 } else {
6168 let content = substitute_page_placeholders(content);
6169 let transformed = apply_text_transform(&content, style.text_transform);
6170 self.text_layout.measure_widest_word(
6171 font_context,
6172 &transformed,
6173 style.font_size,
6174 &style.font_family,
6175 style.font_weight,
6176 style.font_style,
6177 style.letter_spacing,
6178 style.hyphens,
6179 style.lang.as_deref(),
6180 )
6181 };
6182 word_width + style.padding.horizontal() + style.margin.horizontal()
6183 }
6184 NodeKind::Image { width, .. } => {
6185 width.unwrap_or(0.0) + style.padding.horizontal() + style.margin.horizontal()
6186 }
6187 NodeKind::Svg { width, .. } => {
6188 *width + style.padding.horizontal() + style.margin.horizontal()
6189 }
6190 _ => {
6191 if node.children.is_empty() {
6192 style.padding.horizontal()
6193 + style.margin.horizontal()
6194 + style.border_width.horizontal()
6195 } else {
6196 let mut max_child_min = 0.0f64;
6197 for child in &node.children {
6198 let child_style = child.style.resolve(Some(style), 0.0);
6199 let child_min =
6200 self.measure_min_content_width(child, &child_style, font_context);
6201 max_child_min = max_child_min.max(child_min);
6202 }
6203 max_child_min
6204 + style.padding.horizontal()
6205 + style.margin.horizontal()
6206 + style.border_width.horizontal()
6207 }
6208 }
6209 }
6210 }
6211
6212 fn cell_first_line_font_size(&self, cell: &Node, cell_style: &ResolvedStyle, w: f64) -> f64 {
6218 fn first(node: &Node, parent: &ResolvedStyle, w: f64) -> Option<f64> {
6219 for ch in &node.children {
6220 let s = ch.style.resolve(Some(parent), w);
6221 match &ch.kind {
6222 NodeKind::Text { .. } | NodeKind::Heading { .. } => return Some(s.font_size),
6223 _ => {
6224 if let Some(f) = first(ch, &s, w) {
6225 return Some(f);
6226 }
6227 }
6228 }
6229 }
6230 None
6231 }
6232 first(cell, cell_style, w).unwrap_or(cell_style.font_size)
6233 }
6234
6235 fn cell_baseline_distance(
6238 &self,
6239 cell: &Node,
6240 cell_style: &ResolvedStyle,
6241 inner_width: f64,
6242 ) -> f64 {
6243 cell_style.padding.top
6244 + cell_style.border_width.top
6245 + self.cell_first_line_font_size(cell, cell_style, inner_width)
6246 }
6247
6248 fn row_baseline(
6251 &self,
6252 row: &Node,
6253 row_style: &ResolvedStyle,
6254 col_widths: &[f64],
6255 ) -> Option<f64> {
6256 let mut b: Option<f64> = None;
6257 let mut col_idx = 0usize;
6258 for cell in row.children.iter() {
6259 let span = match &cell.kind {
6260 NodeKind::TableCell { col_span, .. } => (*col_span).max(1) as usize,
6261 _ => 1,
6262 };
6263 let col_width: f64 = col_widths.iter().skip(col_idx).take(span).copied().sum();
6264 col_idx += span;
6265 let cell_style = cell.style.resolve(Some(row_style), col_width);
6266 if matches!(cell_style.vertical_align, VerticalAlign::Baseline) {
6267 let iw = col_width
6268 - cell_style.padding.horizontal()
6269 - cell_style.border_width.horizontal();
6270 let d = self.cell_baseline_distance(cell, &cell_style, iw);
6271 b = Some(b.map_or(d, |m: f64| m.max(d)));
6272 }
6273 }
6274 b
6275 }
6276
6277 fn measure_table_row_height(
6278 &self,
6279 row: &Node,
6280 col_widths: &[f64],
6281 parent_style: &ResolvedStyle,
6282 font_context: &FontContext,
6283 ) -> f64 {
6284 let row_style = row
6285 .style
6286 .resolve(Some(parent_style), col_widths.iter().sum());
6287 let mut max_height: f64 = 0.0;
6288 let row_bl = self.row_baseline(row, &row_style, col_widths);
6291
6292 let mut col_idx = 0usize;
6293 for cell in row.children.iter() {
6294 let span = match &cell.kind {
6295 NodeKind::TableCell { col_span, .. } => (*col_span).max(1) as usize,
6296 _ => 1,
6297 };
6298 let col_width: f64 = col_widths.iter().skip(col_idx).take(span).copied().sum();
6299 col_idx += span;
6300 let cell_style = cell.style.resolve(Some(&row_style), col_width);
6301 let inner_width =
6302 col_width - cell_style.padding.horizontal() - cell_style.border_width.horizontal();
6303
6304 let mut cell_content_height = 0.0;
6305 for child in &cell.children {
6306 let child_style = child.style.resolve(Some(&cell_style), inner_width);
6307 cell_content_height +=
6308 self.measure_node_height(child, inner_width, &child_style, font_context);
6309 }
6310
6311 let mut total = cell_content_height
6312 + cell_style.padding.vertical()
6313 + cell_style.border_width.vertical();
6314 if matches!(cell_style.vertical_align, VerticalAlign::Baseline) {
6318 if let Some(b) = row_bl {
6319 let d = self.cell_baseline_distance(cell, &cell_style, inner_width);
6320 total += (b - d).max(0.0);
6321 }
6322 }
6323 if let SizeConstraint::Fixed(h) = cell_style.height {
6330 total = total.max(h);
6331 }
6332 max_height = max_height.max(total);
6333 }
6334
6335 max_height.max(row_style.min_height)
6336 }
6337
6338 fn cell_col_span(cell: &Node) -> usize {
6340 match &cell.kind {
6341 NodeKind::TableCell { col_span, .. } => (*col_span).max(1) as usize,
6342 _ => 1,
6343 }
6344 }
6345
6346 fn measure_column_content(
6351 &self,
6352 children: &[Node],
6353 num_cols: usize,
6354 available_width: f64,
6355 table_style: &ResolvedStyle,
6356 font_context: &FontContext,
6357 ) -> (Vec<f64>, Vec<f64>) {
6358 let mut col_min = vec![0.0f64; num_cols];
6359 let mut col_max = vec![0.0f64; num_cols];
6360 for row_node in children {
6361 let mut col = 0usize;
6362 for cell in &row_node.children {
6363 let span = Self::cell_col_span(cell);
6364 let cell_style = cell.style.resolve(Some(table_style), available_width);
6365 let chrome = cell_style.padding.horizontal() + cell_style.border_width.horizontal();
6366 let mut cmin = 0.0f64;
6367 let mut cmax = 0.0f64;
6368 for child in &cell.children {
6369 let child_style = child.style.resolve(Some(&cell_style), 0.0);
6370 cmin =
6371 cmin.max(self.measure_min_content_width(child, &child_style, font_context));
6372 cmax =
6373 cmax.max(self.measure_intrinsic_width(child, &child_style, font_context));
6374 }
6375 cmin += chrome;
6376 let mut cmax = cmax.max(cmin) + chrome;
6377 if let SizeConstraint::Fixed(w) = cell_style.width {
6378 cmax = w.max(cmin);
6379 }
6380 let per_min = cmin / span as f64;
6381 let per_max = cmax / span as f64;
6382 for k in col..(col + span).min(num_cols) {
6383 col_min[k] = col_min[k].max(per_min);
6384 col_max[k] = col_max[k].max(per_max);
6385 }
6386 col += span;
6387 }
6388 }
6389 (col_min, col_max)
6390 }
6391
6392 fn resolve_column_widths(
6402 &self,
6403 defs: &[ColumnDef],
6404 available_width: f64,
6405 children: &[Node],
6406 table_style: &ResolvedStyle,
6407 font_context: &FontContext,
6408 ) -> Vec<f64> {
6409 if defs.is_empty() {
6410 let num_cols = children
6411 .iter()
6412 .map(|row| row.children.iter().map(Self::cell_col_span).sum::<usize>())
6413 .max()
6414 .unwrap_or(1)
6415 .max(1);
6416
6417 let (col_min, col_max) = self.measure_column_content(
6418 children,
6419 num_cols,
6420 available_width,
6421 table_style,
6422 font_context,
6423 );
6424
6425 let sum_min: f64 = col_min.iter().sum();
6426 let sum_max: f64 = col_max.iter().sum();
6427 let w = available_width;
6428 return if sum_max <= w {
6429 if sum_max <= f64::EPSILON {
6433 vec![w / num_cols as f64; num_cols]
6434 } else {
6435 col_max
6436 .iter()
6437 .map(|m| m + (w - sum_max) * (m / sum_max))
6438 .collect()
6439 }
6440 } else if sum_min <= w {
6441 let denom = (sum_max - sum_min).max(f64::EPSILON);
6444 col_min
6445 .iter()
6446 .zip(&col_max)
6447 .map(|(mn, mx)| mn + (w - sum_min) * ((mx - mn) / denom))
6448 .collect()
6449 } else {
6450 self.defect(format!(
6453 "render defect: table columns need {:.0}pt at min-content but only {:.0}pt is available — text will wrap tighter than intended",
6454 sum_min, w
6455 ));
6456 let scale = w / sum_min.max(f64::EPSILON);
6457 col_min.iter().map(|m| m * scale).collect()
6458 };
6459 }
6460
6461 let num_cols = children
6467 .iter()
6468 .map(|row| row.children.iter().map(Self::cell_col_span).sum::<usize>())
6469 .max()
6470 .unwrap_or(defs.len())
6471 .max(defs.len());
6472 let mut defs_vec: Vec<ColumnDef> = defs.to_vec();
6473 while defs_vec.len() < num_cols {
6474 defs_vec.push(ColumnDef {
6475 width: ColumnWidth::Auto,
6476 });
6477 }
6478 let defs = &defs_vec[..];
6479
6480 let mut widths = Vec::new();
6481 let mut remaining = available_width;
6482 let mut auto_count = 0;
6483
6484 for def in defs {
6485 match def.width {
6486 ColumnWidth::Fixed(w) => {
6487 widths.push(w);
6488 remaining -= w;
6489 }
6490 ColumnWidth::Fraction(f) => {
6491 let w = available_width * f;
6492 widths.push(w);
6493 remaining -= w;
6494 }
6495 ColumnWidth::Auto => {
6496 widths.push(0.0);
6497 auto_count += 1;
6498 }
6499 }
6500 }
6501
6502 if remaining < 0.0 {
6503 self.defect(format!(
6506 "render defect: table column widths total {:.0}pt but only {:.0}pt is available — remaining columns were clamped to their minimum",
6507 available_width - remaining,
6508 available_width
6509 ));
6510 remaining = 0.0;
6511 }
6512
6513 if auto_count > 0 {
6514 let auto_width = remaining / auto_count as f64;
6515 for (i, def) in defs.iter().enumerate() {
6516 if matches!(def.width, ColumnWidth::Auto) {
6517 widths[i] = auto_width;
6518 }
6519 }
6520 }
6521
6522 let (col_min, _) = self.measure_column_content(
6529 children,
6530 widths.len(),
6531 available_width,
6532 table_style,
6533 font_context,
6534 );
6535 let needs_floor = widths.iter().zip(&col_min).any(|(w, m)| *w + 0.01 < *m);
6536 if needs_floor {
6537 let sum_min: f64 = col_min.iter().sum();
6538 if sum_min > available_width {
6539 self.defect(format!(
6540 "render defect: table columns need {:.0}pt at min-content but only {:.0}pt is available — text will wrap tighter than intended",
6541 sum_min, available_width
6542 ));
6543 let scale = available_width / sum_min.max(f64::EPSILON);
6544 return col_min.iter().map(|m| m * scale).collect();
6545 }
6546 let deficit: f64 = widths
6547 .iter()
6548 .zip(&col_min)
6549 .map(|(w, m)| (m - w).max(0.0))
6550 .sum();
6551 let surplus: f64 = widths
6552 .iter()
6553 .zip(&col_min)
6554 .map(|(w, m)| (w - m).max(0.0))
6555 .sum();
6556 let take = if surplus > 0.0 {
6557 deficit / surplus
6558 } else {
6559 0.0
6560 };
6561 widths = widths
6562 .iter()
6563 .zip(&col_min)
6564 .map(|(w, m)| if *w < *m { *m } else { w - (w - m) * take })
6565 .collect();
6566 }
6567
6568 widths
6569 }
6570
6571 fn inject_fixed_elements(&self, pages: &mut [LayoutPage], font_context: &FontContext) {
6572 for (page_index, page) in pages.iter_mut().enumerate() {
6573 if !page.watermarks.is_empty() {
6575 let (page_w, page_h) = page.config.size.dimensions();
6576 let cx = page_w / 2.0;
6577 let cy = page_h / 2.0;
6578
6579 let mut watermark_elements = Vec::new();
6580 for wm_node in &page.watermarks {
6581 if let NodeKind::Watermark {
6582 text,
6583 font_size,
6584 angle,
6585 } = &wm_node.kind
6586 {
6587 let style = wm_node.style.resolve(None, page_w);
6588 let color = style.color;
6589 let opacity = style.opacity;
6590 let angle_rad = angle.to_radians();
6591
6592 let italic =
6594 matches!(style.font_style, FontStyle::Italic | FontStyle::Oblique);
6595
6596 let shaped = self.text_layout.shape_text(
6598 font_context,
6599 text,
6600 &style.font_family,
6601 style.font_weight,
6602 style.font_style,
6603 );
6604
6605 let mut glyphs = Vec::new();
6606 let mut x_pos = 0.0;
6607 let text_chars: Vec<char> = text.chars().collect();
6608
6609 if let Some(shaped_glyphs) = shaped {
6610 let units_per_em = font_context.units_per_em(
6612 &style.font_family,
6613 style.font_weight,
6614 italic,
6615 ) as f64;
6616
6617 for sg in &shaped_glyphs {
6618 let advance = sg.x_advance as f64 / units_per_em * *font_size;
6619 let cluster_idx = sg.cluster as usize;
6620 let ch = text_chars.get(cluster_idx).copied().unwrap_or(' ');
6621 glyphs.push(PositionedGlyph {
6622 glyph_id: sg.glyph_id,
6623 char_value: ch,
6624 x_offset: x_pos,
6625 y_offset: 0.0,
6626 x_advance: advance,
6627 font_size: *font_size,
6628 font_family: Arc::from(style.font_family.as_str()),
6629 font_weight: style.font_weight,
6630 font_style: style.font_style,
6631 color: Some(color),
6632 href: None,
6633 text_decoration: TextDecoration::None,
6634 letter_spacing: style.letter_spacing,
6635 cluster_text: None,
6636 });
6637 x_pos += advance + style.letter_spacing;
6638 }
6639 } else {
6640 for &ch in &text_chars {
6642 let w = font_context.char_width(
6643 ch,
6644 &style.font_family,
6645 style.font_weight,
6646 italic,
6647 *font_size,
6648 );
6649 glyphs.push(PositionedGlyph {
6650 glyph_id: ch as u16,
6651 char_value: ch,
6652 x_offset: x_pos,
6653 y_offset: 0.0,
6654 x_advance: w,
6655 font_size: *font_size,
6656 font_family: Arc::from(style.font_family.as_str()),
6657 font_weight: style.font_weight,
6658 font_style: style.font_style,
6659 color: Some(color),
6660 href: None,
6661 text_decoration: TextDecoration::None,
6662 letter_spacing: style.letter_spacing,
6663 cluster_text: None,
6664 });
6665 x_pos += w + style.letter_spacing;
6666 }
6667 }
6668
6669 let text_width = x_pos;
6670
6671 let line = TextLine {
6672 x: 0.0,
6673 y: 0.0,
6674 glyphs,
6675 width: text_width,
6676 height: *font_size,
6677 word_spacing: 0.0,
6678 };
6679
6680 watermark_elements.push(LayoutElement {
6681 x: cx,
6682 y: cy,
6683 width: text_width,
6684 height: *font_size,
6685 draw: DrawCommand::Watermark {
6686 lines: vec![line],
6687 color,
6688 opacity,
6689 angle_rad,
6690 font_family: style.font_family.clone(),
6691 },
6692 children: vec![],
6693 node_type: Some("Watermark".to_string()),
6694 resolved_style: None,
6695 source_location: None,
6696 href: None,
6697 bookmark: None,
6698 alt: None,
6699 is_header_row: false,
6700 col_span: 1,
6701 overflow: Overflow::default(),
6702 opacity: 1.0,
6703 });
6704 }
6705 }
6706
6707 watermark_elements.append(&mut page.elements);
6709 page.elements = watermark_elements;
6710 page.watermarks.clear();
6711 }
6712
6713 if page.fixed_header.is_empty() && page.fixed_footer.is_empty() {
6714 continue;
6715 }
6716
6717 if !page.fixed_header.is_empty() {
6719 let mut hdr_cursor = PageCursor::new(&page.config);
6720 for (node, _h) in &page.fixed_header {
6721 if !fixed_applies_on(node, page_index, page.page_name.as_deref()) {
6724 continue;
6725 }
6726 let cw = hdr_cursor.content_width;
6727 let cx = hdr_cursor.content_x;
6728 let style = node.style.resolve(None, cw);
6729 self.layout_view(
6730 node,
6731 &style,
6732 &mut hdr_cursor,
6733 &mut Vec::new(),
6734 cx,
6735 cw,
6736 font_context,
6737 );
6738 }
6739 let mut combined = hdr_cursor.elements;
6741 combined.append(&mut page.elements);
6742 page.elements = combined;
6743 }
6744
6745 if !page.fixed_footer.is_empty() {
6750 let mut ftr_cursor = PageCursor::new(&page.config);
6751 let total_ftr: f64 = page
6752 .fixed_footer
6753 .iter()
6754 .filter(|(n, _)| fixed_applies_on(n, page_index, page.page_name.as_deref()))
6755 .map(|(_, h)| *h)
6756 .sum();
6757 let target_y = ftr_cursor.content_height - total_ftr;
6758 for (node, _h) in &page.fixed_footer {
6760 if !fixed_applies_on(node, page_index, page.page_name.as_deref()) {
6761 continue;
6762 }
6763 let cw = ftr_cursor.content_width;
6764 let cx = ftr_cursor.content_x;
6765 let style = node.style.resolve(None, cw);
6766 self.layout_view(
6767 node,
6768 &style,
6769 &mut ftr_cursor,
6770 &mut Vec::new(),
6771 cx,
6772 cw,
6773 font_context,
6774 );
6775 }
6776 for el in &mut ftr_cursor.elements {
6780 offset_element_y(el, target_y);
6781 }
6782 page.elements.extend(ftr_cursor.elements);
6783 }
6784
6785 page.fixed_header.clear();
6787 page.fixed_footer.clear();
6788 }
6789 }
6790
6791 #[allow(clippy::too_many_arguments)]
6796 fn layout_grid_children(
6797 &self,
6798 children: &[Node],
6799 parent_style: &ResolvedStyle,
6800 cursor: &mut PageCursor,
6801 pages: &mut Vec<LayoutPage>,
6802 x: f64,
6803 available_width: f64,
6804 font_context: &FontContext,
6805 ) {
6806 let template_cols = match &parent_style.grid_template_columns {
6807 Some(cols) => cols,
6808 None => return, };
6810
6811 let num_columns = template_cols.len();
6812 if num_columns == 0 || children.is_empty() {
6813 return;
6814 }
6815
6816 let col_gap = parent_style.column_gap;
6817 let row_gap = parent_style.row_gap;
6818
6819 let content_sizes: Vec<f64> = template_cols
6822 .iter()
6823 .map(|track| {
6824 if matches!(track, GridTrackSize::Auto) {
6825 available_width / num_columns as f64
6828 } else {
6829 0.0
6830 }
6831 })
6832 .collect();
6833
6834 let col_widths =
6835 grid::resolve_tracks(template_cols, available_width, col_gap, &content_sizes);
6836
6837 let placements: Vec<Option<&GridPlacement>> = children
6839 .iter()
6840 .map(|child| child.style.grid_placement.as_ref())
6841 .collect();
6842
6843 let item_placements = grid::place_items(&placements, num_columns);
6845 let num_rows = grid::compute_num_rows(&item_placements);
6846
6847 if num_rows == 0 {
6848 return;
6849 }
6850
6851 let mut item_heights: Vec<f64> = vec![0.0; children.len()];
6853 for placement in &item_placements {
6854 let cell_width =
6855 grid::span_width(placement.col_start, placement.col_end, &col_widths, col_gap);
6856 let child = &children[placement.child_index];
6857 let child_style = child.style.resolve(Some(parent_style), cell_width);
6858 item_heights[placement.child_index] =
6859 self.measure_node_height(child, cell_width, &child_style, font_context);
6860 }
6861
6862 let template_rows = parent_style.grid_template_rows.as_deref();
6864 let mut row_heights = vec![0.0_f64; num_rows];
6865 for placement in &item_placements {
6866 let h = item_heights[placement.child_index];
6867 let span = placement.row_end - placement.row_start;
6868 let per_row = h / span as f64;
6869 for rh in row_heights
6870 .iter_mut()
6871 .take(placement.row_end.min(num_rows))
6872 .skip(placement.row_start)
6873 {
6874 if per_row > *rh {
6875 *rh = per_row;
6876 }
6877 }
6878 }
6879
6880 if let Some(template) = template_rows {
6882 let auto_row = parent_style.grid_auto_rows.as_ref();
6883 for (r, rh) in row_heights.iter_mut().enumerate() {
6884 let track = template.get(r).or(auto_row);
6885 if let Some(track) = track {
6886 match track {
6887 GridTrackSize::Pt(pts) => *rh = *pts,
6888 GridTrackSize::Auto => {} _ => {} }
6891 }
6892 }
6893 }
6894
6895 for (row, &row_height) in row_heights.iter().enumerate().take(num_rows) {
6897 if row_height > cursor.remaining_height() {
6902 pages.push(cursor.finalize());
6903 *cursor = cursor.new_page();
6904 }
6905
6906 let row_start_y = cursor.y;
6907
6908 for placement in &item_placements {
6910 if placement.row_start != row {
6911 continue; }
6913
6914 let cell_x = x + grid::column_x_offset(placement.col_start, &col_widths, col_gap);
6915 let cell_width =
6916 grid::span_width(placement.col_start, placement.col_end, &col_widths, col_gap);
6917
6918 let child = &children[placement.child_index];
6919
6920 self.layout_node(
6921 child,
6922 cursor,
6923 pages,
6924 cell_x,
6925 cell_width,
6926 Some(parent_style),
6927 font_context,
6928 None,
6929 None,
6930 );
6931 cursor.y = row_start_y;
6933 }
6934
6935 cursor.y = row_start_y + row_height + row_gap;
6936 }
6937
6938 if num_rows > 0 {
6940 cursor.y -= row_gap;
6941 }
6942 }
6943}
6944
6945struct FlexItem<'a> {
6946 node: &'a Node,
6947 style: ResolvedStyle,
6948 base_width: f64,
6949 min_content_width: f64,
6950}
6951
6952#[cfg(test)]
6953mod tests {
6954 use super::*;
6955 use crate::font::FontContext;
6956
6957 fn make_text(content: &str, font_size: f64) -> Node {
6958 Node {
6959 kind: NodeKind::Text {
6960 content: content.to_string(),
6961 href: None,
6962 runs: vec![],
6963 },
6964 style: Style {
6965 font_size: Some(font_size),
6966 ..Default::default()
6967 },
6968 children: vec![],
6969 id: None,
6970 source_location: None,
6971 bookmark: None,
6972 href: None,
6973 alt: None,
6974 }
6975 }
6976
6977 fn make_styled_view(style: Style, children: Vec<Node>) -> Node {
6978 Node {
6979 kind: NodeKind::View,
6980 style,
6981 children,
6982 id: None,
6983 source_location: None,
6984 bookmark: None,
6985 href: None,
6986 alt: None,
6987 }
6988 }
6989
6990 fn make_runs_text(runs: Vec<crate::model::TextRun>) -> Node {
6991 Node {
6992 kind: NodeKind::Text {
6993 content: String::new(),
6994 href: None,
6995 runs,
6996 },
6997 style: Style::default(),
6998 children: vec![],
6999 id: None,
7000 source_location: None,
7001 bookmark: None,
7002 href: None,
7003 alt: None,
7004 }
7005 }
7006
7007 #[test]
7008 fn intrinsic_width_measures_runs_not_just_content() {
7009 let engine = LayoutEngine::new();
7013 let font_context = FontContext::new();
7014
7015 let runs_node = make_runs_text(vec![
7016 crate::model::TextRun {
7017 content: "Hello ".to_string(),
7018 style: Style::default(),
7019 href: None,
7020 },
7021 crate::model::TextRun {
7022 content: "World".to_string(),
7023 style: Style {
7024 font_weight: Some(700),
7025 ..Default::default()
7026 },
7027 href: None,
7028 },
7029 ]);
7030 let plain_node = make_text("Hello World", 12.0);
7031
7032 let runs_style = runs_node.style.resolve(None, 0.0);
7033 let plain_style = plain_node.style.resolve(None, 0.0);
7034 let runs_w = engine.measure_intrinsic_width(&runs_node, &runs_style, &font_context);
7035 let plain_w = engine.measure_intrinsic_width(&plain_node, &plain_style, &font_context);
7036
7037 assert!(
7040 runs_w >= plain_w,
7041 "runs width ({runs_w}) must not undershoot plain width ({plain_w})"
7042 );
7043 assert!(
7044 runs_w < plain_w * 1.5,
7045 "runs width ({runs_w}) should be close to plain width ({plain_w})"
7046 );
7047 }
7048
7049 #[test]
7050 fn intrinsic_width_of_multiline_text_is_widest_line() {
7051 let engine = LayoutEngine::new();
7052 let font_context = FontContext::new();
7053
7054 let multiline = make_text("123 Main St\nSpringfield, IL 62704", 12.0);
7055 let widest = make_text("Springfield, IL 62704", 12.0);
7056
7057 let m_style = multiline.style.resolve(None, 0.0);
7058 let w_style = widest.style.resolve(None, 0.0);
7059 let m_w = engine.measure_intrinsic_width(&multiline, &m_style, &font_context);
7060 let w_w = engine.measure_intrinsic_width(&widest, &w_style, &font_context);
7061
7062 assert!(
7063 (m_w - w_w).abs() < 0.01,
7064 "multiline intrinsic width ({m_w}) must equal its widest line ({w_w})"
7065 );
7066 }
7067
7068 #[test]
7069 fn intrinsic_width_of_heading_measures_its_text() {
7070 let engine = LayoutEngine::new();
7073 let font_context = FontContext::new();
7074
7075 let heading = Node {
7076 kind: NodeKind::Heading {
7077 level: 1,
7078 content: "Invoice #2024-001".to_string(),
7079 href: None,
7080 runs: vec![],
7081 },
7082 style: Style {
7083 font_size: Some(24.0),
7084 ..Default::default()
7085 },
7086 children: vec![],
7087 id: None,
7088 source_location: None,
7089 bookmark: None,
7090 href: None,
7091 alt: None,
7092 };
7093 let style = heading.style.resolve(None, 0.0);
7094 let w = engine.measure_intrinsic_width(&heading, &style, &font_context);
7095 assert!(w > 100.0, "24pt heading text must measure wide, got {w}");
7096 }
7097
7098 #[test]
7099 fn measure_node_height_of_wrapping_heading_matches_text() {
7100 let engine = LayoutEngine::new();
7107 let font_context = FontContext::new();
7108
7109 let content = "Annual Performance Review";
7110 let heading = Node {
7111 kind: NodeKind::Heading {
7112 level: 1,
7113 content: content.to_string(),
7114 href: None,
7115 runs: vec![],
7116 },
7117 style: Style {
7118 font_size: Some(32.0),
7119 ..Default::default()
7120 },
7121 children: vec![],
7122 id: None,
7123 source_location: None,
7124 bookmark: None,
7125 href: None,
7126 alt: None,
7127 };
7128 let text = make_text(content, 32.0);
7129
7130 let width = 200.0;
7132 let h_style = heading.style.resolve(None, width);
7133 let t_style = text.style.resolve(None, width);
7134 let h_height = engine.measure_node_height(&heading, width, &h_style, &font_context);
7135 let t_height = engine.measure_node_height(&text, width, &t_style, &font_context);
7136
7137 assert!(
7138 h_height > 32.0,
7139 "a wrapping 32pt heading must measure more than one line, got {h_height}"
7140 );
7141 assert!(
7142 (h_height - t_height).abs() < 0.01,
7143 "heading height ({h_height}) must equal the same text's height ({t_height})"
7144 );
7145 }
7146
7147 #[test]
7148 fn intrinsic_width_flex_row_sums_children() {
7149 let engine = LayoutEngine::new();
7150 let font_context = FontContext::new();
7151
7152 let child1 = make_text("Hello", 14.0);
7153 let child2 = make_text("World", 14.0);
7154
7155 let child1_style = child1.style.resolve(None, 0.0);
7156 let child2_style = child2.style.resolve(None, 0.0);
7157 let child1_w = engine.measure_intrinsic_width(&child1, &child1_style, &font_context);
7158 let child2_w = engine.measure_intrinsic_width(&child2, &child2_style, &font_context);
7159
7160 let row = make_styled_view(
7161 Style {
7162 flex_direction: Some(FlexDirection::Row),
7163 ..Default::default()
7164 },
7165 vec![make_text("Hello", 14.0), make_text("World", 14.0)],
7166 );
7167 let row_style = row.style.resolve(None, 0.0);
7168 let row_w = engine.measure_intrinsic_width(&row, &row_style, &font_context);
7169
7170 assert!(
7171 (row_w - (child1_w + child2_w)).abs() < 0.01,
7172 "Row intrinsic width ({}) should equal sum of children ({} + {})",
7173 row_w,
7174 child1_w,
7175 child2_w
7176 );
7177 }
7178
7179 #[test]
7180 fn intrinsic_width_flex_column_takes_max() {
7181 let engine = LayoutEngine::new();
7182 let font_context = FontContext::new();
7183
7184 let short = make_text("Hi", 14.0);
7185 let long = make_text("Hello World", 14.0);
7186
7187 let short_style = short.style.resolve(None, 0.0);
7188 let long_style = long.style.resolve(None, 0.0);
7189 let short_w = engine.measure_intrinsic_width(&short, &short_style, &font_context);
7190 let long_w = engine.measure_intrinsic_width(&long, &long_style, &font_context);
7191
7192 let col = make_styled_view(
7193 Style {
7194 flex_direction: Some(FlexDirection::Column),
7195 ..Default::default()
7196 },
7197 vec![make_text("Hi", 14.0), make_text("Hello World", 14.0)],
7198 );
7199 let col_style = col.style.resolve(None, 0.0);
7200 let col_w = engine.measure_intrinsic_width(&col, &col_style, &font_context);
7201
7202 assert!(
7203 (col_w - long_w).abs() < 0.01,
7204 "Column intrinsic width ({}) should equal max child ({}, short was {})",
7205 col_w,
7206 long_w,
7207 short_w
7208 );
7209 }
7210
7211 #[test]
7212 fn intrinsic_width_nested_containers() {
7213 let engine = LayoutEngine::new();
7214 let font_context = FontContext::new();
7215
7216 let inner = make_styled_view(
7217 Style {
7218 flex_direction: Some(FlexDirection::Row),
7219 ..Default::default()
7220 },
7221 vec![make_text("A", 12.0), make_text("B", 12.0)],
7222 );
7223 let inner_style = inner.style.resolve(None, 0.0);
7224 let inner_w = engine.measure_intrinsic_width(&inner, &inner_style, &font_context);
7225
7226 let outer = make_styled_view(
7227 Style::default(),
7228 vec![make_styled_view(
7229 Style {
7230 flex_direction: Some(FlexDirection::Row),
7231 ..Default::default()
7232 },
7233 vec![make_text("A", 12.0), make_text("B", 12.0)],
7234 )],
7235 );
7236 let outer_style = outer.style.resolve(None, 0.0);
7237 let outer_w = engine.measure_intrinsic_width(&outer, &outer_style, &font_context);
7238
7239 assert!(
7240 (outer_w - inner_w).abs() < 0.01,
7241 "Nested container ({}) should match inner container ({})",
7242 outer_w,
7243 inner_w
7244 );
7245 }
7246
7247 #[test]
7248 fn intrinsic_width_row_with_gap() {
7249 let engine = LayoutEngine::new();
7250 let font_context = FontContext::new();
7251
7252 let no_gap = make_styled_view(
7253 Style {
7254 flex_direction: Some(FlexDirection::Row),
7255 ..Default::default()
7256 },
7257 vec![make_text("A", 12.0), make_text("B", 12.0)],
7258 );
7259 let with_gap = make_styled_view(
7260 Style {
7261 flex_direction: Some(FlexDirection::Row),
7262 gap: Some(10.0),
7263 ..Default::default()
7264 },
7265 vec![make_text("A", 12.0), make_text("B", 12.0)],
7266 );
7267
7268 let no_gap_style = no_gap.style.resolve(None, 0.0);
7269 let with_gap_style = with_gap.style.resolve(None, 0.0);
7270 let no_gap_w = engine.measure_intrinsic_width(&no_gap, &no_gap_style, &font_context);
7271 let with_gap_w = engine.measure_intrinsic_width(&with_gap, &with_gap_style, &font_context);
7272
7273 assert!(
7274 (with_gap_w - no_gap_w - 10.0).abs() < 0.01,
7275 "Gap should add 10pt: with_gap={}, no_gap={}",
7276 with_gap_w,
7277 no_gap_w
7278 );
7279 }
7280
7281 #[test]
7282 fn intrinsic_width_empty_container() {
7283 let engine = LayoutEngine::new();
7284 let font_context = FontContext::new();
7285
7286 let padding = 8.0;
7287 let empty = make_styled_view(
7288 Style {
7289 padding: Some(Edges::uniform(padding)),
7290 ..Default::default()
7291 },
7292 vec![],
7293 );
7294 let style = empty.style.resolve(None, 0.0);
7295 let w = engine.measure_intrinsic_width(&empty, &style, &font_context);
7296
7297 assert!(
7298 (w - padding * 2.0).abs() < 0.01,
7299 "Empty container width ({}) should equal horizontal padding ({})",
7300 w,
7301 padding * 2.0
7302 );
7303 }
7304
7305 #[test]
7308 fn flex_shrink_respects_min_content_width() {
7309 let engine = LayoutEngine::new();
7313 let font_context = FontContext::new();
7314
7315 let sale_text = make_text("SALE", 12.0);
7316 let sale_style = sale_text.style.resolve(None, 0.0);
7317 let sale_word_width =
7318 engine.measure_min_content_width(&sale_text, &sale_style, &font_context);
7319 assert!(
7320 sale_word_width > 0.0,
7321 "SALE should have non-zero min-content width"
7322 );
7323
7324 let container = make_styled_view(
7327 Style {
7328 flex_direction: Some(FlexDirection::Row),
7329 width: Some(Dimension::Pt(100.0)),
7330 ..Default::default()
7331 },
7332 vec![
7333 make_styled_view(
7334 Style {
7335 width: Some(Dimension::Pt(80.0)),
7336 flex_shrink: Some(1.0),
7337 ..Default::default()
7338 },
7339 vec![],
7340 ),
7341 make_styled_view(
7342 Style {
7343 width: Some(Dimension::Pt(60.0)),
7344 flex_shrink: Some(1.0),
7345 ..Default::default()
7346 },
7347 vec![make_text("SALE", 12.0)],
7348 ),
7349 ],
7350 );
7351
7352 let doc = Document {
7353 children: vec![Node::page(
7354 PageConfig::default(),
7355 Style::default(),
7356 vec![container],
7357 )],
7358 metadata: Default::default(),
7359 default_page: PageConfig::default(),
7360 first_page: None,
7361 left_page: None,
7362 right_page: None,
7363 named_pages: Default::default(),
7364 attachments: vec![],
7365 zugferd: None,
7366 fonts: vec![],
7367 tagged: false,
7368 pdfa: None,
7369 default_style: None,
7370 embedded_data: None,
7371 flatten_forms: false,
7372 pdf_ua: false,
7373 certification: None,
7374 };
7375
7376 let pages = engine.layout(&doc, &font_context);
7377 assert!(!pages.is_empty());
7378
7379 let page = &pages[0];
7382 let container_el = page.elements.iter().find(|e| e.children.len() == 2);
7384 assert!(
7385 container_el.is_some(),
7386 "Should find container with 2 children"
7387 );
7388 let sale_child = &container_el.unwrap().children[1];
7389 assert!(
7390 sale_child.width >= sale_word_width - 0.01,
7391 "SALE child width ({}) should be >= min-content width ({})",
7392 sale_child.width,
7393 sale_word_width
7394 );
7395 }
7396
7397 #[test]
7400 fn column_justify_content_center() {
7401 let engine = LayoutEngine::new();
7404 let font_context = FontContext::new();
7405
7406 let container = make_styled_view(
7407 Style {
7408 flex_direction: Some(FlexDirection::Column),
7409 height: Some(Dimension::Pt(200.0)),
7410 justify_content: Some(JustifyContent::Center),
7411 ..Default::default()
7412 },
7413 vec![make_text("Centered", 12.0)],
7414 );
7415
7416 let doc = Document {
7417 children: vec![Node::page(
7418 PageConfig::default(),
7419 Style::default(),
7420 vec![container],
7421 )],
7422 metadata: Default::default(),
7423 default_page: PageConfig::default(),
7424 first_page: None,
7425 left_page: None,
7426 right_page: None,
7427 named_pages: Default::default(),
7428 attachments: vec![],
7429 zugferd: None,
7430 fonts: vec![],
7431 tagged: false,
7432 pdfa: None,
7433 default_style: None,
7434 embedded_data: None,
7435 flatten_forms: false,
7436 pdf_ua: false,
7437 certification: None,
7438 };
7439
7440 let pages = engine.layout(&doc, &font_context);
7441 let page = &pages[0];
7442
7443 let container_el = page.elements.iter().find(|e| !e.children.is_empty());
7446 assert!(
7447 container_el.is_some(),
7448 "Should find container with children"
7449 );
7450 let container_el = container_el.unwrap();
7451 let child = &container_el.children[0];
7452
7453 let child_offset = child.y - container_el.y;
7455 let expected_offset = (200.0 - child.height) / 2.0;
7456 assert!(
7457 (child_offset - expected_offset).abs() < 2.0,
7458 "Child offset ({}) should be near center ({})",
7459 child_offset,
7460 expected_offset
7461 );
7462 }
7463
7464 #[test]
7465 fn column_align_items_center() {
7466 let engine = LayoutEngine::new();
7469 let font_context = FontContext::new();
7470
7471 let container = make_styled_view(
7472 Style {
7473 flex_direction: Some(FlexDirection::Column),
7474 width: Some(Dimension::Pt(300.0)),
7475 align_items: Some(AlignItems::Center),
7476 ..Default::default()
7477 },
7478 vec![make_text("Hi", 12.0)],
7479 );
7480
7481 let doc = Document {
7482 children: vec![Node::page(
7483 PageConfig::default(),
7484 Style::default(),
7485 vec![container],
7486 )],
7487 metadata: Default::default(),
7488 default_page: PageConfig::default(),
7489 first_page: None,
7490 left_page: None,
7491 right_page: None,
7492 named_pages: Default::default(),
7493 attachments: vec![],
7494 zugferd: None,
7495 fonts: vec![],
7496 tagged: false,
7497 pdfa: None,
7498 default_style: None,
7499 embedded_data: None,
7500 flatten_forms: false,
7501 pdf_ua: false,
7502 certification: None,
7503 };
7504
7505 let pages = engine.layout(&doc, &font_context);
7506 let page = &pages[0];
7507
7508 let container_el = page.elements.iter().find(|e| !e.children.is_empty());
7509 assert!(container_el.is_some());
7510 let container_el = container_el.unwrap();
7511 let child = &container_el.children[0];
7512
7513 let child_center = child.x + child.width / 2.0;
7515 let container_center = container_el.x + container_el.width / 2.0;
7516 assert!(
7517 (child_center - container_center).abs() < 2.0,
7518 "Child center ({}) should be near container center ({})",
7519 child_center,
7520 container_center
7521 );
7522 }
7523
7524 #[test]
7527 fn absolute_child_positioned_relative_to_parent() {
7528 let engine = LayoutEngine::new();
7532 let font_context = FontContext::new();
7533
7534 let parent = make_styled_view(
7535 Style {
7536 position: Some(crate::model::Position::Relative),
7537 margin: Some(MarginEdges::from_edges(Edges {
7538 top: 50.0,
7539 left: 50.0,
7540 ..Default::default()
7541 })),
7542 width: Some(Dimension::Pt(200.0)),
7543 height: Some(Dimension::Pt(200.0)),
7544 ..Default::default()
7545 },
7546 vec![make_styled_view(
7547 Style {
7548 position: Some(crate::model::Position::Absolute),
7549 top: Some(10.0),
7550 left: Some(10.0),
7551 width: Some(Dimension::Pt(50.0)),
7552 height: Some(Dimension::Pt(50.0)),
7553 ..Default::default()
7554 },
7555 vec![],
7556 )],
7557 );
7558
7559 let doc = Document {
7560 children: vec![Node::page(
7561 PageConfig::default(),
7562 Style::default(),
7563 vec![parent],
7564 )],
7565 metadata: Default::default(),
7566 default_page: PageConfig::default(),
7567 first_page: None,
7568 left_page: None,
7569 right_page: None,
7570 named_pages: Default::default(),
7571 attachments: vec![],
7572 zugferd: None,
7573 fonts: vec![],
7574 tagged: false,
7575 pdfa: None,
7576 default_style: None,
7577 embedded_data: None,
7578 flatten_forms: false,
7579 pdf_ua: false,
7580 certification: None,
7581 };
7582
7583 let pages = engine.layout(&doc, &font_context);
7584 let page = &pages[0];
7585
7586 let parent_el = page
7589 .elements
7590 .iter()
7591 .find(|e| e.width > 190.0 && e.width < 210.0);
7592 assert!(parent_el.is_some(), "Should find the 200x200 parent");
7593 let parent_el = parent_el.unwrap();
7594
7595 let abs_child = parent_el
7597 .children
7598 .iter()
7599 .find(|e| e.width > 45.0 && e.width < 55.0);
7600 assert!(abs_child.is_some(), "Should find 50x50 absolute child");
7601 let abs_child = abs_child.unwrap();
7602
7603 let expected_x = parent_el.x + 10.0;
7604 let expected_y = parent_el.y + 10.0;
7605 assert!(
7606 (abs_child.x - expected_x).abs() < 1.0,
7607 "Absolute child x ({}) should be parent.x + 10 ({})",
7608 abs_child.x,
7609 expected_x
7610 );
7611 assert!(
7612 (abs_child.y - expected_y).abs() < 1.0,
7613 "Absolute child y ({}) should be parent.y + 10 ({})",
7614 abs_child.y,
7615 expected_y
7616 );
7617 }
7618
7619 #[test]
7620 fn absolute_escapes_unpositioned_parent_to_page() {
7621 let engine = LayoutEngine::new();
7626 let font_context = FontContext::new();
7627 let parent = make_styled_view(
7628 Style {
7629 margin: Some(MarginEdges::from_edges(Edges {
7630 top: 50.0,
7631 left: 50.0,
7632 ..Default::default()
7633 })),
7634 width: Some(Dimension::Pt(200.0)),
7635 height: Some(Dimension::Pt(200.0)),
7636 ..Default::default()
7637 },
7638 vec![make_styled_view(
7639 Style {
7640 position: Some(crate::model::Position::Absolute),
7641 top: Some(10.0),
7642 left: Some(10.0),
7643 width: Some(Dimension::Pt(50.0)),
7644 height: Some(Dimension::Pt(50.0)),
7645 ..Default::default()
7646 },
7647 vec![],
7648 )],
7649 );
7650 let doc = Document {
7651 children: vec![Node::page(
7652 PageConfig::default(),
7653 Style::default(),
7654 vec![parent],
7655 )],
7656 metadata: Default::default(),
7657 default_page: PageConfig::default(),
7658 first_page: None,
7659 left_page: None,
7660 right_page: None,
7661 named_pages: Default::default(),
7662 attachments: vec![],
7663 zugferd: None,
7664 fonts: vec![],
7665 tagged: false,
7666 pdfa: None,
7667 default_style: None,
7668 embedded_data: None,
7669 flatten_forms: false,
7670 pdf_ua: false,
7671 certification: None,
7672 };
7673 let pages = engine.layout(&doc, &font_context);
7674 let page = &pages[0];
7675 let parent_el = page
7676 .elements
7677 .iter()
7678 .find(|e| e.width > 190.0 && e.width < 210.0)
7679 .expect("parent");
7680 let abs_child = parent_el
7681 .children
7682 .iter()
7683 .find(|e| e.width > 45.0 && e.width < 55.0)
7684 .expect("abs child");
7685 let page_left = PageConfig::default().margin.left;
7686 let page_top = PageConfig::default().margin.top;
7687 assert!(
7688 (abs_child.x - (page_left + 10.0)).abs() < 1.0,
7689 "absolute escapes to the page: x {} should be page_left + 10 ({})",
7690 abs_child.x,
7691 page_left + 10.0
7692 );
7693 assert!(
7694 (abs_child.y - (page_top + 10.0)).abs() < 1.0,
7695 "absolute escapes to the page: y {} should be page_top + 10 ({})",
7696 abs_child.y,
7697 page_top + 10.0
7698 );
7699 assert!(
7700 abs_child.x < parent_el.x,
7701 "child must no longer be parent-relative (parent is 50pt further in)"
7702 );
7703 }
7704
7705 #[test]
7706 fn text_transform_none_passthrough() {
7707 assert_eq!(
7708 apply_text_transform("Hello World", TextTransform::None),
7709 "Hello World"
7710 );
7711 }
7712
7713 #[test]
7714 fn text_transform_uppercase() {
7715 assert_eq!(
7716 apply_text_transform("hello world", TextTransform::Uppercase),
7717 "HELLO WORLD"
7718 );
7719 }
7720
7721 #[test]
7722 fn text_transform_lowercase() {
7723 assert_eq!(
7724 apply_text_transform("HELLO WORLD", TextTransform::Lowercase),
7725 "hello world"
7726 );
7727 }
7728
7729 #[test]
7730 fn text_transform_capitalize() {
7731 assert_eq!(
7732 apply_text_transform("hello world", TextTransform::Capitalize),
7733 "Hello World"
7734 );
7735 assert_eq!(
7736 apply_text_transform(" hello world ", TextTransform::Capitalize),
7737 " Hello World "
7738 );
7739 assert_eq!(
7740 apply_text_transform("already Capitalized", TextTransform::Capitalize),
7741 "Already Capitalized"
7742 );
7743 }
7744
7745 #[test]
7746 fn text_transform_capitalize_empty() {
7747 assert_eq!(apply_text_transform("", TextTransform::Capitalize), "");
7748 }
7749
7750 #[test]
7751 fn apply_char_transform_uppercase() {
7752 assert_eq!(
7753 apply_char_transform('a', TextTransform::Uppercase, false),
7754 'A'
7755 );
7756 assert_eq!(
7757 apply_char_transform('A', TextTransform::Uppercase, false),
7758 'A'
7759 );
7760 }
7761
7762 #[test]
7763 fn apply_char_transform_capitalize_word_start() {
7764 assert_eq!(
7765 apply_char_transform('h', TextTransform::Capitalize, true),
7766 'H'
7767 );
7768 assert_eq!(
7769 apply_char_transform('h', TextTransform::Capitalize, false),
7770 'h'
7771 );
7772 }
7773
7774 #[test]
7777 fn column_flex_grow_single_child_fills_container() {
7778 let engine = LayoutEngine::new();
7781 let font_context = FontContext::new();
7782
7783 let child = make_styled_view(
7784 Style {
7785 flex_grow: Some(1.0),
7786 ..Default::default()
7787 },
7788 vec![make_text("Short", 12.0)],
7789 );
7790
7791 let container = make_styled_view(
7792 Style {
7793 flex_direction: Some(FlexDirection::Column),
7794 height: Some(Dimension::Pt(300.0)),
7795 ..Default::default()
7796 },
7797 vec![child],
7798 );
7799
7800 let doc = Document {
7801 children: vec![Node::page(
7802 PageConfig::default(),
7803 Style::default(),
7804 vec![container],
7805 )],
7806 metadata: Default::default(),
7807 default_page: PageConfig::default(),
7808 first_page: None,
7809 left_page: None,
7810 right_page: None,
7811 named_pages: Default::default(),
7812 attachments: vec![],
7813 zugferd: None,
7814 fonts: vec![],
7815 tagged: false,
7816 pdfa: None,
7817 default_style: None,
7818 embedded_data: None,
7819 flatten_forms: false,
7820 pdf_ua: false,
7821 certification: None,
7822 };
7823
7824 let pages = engine.layout(&doc, &font_context);
7825 let page = &pages[0];
7826
7827 let container_el = page.elements.iter().find(|e| !e.children.is_empty());
7828 assert!(container_el.is_some());
7829 let container_el = container_el.unwrap();
7830 assert!(
7831 (container_el.height - 300.0).abs() < 1.0,
7832 "Container should be 300pt, got {}",
7833 container_el.height
7834 );
7835
7836 let child_el = &container_el.children[0];
7837 assert!(
7838 (child_el.height - 300.0).abs() < 1.0,
7839 "flex-grow child should expand to 300pt, got {}",
7840 child_el.height
7841 );
7842 }
7843
7844 #[test]
7845 fn column_flex_grow_two_children_proportional() {
7846 let engine = LayoutEngine::new();
7849 let font_context = FontContext::new();
7850
7851 let child1 = make_styled_view(
7852 Style {
7853 flex_grow: Some(1.0),
7854 ..Default::default()
7855 },
7856 vec![make_text("A", 12.0)],
7857 );
7858 let child2 = make_styled_view(
7859 Style {
7860 flex_grow: Some(2.0),
7861 ..Default::default()
7862 },
7863 vec![make_text("B", 12.0)],
7864 );
7865
7866 let container = make_styled_view(
7867 Style {
7868 flex_direction: Some(FlexDirection::Column),
7869 height: Some(Dimension::Pt(300.0)),
7870 ..Default::default()
7871 },
7872 vec![child1, child2],
7873 );
7874
7875 let doc = Document {
7876 children: vec![Node::page(
7877 PageConfig::default(),
7878 Style::default(),
7879 vec![container],
7880 )],
7881 metadata: Default::default(),
7882 default_page: PageConfig::default(),
7883 first_page: None,
7884 left_page: None,
7885 right_page: None,
7886 named_pages: Default::default(),
7887 attachments: vec![],
7888 zugferd: None,
7889 fonts: vec![],
7890 tagged: false,
7891 pdfa: None,
7892 default_style: None,
7893 embedded_data: None,
7894 flatten_forms: false,
7895 pdf_ua: false,
7896 certification: None,
7897 };
7898
7899 let pages = engine.layout(&doc, &font_context);
7900 let page = &pages[0];
7901
7902 let container_el = page
7903 .elements
7904 .iter()
7905 .find(|e| e.children.len() == 2)
7906 .expect("Should find container with two children");
7907
7908 let c1 = &container_el.children[0];
7909 let c2 = &container_el.children[1];
7910
7911 let total = c1.height + c2.height;
7915 assert!(
7916 (total - 300.0).abs() < 2.0,
7917 "Children should sum to ~300pt, got {}",
7918 total
7919 );
7920
7921 let ratio = c2.height / c1.height;
7924 assert!(
7925 ratio > 1.3 && ratio < 2.5,
7926 "child2/child1 ratio should be between 1.3 and 2.5, got {}",
7927 ratio
7928 );
7929 }
7930
7931 #[test]
7932 fn column_flex_grow_mixed_grow_and_fixed() {
7933 let engine = LayoutEngine::new();
7936 let font_context = FontContext::new();
7937
7938 let fixed_child = make_styled_view(
7939 Style {
7940 height: Some(Dimension::Pt(50.0)),
7941 ..Default::default()
7942 },
7943 vec![make_text("Fixed", 12.0)],
7944 );
7945 let grow_child = make_styled_view(
7946 Style {
7947 flex_grow: Some(1.0),
7948 ..Default::default()
7949 },
7950 vec![make_text("Grow", 12.0)],
7951 );
7952
7953 let container = make_styled_view(
7954 Style {
7955 flex_direction: Some(FlexDirection::Column),
7956 height: Some(Dimension::Pt(300.0)),
7957 ..Default::default()
7958 },
7959 vec![fixed_child, grow_child],
7960 );
7961
7962 let doc = Document {
7963 children: vec![Node::page(
7964 PageConfig::default(),
7965 Style::default(),
7966 vec![container],
7967 )],
7968 metadata: Default::default(),
7969 default_page: PageConfig::default(),
7970 first_page: None,
7971 left_page: None,
7972 right_page: None,
7973 named_pages: Default::default(),
7974 attachments: vec![],
7975 zugferd: None,
7976 fonts: vec![],
7977 tagged: false,
7978 pdfa: None,
7979 default_style: None,
7980 embedded_data: None,
7981 flatten_forms: false,
7982 pdf_ua: false,
7983 certification: None,
7984 };
7985
7986 let pages = engine.layout(&doc, &font_context);
7987 let page = &pages[0];
7988
7989 let container_el = page
7990 .elements
7991 .iter()
7992 .find(|e| e.children.len() == 2)
7993 .expect("Should find container with two children");
7994
7995 let fixed_el = &container_el.children[0];
7996 let grow_el = &container_el.children[1];
7997
7998 assert!(
8000 (fixed_el.height - 50.0).abs() < 1.0,
8001 "Fixed child should stay at 50pt, got {}",
8002 fixed_el.height
8003 );
8004
8005 assert!(
8007 (grow_el.height - 250.0).abs() < 2.0,
8008 "Grow child should expand to ~250pt, got {}",
8009 grow_el.height
8010 );
8011 }
8012
8013 #[test]
8014 fn column_flex_grow_page_level() {
8015 let engine = LayoutEngine::new();
8017 let font_context = FontContext::new();
8018
8019 let grow_child = make_styled_view(
8020 Style {
8021 flex_grow: Some(1.0),
8022 ..Default::default()
8023 },
8024 vec![make_text("Fill page", 12.0)],
8025 );
8026
8027 let doc = Document {
8028 children: vec![Node::page(
8029 PageConfig::default(),
8030 Style::default(),
8031 vec![grow_child],
8032 )],
8033 metadata: Default::default(),
8034 default_page: PageConfig::default(),
8035 first_page: None,
8036 left_page: None,
8037 right_page: None,
8038 named_pages: Default::default(),
8039 attachments: vec![],
8040 zugferd: None,
8041 fonts: vec![],
8042 tagged: false,
8043 pdfa: None,
8044 default_style: None,
8045 embedded_data: None,
8046 flatten_forms: false,
8047 pdf_ua: false,
8048 certification: None,
8049 };
8050
8051 let pages = engine.layout(&doc, &font_context);
8052 let page = &pages[0];
8053
8054 assert!(
8056 !page.elements.is_empty(),
8057 "Page should have at least one element"
8058 );
8059
8060 let content_height = page.height - page.config.margin.top - page.config.margin.bottom;
8061 let el = &page.elements[0];
8062 assert!(
8063 (el.height - content_height).abs() < 2.0,
8064 "Page-level flex-grow child should fill content height ({}), got {}",
8065 content_height,
8066 el.height
8067 );
8068 }
8069
8070 #[test]
8071 fn column_flex_grow_with_justify_content() {
8072 let engine = LayoutEngine::new();
8076 let font_context = FontContext::new();
8077
8078 let fixed_child = make_styled_view(
8079 Style {
8080 height: Some(Dimension::Pt(50.0)),
8081 ..Default::default()
8082 },
8083 vec![make_text("Top", 12.0)],
8084 );
8085 let grow_child = make_styled_view(
8086 Style {
8087 flex_grow: Some(1.0),
8088 ..Default::default()
8089 },
8090 vec![make_text("Fill", 12.0)],
8091 );
8092
8093 let container = make_styled_view(
8094 Style {
8095 flex_direction: Some(FlexDirection::Column),
8096 height: Some(Dimension::Pt(300.0)),
8097 justify_content: Some(JustifyContent::Center),
8098 ..Default::default()
8099 },
8100 vec![fixed_child, grow_child],
8101 );
8102
8103 let doc = Document {
8104 children: vec![Node::page(
8105 PageConfig::default(),
8106 Style::default(),
8107 vec![container],
8108 )],
8109 metadata: Default::default(),
8110 default_page: PageConfig::default(),
8111 first_page: None,
8112 left_page: None,
8113 right_page: None,
8114 named_pages: Default::default(),
8115 attachments: vec![],
8116 zugferd: None,
8117 fonts: vec![],
8118 tagged: false,
8119 pdfa: None,
8120 default_style: None,
8121 embedded_data: None,
8122 flatten_forms: false,
8123 pdf_ua: false,
8124 certification: None,
8125 };
8126
8127 let pages = engine.layout(&doc, &font_context);
8128 let page = &pages[0];
8129
8130 let container_el = page
8131 .elements
8132 .iter()
8133 .find(|e| e.children.len() == 2)
8134 .expect("Should find container");
8135
8136 let first_child = &container_el.children[0];
8139 assert!(
8140 (first_child.y - container_el.y).abs() < 1.0,
8141 "First child should be at top of container"
8142 );
8143
8144 let total = container_el.children[0].height + container_el.children[1].height;
8146 assert!(
8147 (total - 300.0).abs() < 2.0,
8148 "Children should fill container, got {}",
8149 total
8150 );
8151 }
8152
8153 #[test]
8154 fn column_flex_grow_child_justify_content_center() {
8155 let engine = LayoutEngine::new();
8158 let font_context = FontContext::new();
8159
8160 let inner_box = make_styled_view(
8162 Style {
8163 height: Some(Dimension::Pt(40.0)),
8164 ..Default::default()
8165 },
8166 vec![make_text("Centered", 12.0)],
8167 );
8168
8169 let grow_child = make_styled_view(
8171 Style {
8172 flex_grow: Some(1.0),
8173 flex_direction: Some(FlexDirection::Column),
8174 justify_content: Some(JustifyContent::Center),
8175 ..Default::default()
8176 },
8177 vec![inner_box],
8178 );
8179
8180 let container = make_styled_view(
8182 Style {
8183 flex_direction: Some(FlexDirection::Column),
8184 height: Some(Dimension::Pt(400.0)),
8185 ..Default::default()
8186 },
8187 vec![grow_child],
8188 );
8189
8190 let doc = Document {
8191 children: vec![Node::page(
8192 PageConfig::default(),
8193 Style::default(),
8194 vec![container],
8195 )],
8196 metadata: Default::default(),
8197 default_page: PageConfig::default(),
8198 first_page: None,
8199 left_page: None,
8200 right_page: None,
8201 named_pages: Default::default(),
8202 attachments: vec![],
8203 zugferd: None,
8204 fonts: vec![],
8205 tagged: false,
8206 pdfa: None,
8207 default_style: None,
8208 embedded_data: None,
8209 flatten_forms: false,
8210 pdf_ua: false,
8211 certification: None,
8212 };
8213
8214 let pages = engine.layout(&doc, &font_context);
8215 let page = &pages[0];
8216
8217 let container_el = page
8219 .elements
8220 .iter()
8221 .find(|e| e.height > 350.0 && e.children.len() == 1)
8222 .expect("Should find outer container");
8223
8224 let grow_el = &container_el.children[0];
8225 assert!(
8226 (grow_el.height - 400.0).abs() < 2.0,
8227 "Grow child should expand to 400, got {}",
8228 grow_el.height
8229 );
8230
8231 let inner_el = &grow_el.children[0];
8233 let expected_center = grow_el.y + grow_el.height / 2.0;
8234 let actual_center = inner_el.y + inner_el.height / 2.0;
8235 assert!(
8236 (actual_center - expected_center).abs() < 2.0,
8237 "Inner box should be vertically centered. Expected center ~{}, got ~{}",
8238 expected_center,
8239 actual_center
8240 );
8241 }
8242
8243 #[test]
8244 fn column_flex_grow_child_justify_content_flex_end() {
8245 let engine = LayoutEngine::new();
8247 let font_context = FontContext::new();
8248
8249 let inner_box = make_styled_view(
8250 Style {
8251 height: Some(Dimension::Pt(30.0)),
8252 ..Default::default()
8253 },
8254 vec![make_text("Bottom", 12.0)],
8255 );
8256
8257 let grow_child = make_styled_view(
8258 Style {
8259 flex_grow: Some(1.0),
8260 flex_direction: Some(FlexDirection::Column),
8261 justify_content: Some(JustifyContent::FlexEnd),
8262 ..Default::default()
8263 },
8264 vec![inner_box],
8265 );
8266
8267 let container = make_styled_view(
8268 Style {
8269 flex_direction: Some(FlexDirection::Column),
8270 height: Some(Dimension::Pt(300.0)),
8271 ..Default::default()
8272 },
8273 vec![grow_child],
8274 );
8275
8276 let doc = Document {
8277 children: vec![Node::page(
8278 PageConfig::default(),
8279 Style::default(),
8280 vec![container],
8281 )],
8282 metadata: Default::default(),
8283 default_page: PageConfig::default(),
8284 first_page: None,
8285 left_page: None,
8286 right_page: None,
8287 named_pages: Default::default(),
8288 attachments: vec![],
8289 zugferd: None,
8290 fonts: vec![],
8291 tagged: false,
8292 pdfa: None,
8293 default_style: None,
8294 embedded_data: None,
8295 flatten_forms: false,
8296 pdf_ua: false,
8297 certification: None,
8298 };
8299
8300 let pages = engine.layout(&doc, &font_context);
8301 let page = &pages[0];
8302
8303 let container_el = page
8304 .elements
8305 .iter()
8306 .find(|e| e.height > 250.0 && e.children.len() == 1)
8307 .expect("Should find outer container");
8308
8309 let grow_el = &container_el.children[0];
8310 let inner_el = &grow_el.children[0];
8311
8312 let inner_bottom = inner_el.y + inner_el.height;
8314 let grow_bottom = grow_el.y + grow_el.height;
8315 assert!(
8316 (inner_bottom - grow_bottom).abs() < 2.0,
8317 "Inner box bottom ({}) should align with grow child bottom ({})",
8318 inner_bottom,
8319 grow_bottom
8320 );
8321 }
8322
8323 #[test]
8324 fn column_flex_grow_child_no_justify_unchanged() {
8325 let engine = LayoutEngine::new();
8327 let font_context = FontContext::new();
8328
8329 let inner_box = make_styled_view(
8330 Style {
8331 height: Some(Dimension::Pt(50.0)),
8332 ..Default::default()
8333 },
8334 vec![make_text("Top", 12.0)],
8335 );
8336
8337 let grow_child = make_styled_view(
8338 Style {
8339 flex_grow: Some(1.0),
8340 flex_direction: Some(FlexDirection::Column),
8341 ..Default::default()
8343 },
8344 vec![inner_box],
8345 );
8346
8347 let container = make_styled_view(
8348 Style {
8349 flex_direction: Some(FlexDirection::Column),
8350 height: Some(Dimension::Pt(300.0)),
8351 ..Default::default()
8352 },
8353 vec![grow_child],
8354 );
8355
8356 let doc = Document {
8357 children: vec![Node::page(
8358 PageConfig::default(),
8359 Style::default(),
8360 vec![container],
8361 )],
8362 metadata: Default::default(),
8363 default_page: PageConfig::default(),
8364 first_page: None,
8365 left_page: None,
8366 right_page: None,
8367 named_pages: Default::default(),
8368 attachments: vec![],
8369 zugferd: None,
8370 fonts: vec![],
8371 tagged: false,
8372 pdfa: None,
8373 default_style: None,
8374 embedded_data: None,
8375 flatten_forms: false,
8376 pdf_ua: false,
8377 certification: None,
8378 };
8379
8380 let pages = engine.layout(&doc, &font_context);
8381 let page = &pages[0];
8382
8383 let container_el = page
8384 .elements
8385 .iter()
8386 .find(|e| e.height > 250.0 && e.children.len() == 1)
8387 .expect("Should find outer container");
8388
8389 let grow_el = &container_el.children[0];
8390 let inner_el = &grow_el.children[0];
8391
8392 assert!(
8394 (inner_el.y - grow_el.y).abs() < 2.0,
8395 "Inner box ({}) should be at top of grow child ({})",
8396 inner_el.y,
8397 grow_el.y
8398 );
8399 }
8400
8401 #[test]
8402 fn column_flex_grow_child_align_items_center() {
8403 let engine = LayoutEngine::new();
8405 let font_context = FontContext::new();
8406
8407 let text = make_text("Hello", 12.0);
8408
8409 let grow_child = make_styled_view(
8410 Style {
8411 flex_grow: Some(1.0),
8412 flex_direction: Some(FlexDirection::Column),
8413 align_items: Some(AlignItems::Center),
8414 ..Default::default()
8415 },
8416 vec![text],
8417 );
8418
8419 let container = make_styled_view(
8420 Style {
8421 flex_direction: Some(FlexDirection::Column),
8422 height: Some(Dimension::Pt(300.0)),
8423 ..Default::default()
8424 },
8425 vec![grow_child],
8426 );
8427
8428 let doc = Document {
8429 children: vec![Node::page(
8430 PageConfig::default(),
8431 Style::default(),
8432 vec![container],
8433 )],
8434 metadata: Default::default(),
8435 default_page: PageConfig::default(),
8436 first_page: None,
8437 left_page: None,
8438 right_page: None,
8439 named_pages: Default::default(),
8440 attachments: vec![],
8441 zugferd: None,
8442 fonts: vec![],
8443 tagged: false,
8444 pdfa: None,
8445 default_style: None,
8446 embedded_data: None,
8447 flatten_forms: false,
8448 pdf_ua: false,
8449 certification: None,
8450 };
8451
8452 let pages = engine.layout(&doc, &font_context);
8453 let page = &pages[0];
8454
8455 let container_el = page
8456 .elements
8457 .iter()
8458 .find(|e| e.height > 250.0 && e.children.len() == 1)
8459 .expect("Should find outer container");
8460
8461 let grow_el = &container_el.children[0];
8462 assert!(
8463 !grow_el.children.is_empty(),
8464 "Grow child should have text child"
8465 );
8466
8467 let text_el = &grow_el.children[0];
8468 let text_center = text_el.x + text_el.width / 2.0;
8469 let grow_center = grow_el.x + grow_el.width / 2.0;
8470 assert!(
8471 (text_center - grow_center).abs() < 2.0,
8472 "Text center ({}) should be near grow child center ({})",
8473 text_center,
8474 grow_center
8475 );
8476 }
8477
8478 #[test]
8479 fn image_intrinsic_width_respects_height_constraint() {
8480 let engine = LayoutEngine::new();
8484 let font_context = FontContext::new();
8485
8486 let one_px_png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
8488
8489 let image_node = Node {
8490 kind: NodeKind::Image {
8491 src: one_px_png.to_string(),
8492 width: None,
8493 height: Some(36.0),
8494 },
8495 style: Style::default(),
8496 children: vec![],
8497 id: None,
8498 source_location: None,
8499 bookmark: None,
8500 href: None,
8501 alt: None,
8502 };
8503
8504 let resolved = image_node.style.resolve(None, 0.0);
8505 let intrinsic = engine.measure_intrinsic_width(&image_node, &resolved, &font_context);
8506
8507 assert!(
8509 (intrinsic - 36.0).abs() < 1.0,
8510 "Intrinsic width should be ~36 for 1:1 aspect image with height 36, got {}",
8511 intrinsic
8512 );
8513 }
8514}