1pub mod flex;
35pub mod grid;
36pub mod page_break;
37
38use std::cell::RefCell;
39use std::collections::HashMap;
40
41use serde::Serialize;
42
43use crate::font::FontContext;
44use crate::model::*;
45use crate::style::*;
46use crate::text::bidi;
47use crate::text::shaping;
48use crate::text::{BrokenLine, RunBrokenLine, StyledChar, TextLayout};
49
50#[derive(Debug, Clone, Serialize)]
52#[serde(rename_all = "camelCase")]
53pub struct BookmarkEntry {
54 pub title: String,
55 pub page_index: usize,
56 pub y: f64,
57}
58
59#[derive(Debug, Clone, Serialize)]
63#[serde(rename_all = "camelCase")]
64pub struct LayoutInfo {
65 pub pages: Vec<PageInfo>,
66}
67
68#[derive(Debug, Clone, Serialize)]
70#[serde(rename_all = "camelCase")]
71pub struct PageInfo {
72 pub width: f64,
73 pub height: f64,
74 pub content_x: f64,
75 pub content_y: f64,
76 pub content_width: f64,
77 pub content_height: f64,
78 pub elements: Vec<ElementInfo>,
79}
80
81#[derive(Debug, Clone, Serialize)]
83#[serde(rename_all = "camelCase")]
84pub struct ElementStyleInfo {
85 pub margin: Edges,
87 pub padding: Edges,
88 pub border_width: Edges,
89 #[serde(skip_serializing_if = "Option::is_none")]
90 pub width: Option<String>,
91 #[serde(skip_serializing_if = "Option::is_none")]
92 pub height: Option<String>,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub min_width: Option<f64>,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub min_height: Option<f64>,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub max_width: Option<f64>,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 pub max_height: Option<f64>,
101 pub flex_direction: FlexDirection,
103 pub justify_content: JustifyContent,
104 pub align_items: AlignItems,
105 #[serde(skip_serializing_if = "Option::is_none")]
106 pub align_self: Option<AlignItems>,
107 pub flex_wrap: FlexWrap,
108 pub align_content: AlignContent,
109 pub flex_grow: f64,
110 pub flex_shrink: f64,
111 #[serde(skip_serializing_if = "Option::is_none")]
112 pub flex_basis: Option<String>,
113 pub gap: f64,
114 pub row_gap: f64,
115 pub column_gap: f64,
116 pub font_family: String,
118 pub font_size: f64,
119 pub font_weight: u32,
120 pub font_style: FontStyle,
121 pub line_height: f64,
122 pub text_align: TextAlign,
123 pub letter_spacing: f64,
124 pub text_decoration: TextDecoration,
125 pub text_transform: TextTransform,
126 pub color: Color,
128 pub background_color: Option<Color>,
129 pub border_color: EdgeValues<Color>,
130 pub border_radius: CornerValues,
131 pub opacity: f64,
132 pub position: Position,
134 #[serde(skip_serializing_if = "Option::is_none")]
135 pub top: Option<f64>,
136 #[serde(skip_serializing_if = "Option::is_none")]
137 pub right: Option<f64>,
138 #[serde(skip_serializing_if = "Option::is_none")]
139 pub bottom: Option<f64>,
140 #[serde(skip_serializing_if = "Option::is_none")]
141 pub left: Option<f64>,
142 pub overflow: Overflow,
144 pub breakable: bool,
146 pub break_before: bool,
147 pub min_widow_lines: u32,
148 pub min_orphan_lines: u32,
149}
150
151fn size_constraint_to_str(sc: &SizeConstraint) -> Option<String> {
152 match sc {
153 SizeConstraint::Auto => None,
154 SizeConstraint::Fixed(v) => Some(format!("{v}")),
155 }
156}
157
158impl ElementStyleInfo {
159 fn from_resolved(style: &ResolvedStyle) -> Self {
160 ElementStyleInfo {
161 margin: style.margin.to_edges(),
162 padding: style.padding,
163 border_width: style.border_width,
164 width: size_constraint_to_str(&style.width),
165 height: size_constraint_to_str(&style.height),
166 min_width: if style.min_width > 0.0 {
167 Some(style.min_width)
168 } else {
169 None
170 },
171 min_height: if style.min_height > 0.0 {
172 Some(style.min_height)
173 } else {
174 None
175 },
176 max_width: if style.max_width.is_finite() {
177 Some(style.max_width)
178 } else {
179 None
180 },
181 max_height: if style.max_height.is_finite() {
182 Some(style.max_height)
183 } else {
184 None
185 },
186 flex_direction: style.flex_direction,
187 justify_content: style.justify_content,
188 align_items: style.align_items,
189 align_self: style.align_self,
190 flex_wrap: style.flex_wrap,
191 align_content: style.align_content,
192 flex_grow: style.flex_grow,
193 flex_shrink: style.flex_shrink,
194 flex_basis: size_constraint_to_str(&style.flex_basis),
195 gap: style.gap,
196 row_gap: style.row_gap,
197 column_gap: style.column_gap,
198 font_family: style.font_family.clone(),
199 font_size: style.font_size,
200 font_weight: style.font_weight,
201 font_style: style.font_style,
202 line_height: style.line_height,
203 text_align: style.text_align,
204 letter_spacing: style.letter_spacing,
205 text_decoration: style.text_decoration,
206 text_transform: style.text_transform,
207 color: style.color,
208 background_color: style.background_color,
209 border_color: style.border_color,
210 border_radius: style.border_radius,
211 opacity: style.opacity,
212 position: style.position,
213 top: style.top,
214 right: style.right,
215 bottom: style.bottom,
216 left: style.left,
217 overflow: style.overflow,
218 breakable: style.breakable,
219 break_before: style.break_before,
220 min_widow_lines: style.min_widow_lines,
221 min_orphan_lines: style.min_orphan_lines,
222 }
223 }
224}
225
226impl Default for ElementStyleInfo {
227 fn default() -> Self {
228 ElementStyleInfo {
229 margin: Edges::default(),
230 padding: Edges::default(),
231 border_width: Edges::default(),
232 width: None,
233 height: None,
234 min_width: None,
235 min_height: None,
236 max_width: None,
237 max_height: None,
238 flex_direction: FlexDirection::default(),
239 justify_content: JustifyContent::default(),
240 align_items: AlignItems::default(),
241 align_self: None,
242 flex_wrap: FlexWrap::default(),
243 align_content: AlignContent::default(),
244 flex_grow: 0.0,
245 flex_shrink: 1.0,
246 flex_basis: None,
247 gap: 0.0,
248 row_gap: 0.0,
249 column_gap: 0.0,
250 font_family: "Helvetica".to_string(),
251 font_size: 12.0,
252 font_weight: 400,
253 font_style: FontStyle::default(),
254 line_height: 1.4,
255 text_align: TextAlign::default(),
256 letter_spacing: 0.0,
257 text_decoration: TextDecoration::None,
258 text_transform: TextTransform::None,
259 color: Color::BLACK,
260 background_color: None,
261 border_color: EdgeValues::uniform(Color::BLACK),
262 border_radius: CornerValues::uniform(0.0),
263 opacity: 1.0,
264 position: Position::default(),
265 top: None,
266 right: None,
267 bottom: None,
268 left: None,
269 overflow: Overflow::default(),
270 breakable: false,
271 break_before: false,
272 min_widow_lines: 2,
273 min_orphan_lines: 2,
274 }
275 }
276}
277
278#[derive(Debug, Clone, Serialize)]
280#[serde(rename_all = "camelCase")]
281pub struct ElementInfo {
282 pub x: f64,
283 pub y: f64,
284 pub width: f64,
285 pub height: f64,
286 pub kind: String,
288 pub node_type: String,
290 pub style: ElementStyleInfo,
292 pub children: Vec<ElementInfo>,
294 #[serde(skip_serializing_if = "Option::is_none")]
296 pub source_location: Option<SourceLocation>,
297 #[serde(skip_serializing_if = "Option::is_none")]
299 pub text_content: Option<String>,
300 #[serde(skip_serializing_if = "Option::is_none")]
302 pub href: Option<String>,
303 #[serde(skip_serializing_if = "Option::is_none")]
305 pub bookmark: Option<String>,
306}
307
308impl LayoutInfo {
309 pub fn from_pages(pages: &[LayoutPage]) -> Self {
311 LayoutInfo {
312 pages: pages
313 .iter()
314 .map(|page| {
315 let (page_w, page_h) = page.config.size.dimensions();
316 let content_x = page.config.margin.left;
317 let content_y = page.config.margin.top;
318 let content_width = page_w - page.config.margin.horizontal();
319 let content_height = page_h - page.config.margin.vertical();
320
321 let elements = Self::build_element_tree(&page.elements);
322
323 PageInfo {
324 width: page_w,
325 height: page_h,
326 content_x,
327 content_y,
328 content_width,
329 content_height,
330 elements,
331 }
332 })
333 .collect(),
334 }
335 }
336
337 fn build_element_tree(elems: &[LayoutElement]) -> Vec<ElementInfo> {
338 elems
339 .iter()
340 .map(|elem| {
341 let kind = match &elem.draw {
342 DrawCommand::None => "None",
343 DrawCommand::Rect { .. } => "Rect",
344 DrawCommand::Text { .. } => "Text",
345 DrawCommand::Image { .. } => "Image",
346 DrawCommand::ImagePlaceholder => "ImagePlaceholder",
347 DrawCommand::Svg { .. } => "Svg",
348 DrawCommand::Barcode { .. } => "Barcode",
349 DrawCommand::QrCode { .. } => "QrCode",
350 DrawCommand::Chart { .. } => "Chart",
351 DrawCommand::Watermark { .. } => "Watermark",
352 DrawCommand::FormField { .. } => "FormField",
353 };
354 let text_content = match &elem.draw {
355 DrawCommand::Text { lines, .. } => {
356 let text: String = lines
357 .iter()
358 .flat_map(|line| {
359 line.glyphs.iter().flat_map(|g| {
360 g.cluster_text.as_deref().unwrap_or("").chars().chain(
362 if g.cluster_text.is_none() {
363 Some(g.char_value)
364 } else {
365 None
366 },
367 )
368 })
369 })
370 .collect();
371 if text.is_empty() {
372 None
373 } else {
374 Some(text)
375 }
376 }
377 _ => None,
378 };
379 let node_type = elem.node_type.clone().unwrap_or_else(|| kind.to_string());
380 let style = elem
381 .resolved_style
382 .as_ref()
383 .map(ElementStyleInfo::from_resolved)
384 .unwrap_or_default();
385 ElementInfo {
386 x: elem.x,
387 y: elem.y,
388 width: elem.width,
389 height: elem.height,
390 kind: kind.to_string(),
391 node_type,
392 style,
393 children: Self::build_element_tree(&elem.children),
394 source_location: elem.source_location.clone(),
395 text_content,
396 href: elem.href.clone(),
397 bookmark: elem.bookmark.clone(),
398 }
399 })
400 .collect()
401 }
402}
403
404#[derive(Debug, Clone)]
406pub struct LayoutPage {
407 pub width: f64,
408 pub height: f64,
409 pub elements: Vec<LayoutElement>,
410 pub(crate) fixed_header: Vec<(Node, f64)>,
412 pub(crate) fixed_footer: Vec<(Node, f64)>,
414 pub(crate) watermarks: Vec<Node>,
416 pub(crate) config: PageConfig,
418}
419
420#[derive(Debug, Clone)]
422pub struct LayoutElement {
423 pub x: f64,
425 pub y: f64,
426 pub width: f64,
428 pub height: f64,
429 pub draw: DrawCommand,
431 pub children: Vec<LayoutElement>,
433 pub node_type: Option<String>,
435 pub resolved_style: Option<ResolvedStyle>,
437 pub source_location: Option<SourceLocation>,
439 pub href: Option<String>,
441 pub bookmark: Option<String>,
443 pub alt: Option<String>,
445 pub is_header_row: bool,
447 pub overflow: Overflow,
449 pub opacity: f64,
454}
455
456fn node_kind_name(kind: &NodeKind) -> &'static str {
458 match kind {
459 NodeKind::View => "View",
460 NodeKind::Text { .. } => "Text",
461 NodeKind::Image { .. } => "Image",
462 NodeKind::Table { .. } => "Table",
463 NodeKind::TableRow { .. } => "TableRow",
464 NodeKind::TableCell { .. } => "TableCell",
465 NodeKind::Fixed {
466 position: FixedPosition::Header,
467 } => "FixedHeader",
468 NodeKind::Fixed {
469 position: FixedPosition::Footer,
470 } => "FixedFooter",
471 NodeKind::Page { .. } => "Page",
472 NodeKind::PageBreak => "PageBreak",
473 NodeKind::Svg { .. } => "Svg",
474 NodeKind::Canvas { .. } => "Canvas",
475 NodeKind::Barcode { .. } => "Barcode",
476 NodeKind::QrCode { .. } => "QrCode",
477 NodeKind::BarChart { .. } => "BarChart",
478 NodeKind::LineChart { .. } => "LineChart",
479 NodeKind::PieChart { .. } => "PieChart",
480 NodeKind::AreaChart { .. } => "AreaChart",
481 NodeKind::DotPlot { .. } => "DotPlot",
482 NodeKind::Watermark { .. } => "Watermark",
483 NodeKind::TextField { .. } => "TextField",
484 NodeKind::Checkbox { .. } => "Checkbox",
485 NodeKind::Dropdown { .. } => "Dropdown",
486 NodeKind::RadioButton { .. } => "RadioButton",
487 }
488}
489
490#[derive(Debug, Clone)]
492pub enum FormFieldType {
493 TextField {
494 value: Option<String>,
495 placeholder: Option<String>,
496 multiline: bool,
497 password: bool,
498 read_only: bool,
499 max_length: Option<u32>,
500 font_size: f64,
501 },
502 Checkbox {
503 checked: bool,
504 read_only: bool,
505 },
506 Dropdown {
507 options: Vec<String>,
508 value: Option<String>,
509 read_only: bool,
510 font_size: f64,
511 },
512 RadioButton {
513 value: String,
514 checked: bool,
515 read_only: bool,
516 },
517}
518
519#[derive(Debug, Clone)]
521pub enum DrawCommand {
522 None,
524 Rect {
526 background: Option<Color>,
527 border_width: Edges,
528 border_color: EdgeValues<Color>,
529 border_radius: CornerValues,
530 opacity: f64,
531 box_shadow: Option<Box<crate::style::BoxShadow>>,
534 background_gradient: Option<Box<crate::style::Background>>,
538 },
539 Text {
541 lines: Vec<TextLine>,
542 color: Color,
543 text_decoration: TextDecoration,
544 opacity: f64,
545 },
546 Image {
548 image_data: crate::image_loader::LoadedImage,
549 },
550 ImagePlaceholder,
552 Svg {
554 commands: Vec<crate::svg::SvgCommand>,
555 width: f64,
557 height: f64,
559 viewbox_min_x: f64,
563 viewbox_min_y: f64,
564 viewbox_width: f64,
565 viewbox_height: f64,
566 clip: bool,
568 },
569 Barcode {
571 bars: Vec<u8>,
572 bar_width: f64,
573 height: f64,
574 color: Color,
575 },
576 QrCode {
578 modules: Vec<Vec<bool>>,
579 module_size: f64,
580 color: Color,
581 },
582 Chart {
584 primitives: Vec<crate::chart::ChartPrimitive>,
585 },
586 Watermark {
588 lines: Vec<TextLine>,
589 color: Color,
590 opacity: f64,
591 angle_rad: f64,
592 font_family: String,
594 },
595 FormField {
597 field_type: FormFieldType,
598 name: String,
599 },
600}
601
602#[derive(Debug, Clone)]
603pub struct TextLine {
604 pub x: f64,
605 pub y: f64,
606 pub glyphs: Vec<PositionedGlyph>,
607 pub width: f64,
608 pub height: f64,
609 pub word_spacing: f64,
611}
612
613#[derive(Debug, Clone)]
614pub struct PositionedGlyph {
615 pub glyph_id: u16,
618 pub x_offset: f64,
620 pub y_offset: f64,
622 pub x_advance: f64,
624 pub font_size: f64,
625 pub font_family: String,
626 pub font_weight: u32,
627 pub font_style: FontStyle,
628 pub char_value: char,
630 pub color: Option<Color>,
632 pub href: Option<String>,
634 pub text_decoration: TextDecoration,
636 pub letter_spacing: f64,
638 pub cluster_text: Option<String>,
641}
642
643fn offset_element_y(el: &mut LayoutElement, dy: f64) {
646 el.y += dy;
647 if let DrawCommand::Text { ref mut lines, .. } = el.draw {
648 for line in lines.iter_mut() {
649 line.y += dy;
650 }
651 }
652 for child in &mut el.children {
653 offset_element_y(child, dy);
654 }
655}
656
657#[allow(dead_code)]
659fn offset_element_x(el: &mut LayoutElement, dx: f64) {
660 el.x += dx;
661 if let DrawCommand::Text { ref mut lines, .. } = el.draw {
662 for line in lines.iter_mut() {
663 line.x += dx;
664 }
665 }
666 for child in &mut el.children {
667 offset_element_x(child, dx);
668 }
669}
670
671fn reapply_justify_content(elem: &mut LayoutElement) {
675 let style = match elem.resolved_style {
676 Some(ref s) => s,
677 None => return,
678 };
679 if matches!(style.justify_content, JustifyContent::FlexStart) {
680 return;
681 }
682 if elem.children.is_empty() {
683 return;
684 }
685
686 let padding_top = style.padding.top + style.border_width.top;
687 let padding_bottom = style.padding.bottom + style.border_width.bottom;
688 let inner_h = elem.height - padding_top - padding_bottom;
689 let content_top = elem.y + padding_top;
690
691 let last_child = &elem.children[elem.children.len() - 1];
693 let children_bottom = last_child.y + last_child.height;
694 let children_span = children_bottom - content_top;
695 let slack = inner_h - children_span;
696 if slack < 0.001 {
697 return;
698 }
699
700 let n = elem.children.len();
701 let offsets: Vec<f64> = match style.justify_content {
702 JustifyContent::FlexEnd => vec![slack; n],
703 JustifyContent::Center => vec![slack / 2.0; n],
704 JustifyContent::SpaceBetween => {
705 if n <= 1 {
706 vec![0.0; n]
707 } else {
708 let per_gap = slack / (n - 1) as f64;
709 (0..n).map(|i| i as f64 * per_gap).collect()
710 }
711 }
712 JustifyContent::SpaceAround => {
713 let space = slack / n as f64;
714 (0..n).map(|i| space / 2.0 + i as f64 * space).collect()
715 }
716 JustifyContent::SpaceEvenly => {
717 let space = slack / (n + 1) as f64;
718 (0..n).map(|i| (i + 1) as f64 * space).collect()
719 }
720 JustifyContent::FlexStart => unreachable!(),
721 };
722
723 for (i, child) in elem.children.iter_mut().enumerate() {
724 let dy = offsets[i];
725 if dy.abs() > 0.001 {
726 offset_element_y(child, dy);
727 }
728 }
729}
730
731fn apply_text_transform(text: &str, transform: TextTransform) -> String {
733 match transform {
734 TextTransform::None => text.to_string(),
735 TextTransform::Uppercase => text.to_uppercase(),
736 TextTransform::Lowercase => text.to_lowercase(),
737 TextTransform::Capitalize => {
738 let mut result = String::with_capacity(text.len());
739 let mut prev_is_whitespace = true;
740 for ch in text.chars() {
741 if prev_is_whitespace && ch.is_alphabetic() {
742 for upper in ch.to_uppercase() {
743 result.push(upper);
744 }
745 } else {
746 result.push(ch);
747 }
748 prev_is_whitespace = ch.is_whitespace();
749 }
750 result
751 }
752 }
753}
754
755pub const PAGE_NUMBER_SENTINEL: char = '\x02';
759
760pub const TOTAL_PAGES_SENTINEL: char = '\x03';
762
763fn substitute_page_placeholders(text: &str) -> String {
768 if text.contains("{{pageNumber}}") || text.contains("{{totalPages}}") {
769 text.replace("{{pageNumber}}", &PAGE_NUMBER_SENTINEL.to_string())
770 .replace("{{totalPages}}", &TOTAL_PAGES_SENTINEL.to_string())
771 } else {
772 text.to_string()
773 }
774}
775
776fn apply_char_transform(ch: char, transform: TextTransform, is_word_start: bool) -> char {
779 match transform {
780 TextTransform::None => ch,
781 TextTransform::Uppercase => ch.to_uppercase().next().unwrap_or(ch),
782 TextTransform::Lowercase => ch.to_lowercase().next().unwrap_or(ch),
783 TextTransform::Capitalize => {
784 if is_word_start && ch.is_alphabetic() {
785 ch.to_uppercase().next().unwrap_or(ch)
786 } else {
787 ch
788 }
789 }
790 }
791}
792
793pub struct LayoutEngine {
795 text_layout: TextLayout,
796 image_dim_cache: RefCell<HashMap<String, (u32, u32)>>,
797}
798
799#[derive(Debug, Clone)]
801struct PageCursor {
802 config: PageConfig,
803 content_width: f64,
804 content_height: f64,
805 y: f64,
806 elements: Vec<LayoutElement>,
807 fixed_header: Vec<(Node, f64)>,
808 fixed_footer: Vec<(Node, f64)>,
809 watermarks: Vec<Node>,
811 content_x: f64,
812 content_y: f64,
813 continuation_top_offset: f64,
815}
816
817impl PageCursor {
818 fn new(config: &PageConfig) -> Self {
819 let (page_w, page_h) = config.size.dimensions();
820 let content_width = page_w - config.margin.horizontal();
821 let content_height = page_h - config.margin.vertical();
822
823 Self {
824 config: config.clone(),
825 content_width,
826 content_height,
827 y: 0.0,
828 elements: Vec::new(),
829 fixed_header: Vec::new(),
830 fixed_footer: Vec::new(),
831 watermarks: Vec::new(),
832 content_x: config.margin.left,
833 content_y: config.margin.top,
834 continuation_top_offset: 0.0,
835 }
836 }
837
838 fn remaining_height(&self) -> f64 {
839 let footer_height: f64 = self.fixed_footer.iter().map(|(_, h)| *h).sum();
840 (self.content_height - self.y - footer_height).max(0.0)
841 }
842
843 fn finalize(&self) -> LayoutPage {
844 let (page_w, page_h) = self.config.size.dimensions();
845 LayoutPage {
846 width: page_w,
847 height: page_h,
848 elements: self.elements.clone(),
849 fixed_header: self.fixed_header.clone(),
850 fixed_footer: self.fixed_footer.clone(),
851 watermarks: self.watermarks.clone(),
852 config: self.config.clone(),
853 }
854 }
855
856 fn new_page(&self) -> Self {
857 let mut cursor = PageCursor::new(&self.config);
858 cursor.fixed_header = self.fixed_header.clone();
859 cursor.fixed_footer = self.fixed_footer.clone();
860 cursor.watermarks = self.watermarks.clone();
861 cursor.continuation_top_offset = self.continuation_top_offset;
862
863 let header_height: f64 = cursor.fixed_header.iter().map(|(_, h)| *h).sum();
864 cursor.y = header_height + cursor.continuation_top_offset;
865
866 cursor
867 }
868}
869
870impl Default for LayoutEngine {
871 fn default() -> Self {
872 Self::new()
873 }
874}
875
876impl LayoutEngine {
877 pub fn new() -> Self {
878 Self {
879 text_layout: TextLayout::new(),
880 image_dim_cache: RefCell::new(HashMap::new()),
881 }
882 }
883
884 fn get_image_dimensions(&self, src: &str) -> Option<(u32, u32)> {
886 if let Some(dims) = self.image_dim_cache.borrow().get(src) {
887 return Some(*dims);
888 }
889 if let Ok(dims) = crate::image_loader::load_image_dimensions(src) {
890 self.image_dim_cache
891 .borrow_mut()
892 .insert(src.to_string(), dims);
893 Some(dims)
894 } else {
895 None
896 }
897 }
898
899 pub fn layout(&self, document: &Document, font_context: &FontContext) -> Vec<LayoutPage> {
901 let mut pages: Vec<LayoutPage> = Vec::new();
902 let mut cursor = PageCursor::new(&document.default_page);
903
904 let base = document.default_style.clone().unwrap_or_default();
906 let root_style = Style {
907 lang: base.lang.clone().or(document.metadata.lang.clone()),
908 ..base
909 }
910 .resolve(None, cursor.content_width);
911
912 for node in &document.children {
913 match &node.kind {
914 NodeKind::Page { config } => {
915 if !cursor.elements.is_empty() || cursor.y > 0.0 {
916 pages.push(cursor.finalize());
917 }
918 cursor = PageCursor::new(config);
919
920 let mut page_root = node.style.resolve(Some(&root_style), cursor.content_width);
927 page_root.height = SizeConstraint::Fixed(cursor.content_height);
928
929 let cx = cursor.content_x;
930 let cw = cursor.content_width;
931 self.layout_children(
932 &node.children,
933 &node.style,
934 &mut cursor,
935 &mut pages,
936 cx,
937 cw,
938 Some(&page_root),
939 font_context,
940 );
941 }
942 NodeKind::PageBreak => {
943 pages.push(cursor.finalize());
944 cursor = cursor.new_page();
945 }
946 _ => {
947 let cx = cursor.content_x;
948 let cw = cursor.content_width;
949 self.layout_node(
950 node,
951 &mut cursor,
952 &mut pages,
953 cx,
954 cw,
955 Some(&root_style),
956 font_context,
957 None,
958 None,
959 );
960 }
961 }
962 }
963
964 if !cursor.elements.is_empty() || cursor.y > 0.0 {
965 pages.push(cursor.finalize());
966 }
967
968 self.inject_fixed_elements(&mut pages, font_context);
969
970 pages
971 }
972
973 #[allow(clippy::too_many_arguments)]
974 fn layout_node(
975 &self,
976 node: &Node,
977 cursor: &mut PageCursor,
978 pages: &mut Vec<LayoutPage>,
979 x: f64,
980 available_width: f64,
981 parent_style: Option<&ResolvedStyle>,
982 font_context: &FontContext,
983 cross_axis_height: Option<f64>,
984 forced_outer_width: Option<f64>,
985 ) {
986 let mut style = node.style.resolve(parent_style, available_width);
987
988 if let Some(h) = cross_axis_height {
991 if matches!(style.height, SizeConstraint::Auto) {
992 style.height = SizeConstraint::Fixed(h);
993 }
994 }
995
996 if let Some(w) = forced_outer_width {
1001 style.width = SizeConstraint::Fixed(w);
1002 }
1003
1004 if style.break_before {
1005 pages.push(cursor.finalize());
1006 *cursor = cursor.new_page();
1007 }
1008
1009 match &node.kind {
1010 NodeKind::PageBreak => {
1011 pages.push(cursor.finalize());
1012 *cursor = cursor.new_page();
1013 }
1014
1015 NodeKind::Fixed { position } => {
1016 let height = self.measure_node_height(node, available_width, &style, font_context);
1017 match position {
1018 FixedPosition::Header => {
1019 cursor.fixed_header.push((node.clone(), height));
1020 cursor.y += height;
1021 }
1022 FixedPosition::Footer => {
1023 cursor.fixed_footer.push((node.clone(), height));
1024 }
1025 }
1026 }
1027
1028 NodeKind::Watermark { .. } => {
1029 cursor.watermarks.push(node.clone());
1031 }
1032
1033 NodeKind::TextField {
1034 name,
1035 value,
1036 placeholder,
1037 width: field_w,
1038 height: field_h,
1039 multiline,
1040 password,
1041 read_only,
1042 max_length,
1043 font_size,
1044 } => {
1045 self.layout_form_field(
1046 node,
1047 &style,
1048 cursor,
1049 pages,
1050 x,
1051 *field_w,
1052 *field_h,
1053 DrawCommand::FormField {
1054 field_type: FormFieldType::TextField {
1055 value: value.clone(),
1056 placeholder: placeholder.clone(),
1057 multiline: *multiline,
1058 password: *password,
1059 read_only: *read_only,
1060 max_length: *max_length,
1061 font_size: *font_size,
1062 },
1063 name: name.clone(),
1064 },
1065 "TextField",
1066 );
1067 }
1068
1069 NodeKind::Checkbox {
1070 name,
1071 checked,
1072 width: field_w,
1073 height: field_h,
1074 read_only,
1075 } => {
1076 self.layout_form_field(
1077 node,
1078 &style,
1079 cursor,
1080 pages,
1081 x,
1082 *field_w,
1083 *field_h,
1084 DrawCommand::FormField {
1085 field_type: FormFieldType::Checkbox {
1086 checked: *checked,
1087 read_only: *read_only,
1088 },
1089 name: name.clone(),
1090 },
1091 "Checkbox",
1092 );
1093 }
1094
1095 NodeKind::Dropdown {
1096 name,
1097 options,
1098 value,
1099 width: field_w,
1100 height: field_h,
1101 read_only,
1102 font_size,
1103 } => {
1104 self.layout_form_field(
1105 node,
1106 &style,
1107 cursor,
1108 pages,
1109 x,
1110 *field_w,
1111 *field_h,
1112 DrawCommand::FormField {
1113 field_type: FormFieldType::Dropdown {
1114 options: options.clone(),
1115 value: value.clone(),
1116 read_only: *read_only,
1117 font_size: *font_size,
1118 },
1119 name: name.clone(),
1120 },
1121 "Dropdown",
1122 );
1123 }
1124
1125 NodeKind::RadioButton {
1126 name,
1127 value,
1128 checked,
1129 width: field_w,
1130 height: field_h,
1131 read_only,
1132 } => {
1133 self.layout_form_field(
1134 node,
1135 &style,
1136 cursor,
1137 pages,
1138 x,
1139 *field_w,
1140 *field_h,
1141 DrawCommand::FormField {
1142 field_type: FormFieldType::RadioButton {
1143 value: value.clone(),
1144 checked: *checked,
1145 read_only: *read_only,
1146 },
1147 name: name.clone(),
1148 },
1149 "RadioButton",
1150 );
1151 }
1152
1153 NodeKind::Text {
1154 content,
1155 href,
1156 runs,
1157 } => {
1158 self.layout_text(
1159 content,
1160 href.as_deref(),
1161 runs,
1162 &style,
1163 cursor,
1164 pages,
1165 x,
1166 available_width,
1167 font_context,
1168 node.source_location.as_ref(),
1169 node.bookmark.as_deref(),
1170 );
1171 }
1172
1173 NodeKind::Image { width, height, .. } => {
1174 self.layout_image(
1175 node,
1176 &style,
1177 cursor,
1178 pages,
1179 x,
1180 available_width,
1181 *width,
1182 *height,
1183 );
1184 }
1185
1186 NodeKind::Table { columns } => {
1187 self.layout_table(
1188 node,
1189 &style,
1190 columns,
1191 cursor,
1192 pages,
1193 x,
1194 available_width,
1195 font_context,
1196 );
1197 }
1198
1199 NodeKind::View | NodeKind::Page { .. } => {
1200 self.layout_view(
1201 node,
1202 &style,
1203 cursor,
1204 pages,
1205 x,
1206 available_width,
1207 font_context,
1208 );
1209 }
1210
1211 NodeKind::TableRow { .. } | NodeKind::TableCell { .. } => {
1212 self.layout_view(
1213 node,
1214 &style,
1215 cursor,
1216 pages,
1217 x,
1218 available_width,
1219 font_context,
1220 );
1221 }
1222
1223 NodeKind::Svg {
1224 width: svg_w,
1225 height: svg_h,
1226 view_box,
1227 content,
1228 } => {
1229 self.layout_svg(
1230 node,
1231 &style,
1232 cursor,
1233 pages,
1234 x,
1235 available_width,
1236 *svg_w,
1237 *svg_h,
1238 view_box.as_deref(),
1239 content,
1240 );
1241 }
1242
1243 NodeKind::Barcode {
1244 data,
1245 format,
1246 width: explicit_width,
1247 height: bar_height,
1248 } => {
1249 self.layout_barcode(
1250 node,
1251 &style,
1252 cursor,
1253 pages,
1254 x,
1255 available_width,
1256 data,
1257 *format,
1258 *explicit_width,
1259 *bar_height,
1260 );
1261 }
1262
1263 NodeKind::QrCode {
1264 data,
1265 size: explicit_size,
1266 } => {
1267 self.layout_qrcode(
1268 node,
1269 &style,
1270 cursor,
1271 pages,
1272 x,
1273 available_width,
1274 data,
1275 *explicit_size,
1276 );
1277 }
1278
1279 NodeKind::Canvas {
1280 width: canvas_w,
1281 height: canvas_h,
1282 operations,
1283 } => {
1284 self.layout_canvas(
1285 node,
1286 &style,
1287 cursor,
1288 pages,
1289 x,
1290 available_width,
1291 *canvas_w,
1292 *canvas_h,
1293 operations,
1294 );
1295 }
1296
1297 NodeKind::BarChart {
1298 data,
1299 width: chart_w,
1300 height: chart_h,
1301 color,
1302 show_labels,
1303 show_values,
1304 show_grid,
1305 title,
1306 } => {
1307 let config = crate::chart::bar::BarChartConfig {
1308 color: color.clone(),
1309 show_labels: *show_labels,
1310 show_values: *show_values,
1311 show_grid: *show_grid,
1312 title: title.clone(),
1313 };
1314 let primitives = crate::chart::bar::build(*chart_w, *chart_h, data, &config);
1315 self.layout_chart(
1316 node, &style, cursor, pages, x, *chart_w, *chart_h, primitives, "BarChart",
1317 );
1318 }
1319
1320 NodeKind::LineChart {
1321 series,
1322 labels,
1323 width: chart_w,
1324 height: chart_h,
1325 show_points,
1326 show_grid,
1327 title,
1328 } => {
1329 let config = crate::chart::line::LineChartConfig {
1330 show_points: *show_points,
1331 show_grid: *show_grid,
1332 title: title.clone(),
1333 };
1334 let primitives =
1335 crate::chart::line::build(*chart_w, *chart_h, series, labels, &config);
1336 self.layout_chart(
1337 node,
1338 &style,
1339 cursor,
1340 pages,
1341 x,
1342 *chart_w,
1343 *chart_h,
1344 primitives,
1345 "LineChart",
1346 );
1347 }
1348
1349 NodeKind::PieChart {
1350 data,
1351 width: chart_w,
1352 height: chart_h,
1353 donut,
1354 show_legend,
1355 title,
1356 } => {
1357 let config = crate::chart::pie::PieChartConfig {
1358 donut: *donut,
1359 show_legend: *show_legend,
1360 title: title.clone(),
1361 };
1362 let primitives = crate::chart::pie::build(*chart_w, *chart_h, data, &config);
1363 self.layout_chart(
1364 node, &style, cursor, pages, x, *chart_w, *chart_h, primitives, "PieChart",
1365 );
1366 }
1367
1368 NodeKind::AreaChart {
1369 series,
1370 labels,
1371 width: chart_w,
1372 height: chart_h,
1373 show_grid,
1374 title,
1375 } => {
1376 let config = crate::chart::area::AreaChartConfig {
1377 show_grid: *show_grid,
1378 title: title.clone(),
1379 };
1380 let primitives =
1381 crate::chart::area::build(*chart_w, *chart_h, series, labels, &config);
1382 self.layout_chart(
1383 node,
1384 &style,
1385 cursor,
1386 pages,
1387 x,
1388 *chart_w,
1389 *chart_h,
1390 primitives,
1391 "AreaChart",
1392 );
1393 }
1394
1395 NodeKind::DotPlot {
1396 groups,
1397 width: chart_w,
1398 height: chart_h,
1399 x_min,
1400 x_max,
1401 y_min,
1402 y_max,
1403 x_label,
1404 y_label,
1405 show_legend,
1406 dot_size,
1407 } => {
1408 let config = crate::chart::dot::DotPlotConfig {
1409 x_min: *x_min,
1410 x_max: *x_max,
1411 y_min: *y_min,
1412 y_max: *y_max,
1413 x_label: x_label.clone(),
1414 y_label: y_label.clone(),
1415 show_legend: *show_legend,
1416 dot_size: *dot_size,
1417 };
1418 let primitives = crate::chart::dot::build(*chart_w, *chart_h, groups, &config);
1419 self.layout_chart(
1420 node, &style, cursor, pages, x, *chart_w, *chart_h, primitives, "DotPlot",
1421 );
1422 }
1423 }
1424 }
1425
1426 #[allow(clippy::too_many_arguments)]
1427 fn layout_view(
1428 &self,
1429 node: &Node,
1430 style: &ResolvedStyle,
1431 cursor: &mut PageCursor,
1432 pages: &mut Vec<LayoutPage>,
1433 x: f64,
1434 available_width: f64,
1435 font_context: &FontContext,
1436 ) {
1437 let padding = &style.padding;
1438 let margin = &style.margin.to_edges();
1439 let border = &style.border_width;
1440
1441 let outer_width = match style.width {
1442 SizeConstraint::Fixed(w) => w,
1443 SizeConstraint::Auto => available_width - margin.horizontal(),
1444 };
1445 let inner_width = outer_width - padding.horizontal() - border.horizontal();
1446
1447 let children_height =
1448 self.measure_children_height(&node.children, inner_width, style, font_context);
1449 let total_height = match style.height {
1450 SizeConstraint::Fixed(h) => h,
1451 SizeConstraint::Auto => children_height + padding.vertical() + border.vertical(),
1452 };
1453
1454 let node_x = x + margin.left;
1455
1456 let fits = total_height <= cursor.remaining_height() - margin.vertical();
1457
1458 if fits || !style.breakable {
1459 if !fits && !style.breakable {
1460 pages.push(cursor.finalize());
1461 *cursor = cursor.new_page();
1462 }
1463
1464 let rect_y = cursor.content_y + cursor.y + margin.top;
1466 let snapshot = cursor.elements.len();
1467
1468 let saved_y = cursor.y;
1469 cursor.y += margin.top + padding.top + border.top;
1470
1471 let children_x = node_x + padding.left + border.left;
1472 let is_grid =
1473 matches!(style.display, Display::Grid) && style.grid_template_columns.is_some();
1474 if is_grid {
1475 self.layout_grid_children(
1476 &node.children,
1477 style,
1478 cursor,
1479 pages,
1480 children_x,
1481 inner_width,
1482 font_context,
1483 );
1484 } else {
1485 self.layout_children(
1486 &node.children,
1487 &node.style,
1488 cursor,
1489 pages,
1490 children_x,
1491 inner_width,
1492 Some(style),
1493 font_context,
1494 );
1495 }
1496
1497 let child_elements: Vec<LayoutElement> = cursor.elements.drain(snapshot..).collect();
1499
1500 let rect_element = LayoutElement {
1501 x: node_x,
1502 y: rect_y,
1503 width: outer_width,
1504 height: total_height,
1505 draw: DrawCommand::Rect {
1506 background: style.background_color,
1507 border_width: style.border_width,
1508 border_color: style.border_color,
1509 border_radius: style.border_radius,
1510 opacity: 1.0,
1511 box_shadow: style.box_shadow.map(Box::new),
1512 background_gradient: style.background.clone().map(Box::new),
1513 },
1514 children: child_elements,
1515 node_type: Some(node_kind_name(&node.kind).to_string()),
1516 resolved_style: Some(style.clone()),
1517 source_location: node.source_location.clone(),
1518 href: node.href.clone(),
1519 bookmark: node.bookmark.clone(),
1520 alt: None,
1521 is_header_row: false,
1522 overflow: style.overflow,
1523 opacity: style.opacity,
1524 };
1525 cursor.elements.push(rect_element);
1526
1527 cursor.y = saved_y + total_height + margin.vertical();
1528 } else {
1529 self.layout_breakable_view(
1530 node,
1531 style,
1532 cursor,
1533 pages,
1534 node_x,
1535 outer_width,
1536 inner_width,
1537 font_context,
1538 );
1539 }
1540 }
1541
1542 #[allow(clippy::too_many_arguments)]
1543 fn layout_breakable_view(
1544 &self,
1545 node: &Node,
1546 style: &ResolvedStyle,
1547 cursor: &mut PageCursor,
1548 pages: &mut Vec<LayoutPage>,
1549 node_x: f64,
1550 outer_width: f64,
1551 inner_width: f64,
1552 font_context: &FontContext,
1553 ) {
1554 let padding = &style.padding;
1555 let border = &style.border_width;
1556 let margin = &style.margin.to_edges();
1557
1558 let initial_page_count = pages.len();
1560 let snapshot = cursor.elements.len();
1561 let rect_start_y = cursor.content_y + cursor.y + margin.top;
1562
1563 cursor.y += margin.top + padding.top + border.top;
1564 let prev_continuation_offset = cursor.continuation_top_offset;
1565 cursor.continuation_top_offset = padding.top + border.top;
1566
1567 if node.bookmark.is_some() {
1569 cursor.elements.push(LayoutElement {
1570 x: node_x,
1571 y: cursor.content_y + cursor.y,
1572 width: 0.0,
1573 height: 0.0,
1574 draw: DrawCommand::None,
1575 children: vec![],
1576 node_type: None,
1577 resolved_style: None,
1578 source_location: None,
1579 href: None,
1580 bookmark: node.bookmark.clone(),
1581 alt: None,
1582 is_header_row: false,
1583 overflow: Overflow::default(),
1584 opacity: 1.0,
1585 });
1586 }
1587
1588 let children_x = node_x + padding.left + border.left;
1589 let is_grid =
1590 matches!(style.display, Display::Grid) && style.grid_template_columns.is_some();
1591 if is_grid {
1592 self.layout_grid_children(
1593 &node.children,
1594 style,
1595 cursor,
1596 pages,
1597 children_x,
1598 inner_width,
1599 font_context,
1600 );
1601 } else {
1602 self.layout_children(
1603 &node.children,
1604 &node.style,
1605 cursor,
1606 pages,
1607 children_x,
1608 inner_width,
1609 Some(style),
1610 font_context,
1611 );
1612 }
1613
1614 cursor.continuation_top_offset = prev_continuation_offset;
1615
1616 let has_visual = style.background_color.is_some()
1618 || style.border_width.top > 0.0
1619 || style.border_width.right > 0.0
1620 || style.border_width.bottom > 0.0
1621 || style.border_width.left > 0.0;
1622 let needs_wrapper = has_visual || style.flex_grow > 0.0;
1624
1625 if !needs_wrapper {
1626 cursor.y += padding.bottom + border.bottom + margin.bottom;
1628 return;
1629 }
1630
1631 let draw_cmd = DrawCommand::Rect {
1632 background: style.background_color,
1633 border_width: style.border_width,
1634 border_color: style.border_color,
1635 border_radius: style.border_radius,
1636 opacity: 1.0,
1637 box_shadow: style.box_shadow.map(Box::new),
1638 background_gradient: style.background.clone().map(Box::new),
1639 };
1640
1641 if pages.len() == initial_page_count {
1642 let child_elements: Vec<LayoutElement> = cursor.elements.drain(snapshot..).collect();
1644 let rect_height =
1645 cursor.content_y + cursor.y + padding.bottom + border.bottom - rect_start_y;
1646 cursor.elements.push(LayoutElement {
1647 x: node_x,
1648 y: rect_start_y,
1649 width: outer_width,
1650 height: rect_height,
1651 draw: draw_cmd,
1652 children: child_elements,
1653 node_type: Some(node_kind_name(&node.kind).to_string()),
1654 resolved_style: Some(style.clone()),
1655 source_location: node.source_location.clone(),
1656 href: node.href.clone(),
1657 bookmark: node.bookmark.clone(),
1658 alt: None,
1659 is_header_row: false,
1660 overflow: style.overflow,
1661 opacity: style.opacity,
1662 });
1663 } else {
1664 let page = &mut pages[initial_page_count];
1668 let footer_h: f64 = page.fixed_footer.iter().map(|(_, h)| *h).sum();
1669 let page_content_bottom =
1670 page.config.margin.top + (page.height - page.config.margin.vertical()) - footer_h;
1671 let our_elements: Vec<LayoutElement> = page.elements.drain(snapshot..).collect();
1672 if !our_elements.is_empty() {
1673 let rect_height = page_content_bottom - rect_start_y;
1674 page.elements.push(LayoutElement {
1675 x: node_x,
1676 y: rect_start_y,
1677 width: outer_width,
1678 height: rect_height,
1679 draw: draw_cmd.clone(),
1680 children: our_elements,
1681 node_type: Some(node_kind_name(&node.kind).to_string()),
1682 resolved_style: Some(style.clone()),
1683 source_location: node.source_location.clone(),
1684 href: node.href.clone(),
1685 bookmark: node.bookmark.clone(),
1686 alt: None,
1687 is_header_row: false,
1688 overflow: Overflow::default(),
1689 opacity: 1.0,
1690 });
1691 }
1692
1693 for page in &mut pages[initial_page_count + 1..] {
1695 let header_h: f64 = page.fixed_header.iter().map(|(_, h)| *h).sum();
1696 let content_top = page.config.margin.top + header_h;
1697 let footer_h: f64 = page.fixed_footer.iter().map(|(_, h)| *h).sum();
1698 let content_bottom = page.config.margin.top
1699 + (page.height - page.config.margin.vertical())
1700 - footer_h;
1701 let all_elements: Vec<LayoutElement> = page.elements.drain(..).collect();
1702 if !all_elements.is_empty() {
1703 page.elements.push(LayoutElement {
1704 x: node_x,
1705 y: content_top,
1706 width: outer_width,
1707 height: content_bottom - content_top,
1708 draw: draw_cmd.clone(),
1709 children: all_elements,
1710 node_type: Some(node_kind_name(&node.kind).to_string()),
1711 resolved_style: Some(style.clone()),
1712 source_location: node.source_location.clone(),
1713 href: None,
1714 bookmark: None,
1715 alt: None,
1716 is_header_row: false,
1717 overflow: Overflow::default(),
1718 opacity: 1.0,
1719 });
1720 }
1721 }
1722
1723 let all_elements: Vec<LayoutElement> = cursor.elements.drain(..).collect();
1725 if !all_elements.is_empty() {
1726 let header_h: f64 = cursor.fixed_header.iter().map(|(_, h)| *h).sum();
1727 let content_top = cursor.content_y + header_h;
1728 let rect_height =
1729 cursor.content_y + cursor.y + padding.bottom + border.bottom - content_top;
1730 cursor.elements.push(LayoutElement {
1731 x: node_x,
1732 y: content_top,
1733 width: outer_width,
1734 height: rect_height,
1735 draw: draw_cmd,
1736 children: all_elements,
1737 node_type: Some(node_kind_name(&node.kind).to_string()),
1738 resolved_style: Some(style.clone()),
1739 source_location: node.source_location.clone(),
1740 href: None,
1741 bookmark: None,
1742 alt: None,
1743 is_header_row: false,
1744 overflow: Overflow::default(),
1745 opacity: 1.0,
1746 });
1747 }
1748 }
1749
1750 cursor.y += padding.bottom + border.bottom + margin.bottom;
1751 }
1752
1753 #[allow(clippy::too_many_arguments)]
1754 fn layout_children(
1755 &self,
1756 children: &[Node],
1757 _parent_raw_style: &Style,
1758 cursor: &mut PageCursor,
1759 pages: &mut Vec<LayoutPage>,
1760 content_x: f64,
1761 available_width: f64,
1762 parent_style: Option<&ResolvedStyle>,
1763 font_context: &FontContext,
1764 ) {
1765 let parent_box_y = cursor.content_y + cursor.y;
1767 let parent_box_x = content_x;
1768
1769 let (flow_children, abs_children): (Vec<&Node>, Vec<&Node>) = children
1771 .iter()
1772 .partition(|child| !matches!(child.style.position, Some(Position::Absolute)));
1773
1774 let direction = parent_style
1775 .map(|s| s.flex_direction)
1776 .unwrap_or(FlexDirection::Column);
1777
1778 let row_gap = parent_style.map(|s| s.row_gap).unwrap_or(0.0);
1779 let column_gap = parent_style.map(|s| s.column_gap).unwrap_or(0.0);
1780
1781 match direction {
1783 FlexDirection::Column | FlexDirection::ColumnReverse => {
1784 let items: Vec<&Node> = if matches!(direction, FlexDirection::ColumnReverse) {
1785 flow_children.into_iter().rev().collect()
1786 } else {
1787 flow_children
1788 };
1789
1790 let justify = parent_style
1791 .map(|s| s.justify_content)
1792 .unwrap_or(JustifyContent::FlexStart);
1793 let align = parent_style
1794 .map(|s| s.align_items)
1795 .unwrap_or(AlignItems::Stretch);
1796
1797 let start_y = cursor.y;
1798 let initial_pages = pages.len();
1799
1800 let mut child_ranges: Vec<(usize, usize)> = Vec::new();
1802
1803 for (i, child) in items.iter().enumerate() {
1804 if i > 0 {
1805 cursor.y += row_gap;
1806 }
1807 let child_start = cursor.elements.len();
1808
1809 let child_margin = &child.style.resolve(parent_style, available_width).margin;
1812 let has_auto_h = child_margin.has_auto_horizontal();
1813
1814 let (child_x, layout_w) = if has_auto_h {
1821 let child_style = child.style.resolve(parent_style, available_width);
1822 let has_explicit_width =
1823 matches!(child_style.width, SizeConstraint::Fixed(_));
1824 let intrinsic = self
1825 .measure_intrinsic_width(child, &child_style, font_context)
1826 .min(available_width);
1827 let w = match child_style.width {
1828 SizeConstraint::Fixed(fw) => fw,
1829 SizeConstraint::Auto => intrinsic,
1830 };
1831 let lw = if has_explicit_width {
1832 available_width
1833 } else {
1834 w
1835 };
1836 let fixed_h = child_margin.horizontal();
1837 let slack = (available_width - w - fixed_h).max(0.0);
1838 let auto_left = child_margin.left.is_auto();
1839 let auto_right = child_margin.right.is_auto();
1840 let ml = match (auto_left, auto_right) {
1841 (true, true) => slack / 2.0,
1842 (true, false) => slack,
1843 (false, true) => 0.0,
1844 (false, false) => 0.0,
1845 };
1846 (content_x + child_margin.left.resolve() + ml, lw)
1847 } else if !matches!(align, AlignItems::Stretch | AlignItems::FlexStart) {
1848 let child_style = child.style.resolve(parent_style, available_width);
1849 let has_explicit_width =
1850 matches!(child_style.width, SizeConstraint::Fixed(_));
1851 let intrinsic = self
1852 .measure_intrinsic_width(child, &child_style, font_context)
1853 .min(available_width);
1854 let w = match child_style.width {
1855 SizeConstraint::Fixed(fw) => fw,
1856 SizeConstraint::Auto => intrinsic,
1857 };
1858 let lw = if has_explicit_width {
1859 available_width
1860 } else {
1861 w
1862 };
1863 match align {
1864 AlignItems::Center => (content_x + (available_width - w) / 2.0, lw),
1865 AlignItems::FlexEnd => (content_x + available_width - w, lw),
1866 _ => (content_x, available_width),
1867 }
1868 } else {
1869 (content_x, available_width)
1870 };
1871
1872 self.layout_node(
1873 child,
1874 cursor,
1875 pages,
1876 child_x,
1877 layout_w,
1878 parent_style,
1879 font_context,
1880 None,
1881 None,
1882 );
1883
1884 child_ranges.push((child_start, cursor.elements.len()));
1885 }
1886
1887 let container_inner_h: Option<f64> = parent_style
1890 .and_then(|ps| match ps.height {
1891 SizeConstraint::Fixed(h) => {
1892 Some(h - ps.padding.vertical() - ps.border_width.vertical())
1893 }
1894 SizeConstraint::Auto => None,
1895 })
1896 .or_else(|| {
1897 if parent_style.is_none() {
1899 Some(cursor.content_height - start_y)
1900 } else {
1901 None
1902 }
1903 });
1904
1905 if let Some(inner_h) = container_inner_h {
1906 if pages.len() == initial_pages {
1907 let child_styles: Vec<ResolvedStyle> = items
1908 .iter()
1909 .map(|child| child.style.resolve(parent_style, available_width))
1910 .collect();
1911 let total_grow: f64 = child_styles.iter().map(|s| s.flex_grow).sum();
1912 if total_grow > 0.0 {
1913 let children_total = cursor.y - start_y;
1914 let slack = (inner_h - children_total).max(0.0);
1915 if slack > 0.0 {
1916 let mut cumulative_shift = 0.0_f64;
1917 for (i, cs) in child_styles.iter().enumerate() {
1918 let (start, end) = child_ranges[i];
1919 if cumulative_shift > 0.001 {
1920 for j in start..end {
1921 offset_element_y(
1922 &mut cursor.elements[j],
1923 cumulative_shift,
1924 );
1925 }
1926 }
1927 if cs.flex_grow > 0.0 {
1928 let extra = slack * (cs.flex_grow / total_grow);
1929 if start < end {
1931 let elem = &mut cursor.elements[end - 1];
1932 elem.height += extra;
1933 reapply_justify_content(elem);
1934 }
1935 cumulative_shift += extra;
1936 }
1937 }
1938 cursor.y += cumulative_shift;
1939 }
1940 }
1941 }
1942 }
1943
1944 if let Some(inner_h) = container_inner_h {
1951 if pages.len() == initial_pages {
1952 let auto_styles: Vec<ResolvedStyle> = items
1953 .iter()
1954 .map(|child| child.style.resolve(parent_style, available_width))
1955 .collect();
1956 let total_autos: usize = auto_styles
1957 .iter()
1958 .map(|s| {
1959 s.margin.top.is_auto() as usize + s.margin.bottom.is_auto() as usize
1960 })
1961 .sum();
1962 if total_autos > 0 {
1963 let children_total = cursor.y - start_y;
1964 let total_slack = (inner_h - children_total).max(0.0);
1965 if total_slack > 0.0 {
1966 let per_auto = total_slack / total_autos as f64;
1967 let mut cumulative_shift = 0.0_f64;
1968 for (i, cs) in auto_styles.iter().enumerate() {
1969 let (start, end) = child_ranges[i];
1970 let mt_auto = cs.margin.top.is_auto();
1971 let mb_auto = cs.margin.bottom.is_auto();
1972 let this_child_shift =
1976 cumulative_shift + if mt_auto { per_auto } else { 0.0 };
1977 if this_child_shift > 0.001 {
1978 for j in start..end {
1979 offset_element_y(
1980 &mut cursor.elements[j],
1981 this_child_shift,
1982 );
1983 }
1984 }
1985 cumulative_shift =
1988 this_child_shift + if mb_auto { per_auto } else { 0.0 };
1989 }
1990 cursor.y += cumulative_shift;
1991 }
1992 }
1993 }
1994 }
1995
1996 let needs_justify =
1998 !matches!(justify, JustifyContent::FlexStart) && pages.len() == initial_pages;
1999 if needs_justify {
2000 let justify_inner_h = container_inner_h.or_else(|| {
2002 parent_style.and_then(|ps| match ps.height {
2003 SizeConstraint::Fixed(h) => {
2004 Some(h - ps.padding.vertical() - ps.border_width.vertical())
2005 }
2006 SizeConstraint::Auto => None,
2007 })
2008 });
2009 if let Some(inner_h) = justify_inner_h {
2010 let children_total = cursor.y - start_y;
2011 let slack = inner_h - children_total;
2012 if slack > 0.0 {
2013 let n = child_ranges.len();
2014 let offsets: Vec<f64> = match justify {
2015 JustifyContent::FlexEnd => vec![slack; n],
2016 JustifyContent::Center => vec![slack / 2.0; n],
2017 JustifyContent::SpaceBetween => {
2018 if n <= 1 {
2019 vec![0.0; n]
2020 } else {
2021 let per_gap = slack / (n - 1) as f64;
2022 (0..n).map(|i| i as f64 * per_gap).collect()
2023 }
2024 }
2025 JustifyContent::SpaceAround => {
2026 let space = slack / n as f64;
2027 (0..n).map(|i| space / 2.0 + i as f64 * space).collect()
2028 }
2029 JustifyContent::SpaceEvenly => {
2030 let space = slack / (n + 1) as f64;
2031 (0..n).map(|i| (i + 1) as f64 * space).collect()
2032 }
2033 JustifyContent::FlexStart => vec![0.0; n],
2034 };
2035 for (i, &(start, end)) in child_ranges.iter().enumerate() {
2036 let dy = offsets[i];
2037 if dy.abs() > 0.001 {
2038 for j in start..end {
2039 offset_element_y(&mut cursor.elements[j], dy);
2040 }
2041 }
2042 }
2043 cursor.y += *offsets.last().unwrap_or(&0.0);
2044 }
2045 }
2046 }
2047 }
2048
2049 FlexDirection::Row | FlexDirection::RowReverse => {
2050 let flow_owned: Vec<Node> = flow_children.into_iter().cloned().collect();
2051 self.layout_flex_row(
2052 &flow_owned,
2053 cursor,
2054 pages,
2055 content_x,
2056 available_width,
2057 parent_style,
2058 column_gap,
2059 row_gap,
2060 font_context,
2061 );
2062 }
2063 }
2064
2065 for abs_child in &abs_children {
2067 let abs_style = abs_child.style.resolve(parent_style, available_width);
2068
2069 let child_width = match abs_style.width {
2071 SizeConstraint::Fixed(w) => w,
2072 SizeConstraint::Auto => {
2073 if let (Some(l), Some(r)) = (abs_style.left, abs_style.right) {
2075 (available_width - l - r).max(0.0)
2076 } else {
2077 self.measure_intrinsic_width(abs_child, &abs_style, font_context)
2078 }
2079 }
2080 };
2081
2082 let child_height = match abs_style.height {
2083 SizeConstraint::Fixed(h) => h,
2084 SizeConstraint::Auto => {
2085 self.measure_node_height(abs_child, child_width, &abs_style, font_context)
2086 }
2087 };
2088
2089 let abs_x = if let Some(l) = abs_style.left {
2091 parent_box_x + l
2092 } else if let Some(r) = abs_style.right {
2093 parent_box_x + available_width - r - child_width
2094 } else {
2095 parent_box_x
2096 };
2097
2098 let parent_inner_height = parent_style
2100 .and_then(|ps| match ps.height {
2101 SizeConstraint::Fixed(h) => {
2102 Some(h - ps.padding.vertical() - ps.border_width.vertical())
2103 }
2104 SizeConstraint::Auto => None,
2105 })
2106 .unwrap_or(cursor.content_y + cursor.y - parent_box_y);
2107
2108 let abs_y = if let Some(t) = abs_style.top {
2109 parent_box_y + t
2110 } else if let Some(b) = abs_style.bottom {
2111 parent_box_y + parent_inner_height - b - child_height
2112 } else {
2113 parent_box_y
2114 };
2115
2116 let mut abs_cursor = PageCursor::new(&cursor.config);
2118 abs_cursor.y = 0.0;
2119 abs_cursor.content_x = abs_x;
2120 abs_cursor.content_y = abs_y;
2121
2122 self.layout_node(
2123 abs_child,
2124 &mut abs_cursor,
2125 &mut Vec::new(),
2126 abs_x,
2127 child_width,
2128 parent_style,
2129 font_context,
2130 None,
2131 None,
2132 );
2133
2134 cursor.elements.extend(abs_cursor.elements);
2136 }
2137 }
2138
2139 #[allow(clippy::too_many_arguments)]
2140 fn layout_flex_row(
2141 &self,
2142 children: &[Node],
2143 cursor: &mut PageCursor,
2144 pages: &mut Vec<LayoutPage>,
2145 content_x: f64,
2146 available_width: f64,
2147 parent_style: Option<&ResolvedStyle>,
2148 column_gap: f64,
2149 row_gap: f64,
2150 font_context: &FontContext,
2151 ) {
2152 if children.is_empty() {
2153 return;
2154 }
2155
2156 let flex_wrap = parent_style
2157 .map(|s| s.flex_wrap)
2158 .unwrap_or(FlexWrap::NoWrap);
2159
2160 let items: Vec<FlexItem> = children
2163 .iter()
2164 .map(|child| {
2165 let style = child.style.resolve(parent_style, available_width);
2166 let base_width = match style.flex_basis {
2167 SizeConstraint::Fixed(w) => w,
2168 SizeConstraint::Auto => match style.width {
2169 SizeConstraint::Fixed(w) => w,
2170 SizeConstraint::Auto => {
2171 self.measure_intrinsic_width(child, &style, font_context)
2172 }
2173 },
2174 };
2175 let min_content_width = self.measure_min_content_width(child, &style, font_context);
2176 FlexItem {
2177 node: child,
2178 style,
2179 base_width,
2180 min_content_width,
2181 }
2182 })
2183 .collect();
2184
2185 let base_widths: Vec<f64> = items.iter().map(|i| i.base_width).collect();
2187 let lines = match flex_wrap {
2188 FlexWrap::NoWrap => {
2189 vec![flex::WrapLine {
2190 start: 0,
2191 end: items.len(),
2192 }]
2193 }
2194 FlexWrap::Wrap => flex::partition_into_lines(&base_widths, column_gap, available_width),
2195 FlexWrap::WrapReverse => {
2196 let mut l = flex::partition_into_lines(&base_widths, column_gap, available_width);
2197 l.reverse();
2198 l
2199 }
2200 };
2201
2202 if lines.is_empty() {
2203 return;
2204 }
2205
2206 let justify = parent_style.map(|s| s.justify_content).unwrap_or_default();
2208
2209 let mut final_widths: Vec<f64> = items.iter().map(|i| i.base_width).collect();
2211
2212 let initial_pages_count = pages.len();
2213 let flex_start_y = cursor.y;
2214 let mut line_infos: Vec<(usize, usize, f64)> = Vec::new();
2215
2216 for (line_idx, line) in lines.iter().enumerate() {
2217 let line_items = &items[line.start..line.end];
2218 let line_count = line.end - line.start;
2219 let line_gap = column_gap * (line_count as f64 - 1.0).max(0.0);
2220 let distributable = available_width - line_gap;
2221
2222 let total_base: f64 = line_items.iter().map(|i| i.base_width).sum();
2224 let remaining = distributable - total_base;
2225
2226 if remaining > 0.0 {
2227 let total_grow: f64 = line_items.iter().map(|i| i.style.flex_grow).sum();
2228 if total_grow > 0.0 {
2229 for (j, item) in line_items.iter().enumerate() {
2230 final_widths[line.start + j] =
2231 item.base_width + remaining * (item.style.flex_grow / total_grow);
2232 }
2233 }
2234 } else if remaining < 0.0 {
2235 let total_shrink: f64 = line_items
2236 .iter()
2237 .map(|i| i.style.flex_shrink * i.base_width)
2238 .sum();
2239 if total_shrink > 0.0 {
2240 for (j, item) in line_items.iter().enumerate() {
2241 let factor = (item.style.flex_shrink * item.base_width) / total_shrink;
2242 let w = item.base_width + remaining * factor;
2243 let floor = item.style.min_width.max(item.min_content_width);
2244 final_widths[line.start + j] = w.max(floor);
2245 }
2246 }
2247 }
2248
2249 let line_height: f64 = line_items
2251 .iter()
2252 .enumerate()
2253 .map(|(j, item)| {
2254 let fw = final_widths[line.start + j];
2255 self.measure_node_height(item.node, fw, &item.style, font_context)
2256 + item.style.margin.vertical()
2257 })
2258 .fold(0.0f64, f64::max);
2259
2260 if line_height > cursor.remaining_height() {
2262 pages.push(cursor.finalize());
2263 *cursor = cursor.new_page();
2264 }
2265
2266 if line_idx > 0 {
2268 cursor.y += row_gap;
2269 }
2270
2271 let row_start_y = cursor.y;
2272
2273 let actual_total: f64 = (line.start..line.end).map(|i| final_widths[i]).sum();
2275 let slack = available_width - actual_total - line_gap;
2276
2277 let (start_offset, between_extra) = match justify {
2278 JustifyContent::FlexStart => (0.0, 0.0),
2279 JustifyContent::FlexEnd => (slack, 0.0),
2280 JustifyContent::Center => (slack / 2.0, 0.0),
2281 JustifyContent::SpaceBetween => {
2282 if line_count > 1 {
2283 (0.0, slack / (line_count as f64 - 1.0))
2284 } else {
2285 (0.0, 0.0)
2286 }
2287 }
2288 JustifyContent::SpaceAround => {
2289 let s = slack / line_count as f64;
2290 (s / 2.0, s)
2291 }
2292 JustifyContent::SpaceEvenly => {
2293 let s = slack / (line_count as f64 + 1.0);
2294 (s, s)
2295 }
2296 };
2297
2298 let line_elem_start = cursor.elements.len();
2299 let mut x = content_x + start_offset;
2300
2301 for (j, item) in line_items.iter().enumerate() {
2302 if j > 0 {
2303 x += column_gap + between_extra;
2304 }
2305
2306 let fw = final_widths[line.start + j];
2307
2308 let align = item
2309 .style
2310 .align_self
2311 .unwrap_or(parent_style.map(|s| s.align_items).unwrap_or_default());
2312
2313 let item_height =
2314 self.measure_node_height(item.node, fw, &item.style, font_context);
2315
2316 let has_auto_v = item.style.margin.has_auto_vertical();
2318 let y_offset = if has_auto_v {
2319 let fixed_v = item.style.margin.vertical();
2320 let slack = (line_height - item_height - fixed_v).max(0.0);
2321 let auto_top = item.style.margin.top.is_auto();
2322 let auto_bottom = item.style.margin.bottom.is_auto();
2323 match (auto_top, auto_bottom) {
2324 (true, true) => slack / 2.0,
2325 (true, false) => slack,
2326 (false, true) => 0.0,
2327 (false, false) => 0.0,
2328 }
2329 } else {
2330 match align {
2331 AlignItems::FlexStart => 0.0,
2332 AlignItems::FlexEnd => {
2333 line_height - item_height - item.style.margin.vertical()
2334 }
2335 AlignItems::Center => {
2336 (line_height - item_height - item.style.margin.vertical()) / 2.0
2337 }
2338 AlignItems::Stretch => 0.0,
2339 AlignItems::Baseline => 0.0,
2340 }
2341 };
2342
2343 let cross_h = if matches!(align, AlignItems::Stretch)
2347 && matches!(item.style.height, SizeConstraint::Auto)
2348 && !has_auto_v
2349 {
2350 let stretch_h = line_height - item.style.margin.vertical();
2351 if stretch_h > item_height {
2352 Some(stretch_h)
2353 } else {
2354 None
2355 }
2356 } else {
2357 None
2358 };
2359
2360 let saved_y = cursor.y;
2361 cursor.y = row_start_y + y_offset;
2362
2363 self.layout_node(
2364 item.node,
2365 cursor,
2366 pages,
2367 x,
2368 available_width,
2369 parent_style,
2370 font_context,
2371 cross_h,
2372 Some(fw),
2373 );
2374
2375 cursor.y = saved_y;
2376 x += fw;
2377 }
2378
2379 cursor.y = row_start_y + line_height;
2380 line_infos.push((line_elem_start, cursor.elements.len(), line_height));
2381 }
2382
2383 if pages.len() == initial_pages_count && !line_infos.is_empty() {
2385 let align_content = parent_style.map(|s| s.align_content).unwrap_or_default();
2386 if !matches!(align_content, AlignContent::FlexStart)
2387 && !matches!(flex_wrap, FlexWrap::NoWrap)
2388 {
2389 if let Some(parent) = parent_style {
2390 if let SizeConstraint::Fixed(container_h) = parent.height {
2391 let inner_h = container_h
2392 - parent.padding.vertical()
2393 - parent.border_width.vertical();
2394 let total_used = cursor.y - flex_start_y;
2395 let slack = inner_h - total_used;
2396 if slack > 0.0 {
2397 let n = line_infos.len();
2398 let offsets: Vec<f64> = match align_content {
2399 AlignContent::FlexEnd => vec![slack; n],
2400 AlignContent::Center => vec![slack / 2.0; n],
2401 AlignContent::SpaceBetween => {
2402 if n <= 1 {
2403 vec![0.0; n]
2404 } else {
2405 let per_gap = slack / (n - 1) as f64;
2406 (0..n).map(|i| i as f64 * per_gap).collect()
2407 }
2408 }
2409 AlignContent::SpaceAround => {
2410 let space = slack / n as f64;
2411 (0..n).map(|i| space / 2.0 + i as f64 * space).collect()
2412 }
2413 AlignContent::SpaceEvenly => {
2414 let space = slack / (n + 1) as f64;
2415 (0..n).map(|i| (i + 1) as f64 * space).collect()
2416 }
2417 AlignContent::Stretch => {
2418 let extra = slack / n as f64;
2419 (0..n).map(|i| i as f64 * extra).collect()
2420 }
2421 AlignContent::FlexStart => vec![0.0; n],
2422 };
2423 for (i, &(start, end, _)) in line_infos.iter().enumerate() {
2424 let dy = offsets[i];
2425 if dy.abs() > 0.001 {
2426 for j in start..end {
2427 offset_element_y(&mut cursor.elements[j], dy);
2428 }
2429 }
2430 }
2431 cursor.y += *offsets.last().unwrap_or(&0.0);
2432 }
2433 }
2434 }
2435 }
2436 }
2437 }
2438
2439 #[allow(clippy::too_many_arguments)]
2440 fn layout_table(
2441 &self,
2442 node: &Node,
2443 style: &ResolvedStyle,
2444 column_defs: &[ColumnDef],
2445 cursor: &mut PageCursor,
2446 pages: &mut Vec<LayoutPage>,
2447 x: f64,
2448 available_width: f64,
2449 font_context: &FontContext,
2450 ) {
2451 let padding = &style.padding;
2452 let margin = &style.margin.to_edges();
2453 let border = &style.border_width;
2454
2455 let table_x = x + margin.left;
2456 let table_width = match style.width {
2457 SizeConstraint::Fixed(w) => w,
2458 SizeConstraint::Auto => available_width - margin.horizontal(),
2459 };
2460 let inner_width = table_width - padding.horizontal() - border.horizontal();
2461
2462 let col_widths = self.resolve_column_widths(column_defs, inner_width, &node.children);
2463
2464 let mut header_rows: Vec<&Node> = Vec::new();
2465 let mut body_rows: Vec<&Node> = Vec::new();
2466
2467 for child in &node.children {
2468 match &child.kind {
2469 NodeKind::TableRow { is_header: true } => header_rows.push(child),
2470 _ => body_rows.push(child),
2471 }
2472 }
2473
2474 cursor.y += margin.top + padding.top + border.top;
2475
2476 let cell_x_start = table_x + padding.left + border.left;
2477
2478 if !header_rows.is_empty() {
2489 let total_header_h: f64 = header_rows
2490 .iter()
2491 .map(|r| self.measure_table_row_height(r, &col_widths, style, font_context))
2492 .sum();
2493 if total_header_h > cursor.remaining_height() {
2494 pages.push(cursor.finalize());
2495 *cursor = cursor.new_page();
2496 cursor.y += padding.top + border.top;
2497 }
2498 }
2499
2500 for header_row in &header_rows {
2501 self.layout_table_row(
2502 header_row,
2503 &col_widths,
2504 style,
2505 cursor,
2506 cell_x_start,
2507 font_context,
2508 pages,
2509 );
2510 }
2511
2512 for body_row in &body_rows {
2513 let row_height =
2514 self.measure_table_row_height(body_row, &col_widths, style, font_context);
2515
2516 if row_height > cursor.remaining_height() {
2517 pages.push(cursor.finalize());
2518 *cursor = cursor.new_page();
2519
2520 cursor.y += padding.top + border.top;
2521 for header_row in &header_rows {
2522 self.layout_table_row(
2523 header_row,
2524 &col_widths,
2525 style,
2526 cursor,
2527 cell_x_start,
2528 font_context,
2529 pages,
2530 );
2531 }
2532 }
2533
2534 self.layout_table_row(
2535 body_row,
2536 &col_widths,
2537 style,
2538 cursor,
2539 cell_x_start,
2540 font_context,
2541 pages,
2542 );
2543 }
2544
2545 cursor.y += padding.bottom + border.bottom + margin.bottom;
2546 }
2547
2548 #[allow(clippy::too_many_arguments)]
2549 fn layout_table_row(
2550 &self,
2551 row: &Node,
2552 col_widths: &[f64],
2553 parent_style: &ResolvedStyle,
2554 cursor: &mut PageCursor,
2555 start_x: f64,
2556 font_context: &FontContext,
2557 pages: &mut Vec<LayoutPage>,
2558 ) {
2559 let row_style = row
2560 .style
2561 .resolve(Some(parent_style), col_widths.iter().sum());
2562
2563 let row_height = self.measure_table_row_height(row, col_widths, parent_style, font_context);
2564 let row_y = cursor.content_y + cursor.y;
2565 let total_width: f64 = col_widths.iter().sum();
2566
2567 let is_header = matches!(row.kind, NodeKind::TableRow { is_header: true });
2568
2569 let row_snapshot = cursor.elements.len();
2571
2572 let mut all_overflow_pages: Vec<LayoutPage> = Vec::new();
2573 let mut cell_x = start_x;
2574 for (i, cell) in row.children.iter().enumerate() {
2575 let col_width = col_widths.get(i).copied().unwrap_or(0.0);
2576
2577 let cell_style = cell.style.resolve(Some(&row_style), col_width);
2578
2579 let cell_snapshot = cursor.elements.len();
2581
2582 let inner_width =
2583 col_width - cell_style.padding.horizontal() - cell_style.border_width.horizontal();
2584
2585 let content_x = cell_x + cell_style.padding.left + cell_style.border_width.left;
2586 let saved_y = cursor.y;
2587 cursor.y += cell_style.padding.top + cell_style.border_width.top;
2588
2589 let cursor_before_cell = cursor.clone();
2591 let mut cell_pages: Vec<LayoutPage> = Vec::new();
2592 for child in &cell.children {
2593 self.layout_node(
2594 child,
2595 cursor,
2596 &mut cell_pages,
2597 content_x,
2598 inner_width,
2599 Some(&cell_style),
2600 font_context,
2601 None,
2602 None,
2603 );
2604 }
2605
2606 if !cell_pages.is_empty() {
2608 let post_break_elements = std::mem::take(&mut cursor.elements);
2609 if let Some(last_page) = cell_pages.last_mut() {
2610 last_page.elements.extend(post_break_elements);
2611 }
2612 if !is_header {
2618 all_overflow_pages.extend(cell_pages);
2619 }
2620 *cursor = cursor_before_cell;
2621 }
2622
2623 cursor.y = saved_y;
2624
2625 let cell_children: Vec<LayoutElement> =
2627 cursor.elements.drain(cell_snapshot..).collect();
2628
2629 cursor.elements.push(LayoutElement {
2631 x: cell_x,
2632 y: row_y,
2633 width: col_width,
2634 height: row_height,
2635 draw: if cell_style.background_color.is_some()
2636 || cell_style.border_width.horizontal() > 0.0
2637 || cell_style.border_width.vertical() > 0.0
2638 {
2639 DrawCommand::Rect {
2640 background: cell_style.background_color,
2641 border_width: cell_style.border_width,
2642 border_color: cell_style.border_color,
2643 border_radius: cell_style.border_radius,
2644 opacity: 1.0,
2645 box_shadow: cell_style.box_shadow.map(Box::new),
2646 background_gradient: cell_style.background.clone().map(Box::new),
2647 }
2648 } else {
2649 DrawCommand::None
2650 },
2651 children: cell_children,
2652 node_type: Some("TableCell".to_string()),
2653 resolved_style: Some(cell_style.clone()),
2654 source_location: cell.source_location.clone(),
2655 href: None,
2656 bookmark: cell.bookmark.clone(),
2657 alt: None,
2658 is_header_row: is_header,
2659 overflow: Overflow::default(),
2660 opacity: 1.0,
2661 });
2662
2663 cell_x += col_width;
2664 }
2665
2666 let row_children: Vec<LayoutElement> = cursor.elements.drain(row_snapshot..).collect();
2668 cursor.elements.push(LayoutElement {
2669 x: start_x,
2670 y: row_y,
2671 width: total_width,
2672 height: row_height,
2673 draw: if let Some(bg) = row_style.background_color {
2674 DrawCommand::Rect {
2675 background: Some(bg),
2676 border_width: Edges::default(),
2677 border_color: EdgeValues::uniform(Color::BLACK),
2678 border_radius: CornerValues::uniform(0.0),
2679 opacity: 1.0,
2680 box_shadow: row_style.box_shadow.map(Box::new),
2681 background_gradient: row_style.background.clone().map(Box::new),
2682 }
2683 } else {
2684 DrawCommand::None
2685 },
2686 children: row_children,
2687 node_type: Some("TableRow".to_string()),
2688 resolved_style: Some(row_style.clone()),
2689 source_location: row.source_location.clone(),
2690 href: None,
2691 bookmark: row.bookmark.clone(),
2692 alt: None,
2693 is_header_row: is_header,
2694 overflow: row_style.overflow,
2695 opacity: row_style.opacity,
2696 });
2697
2698 pages.extend(all_overflow_pages);
2700
2701 cursor.y += row_height;
2702 }
2703
2704 #[allow(clippy::too_many_arguments)]
2705 fn layout_text(
2706 &self,
2707 content: &str,
2708 href: Option<&str>,
2709 runs: &[TextRun],
2710 style: &ResolvedStyle,
2711 cursor: &mut PageCursor,
2712 pages: &mut Vec<LayoutPage>,
2713 x: f64,
2714 available_width: f64,
2715 font_context: &FontContext,
2716 source_location: Option<&SourceLocation>,
2717 bookmark: Option<&str>,
2718 ) {
2719 let margin = &style.margin.to_edges();
2720 let text_x = x + margin.left;
2721 let text_width = match style.width {
2728 SizeConstraint::Fixed(w) => (w - margin.horizontal()).max(0.0),
2729 SizeConstraint::Auto => available_width - margin.horizontal(),
2730 };
2731
2732 cursor.y += margin.top;
2733
2734 if !runs.is_empty() {
2736 self.layout_text_runs(
2737 runs,
2738 href,
2739 style,
2740 cursor,
2741 pages,
2742 text_x,
2743 text_width,
2744 font_context,
2745 source_location,
2746 bookmark,
2747 );
2748 cursor.y += margin.bottom;
2749 return;
2750 }
2751
2752 let content = substitute_page_placeholders(content);
2753 let transformed = apply_text_transform(&content, style.text_transform);
2754 let justify = matches!(style.text_align, TextAlign::Justify);
2755 let lines = match style.line_breaking {
2756 LineBreaking::Optimal => self.text_layout.break_into_lines_optimal(
2757 font_context,
2758 &transformed,
2759 text_width,
2760 style.font_size,
2761 &style.font_family,
2762 style.font_weight,
2763 style.font_style,
2764 style.letter_spacing,
2765 style.hyphens,
2766 style.lang.as_deref(),
2767 justify,
2768 ),
2769 LineBreaking::Greedy => self.text_layout.break_into_lines(
2770 font_context,
2771 &transformed,
2772 text_width,
2773 style.font_size,
2774 &style.font_family,
2775 style.font_weight,
2776 style.font_style,
2777 style.letter_spacing,
2778 style.hyphens,
2779 style.lang.as_deref(),
2780 ),
2781 };
2782
2783 let lines = match style.text_overflow {
2785 TextOverflow::Ellipsis => self.text_layout.truncate_with_ellipsis(
2786 font_context,
2787 lines,
2788 text_width,
2789 style.font_size,
2790 &style.font_family,
2791 style.font_weight,
2792 style.font_style,
2793 style.letter_spacing,
2794 ),
2795 TextOverflow::Clip => self.text_layout.truncate_clip(
2796 font_context,
2797 lines,
2798 text_width,
2799 style.font_size,
2800 &style.font_family,
2801 style.font_weight,
2802 style.font_style,
2803 style.letter_spacing,
2804 ),
2805 TextOverflow::Wrap => lines,
2806 };
2807
2808 let line_height = style.font_size * style.line_height;
2809
2810 let line_heights: Vec<f64> = vec![line_height; lines.len()];
2812 let decision = page_break::decide_break(
2813 cursor.remaining_height(),
2814 &line_heights,
2815 true,
2816 style.min_orphan_lines as usize,
2817 style.min_widow_lines as usize,
2818 );
2819
2820 let mut snapshot = cursor.elements.len();
2822 let mut container_start_y = cursor.content_y + cursor.y;
2823 let mut is_first_element = true;
2824
2825 if matches!(decision, page_break::BreakDecision::MoveToNextPage) {
2827 pages.push(cursor.finalize());
2828 *cursor = cursor.new_page();
2829 snapshot = cursor.elements.len();
2830 container_start_y = cursor.content_y + cursor.y;
2831 }
2832
2833 let forced_break_at = match decision {
2835 page_break::BreakDecision::Split {
2836 items_on_current_page,
2837 } => Some(items_on_current_page),
2838 _ => None,
2839 };
2840 let mut first_break_done = false;
2841
2842 for (line_idx, line) in lines.iter().enumerate() {
2843 let needs_break = if let Some(break_at) = forced_break_at {
2845 if !first_break_done && line_idx == break_at {
2846 true
2847 } else {
2848 line_height > cursor.remaining_height()
2849 }
2850 } else {
2851 line_height > cursor.remaining_height()
2852 };
2853
2854 if needs_break {
2855 first_break_done = true;
2856 let line_elements: Vec<LayoutElement> = cursor.elements.drain(snapshot..).collect();
2858 if !line_elements.is_empty() {
2859 let container_height = cursor.content_y + cursor.y - container_start_y;
2860 cursor.elements.push(LayoutElement {
2861 x: text_x,
2862 y: container_start_y,
2863 width: text_width,
2864 height: container_height,
2865 draw: DrawCommand::None,
2866 children: line_elements,
2867 node_type: Some("Text".to_string()),
2868 resolved_style: Some(style.clone()),
2869 source_location: source_location.cloned(),
2870 href: href.map(|s| s.to_string()),
2871 bookmark: if is_first_element {
2872 bookmark.map(|s| s.to_string())
2873 } else {
2874 None
2875 },
2876 alt: None,
2877 is_header_row: false,
2878 overflow: Overflow::default(),
2879 opacity: 1.0,
2880 });
2881 is_first_element = false;
2882 }
2883
2884 pages.push(cursor.finalize());
2885 *cursor = cursor.new_page();
2886
2887 snapshot = cursor.elements.len();
2889 container_start_y = cursor.content_y + cursor.y;
2890 }
2891
2892 let glyphs = self.build_positioned_glyphs_single_style(line, style, href, font_context);
2893
2894 let rendered_width = if glyphs.is_empty() {
2898 line.width
2899 } else {
2900 let last = &glyphs[glyphs.len() - 1];
2901 (last.x_offset + last.x_advance).max(line.width * 0.5)
2902 };
2903
2904 let line_x = match style.text_align {
2905 TextAlign::Left => text_x,
2906 TextAlign::Right => text_x + text_width - rendered_width,
2907 TextAlign::Center => text_x + (text_width - rendered_width) / 2.0,
2908 TextAlign::Justify => text_x,
2909 };
2910
2911 let is_last_line = line_idx == lines.len() - 1;
2919 let user_ws = style.word_spacing;
2920 let (justified_width, word_spacing) =
2921 if matches!(style.text_align, TextAlign::Justify) && !is_last_line {
2922 let last_non_space = glyphs.iter().rposition(|g| g.char_value != ' ');
2923 let (natural_width, space_count) = if let Some(idx) = last_non_space {
2924 let w: f64 = glyphs[..=idx].iter().map(|g| g.x_advance).sum();
2925 let s = glyphs[..=idx]
2926 .iter()
2927 .filter(|g| g.char_value == ' ')
2928 .count();
2929 (w, s)
2930 } else {
2931 (0.0, 0)
2932 };
2933 let slack = text_width - natural_width;
2934 let ws = if space_count > 0 && slack.abs() > 0.01 {
2935 slack / space_count as f64
2936 } else {
2937 0.0
2938 };
2939 (text_width, user_ws + ws)
2940 } else {
2941 (rendered_width, user_ws)
2942 };
2943
2944 let text_line = TextLine {
2945 x: line_x,
2946 y: cursor.content_y + cursor.y + style.font_size,
2947 glyphs,
2948 width: justified_width,
2949 height: line_height,
2950 word_spacing,
2951 };
2952
2953 cursor.elements.push(LayoutElement {
2954 x: line_x,
2955 y: cursor.content_y + cursor.y,
2956 width: justified_width,
2957 height: line_height,
2958 draw: DrawCommand::Text {
2959 lines: vec![text_line],
2960 color: style.color,
2961 text_decoration: style.text_decoration,
2962 opacity: 1.0,
2963 },
2964 children: vec![],
2965 node_type: Some("TextLine".to_string()),
2966 resolved_style: Some(style.clone()),
2967 source_location: None,
2968 href: href.map(|s| s.to_string()),
2969 bookmark: None,
2970 alt: None,
2971 is_header_row: false,
2972 overflow: Overflow::default(),
2973 opacity: 1.0,
2974 });
2975
2976 cursor.y += line_height;
2977 }
2978
2979 let line_elements: Vec<LayoutElement> = cursor.elements.drain(snapshot..).collect();
2981 if !line_elements.is_empty() {
2982 let container_height = cursor.content_y + cursor.y - container_start_y;
2983 cursor.elements.push(LayoutElement {
2984 x: text_x,
2985 y: container_start_y,
2986 width: text_width,
2987 height: container_height,
2988 draw: DrawCommand::None,
2989 children: line_elements,
2990 node_type: Some("Text".to_string()),
2991 resolved_style: Some(style.clone()),
2992 source_location: source_location.cloned(),
2993 href: href.map(|s| s.to_string()),
2994 bookmark: if is_first_element {
2995 bookmark.map(|s| s.to_string())
2996 } else {
2997 None
2998 },
2999 alt: None,
3000 is_header_row: false,
3001 overflow: Overflow::default(),
3002 opacity: 1.0,
3003 });
3004 }
3005
3006 cursor.y += margin.bottom;
3007 }
3008
3009 #[allow(clippy::too_many_arguments)]
3011 fn layout_text_runs(
3012 &self,
3013 runs: &[TextRun],
3014 parent_href: Option<&str>,
3015 style: &ResolvedStyle,
3016 cursor: &mut PageCursor,
3017 pages: &mut Vec<LayoutPage>,
3018 text_x: f64,
3019 text_width: f64,
3020 font_context: &FontContext,
3021 source_location: Option<&SourceLocation>,
3022 bookmark: Option<&str>,
3023 ) {
3024 let mut styled_chars: Vec<StyledChar> = Vec::new();
3026 for run in runs {
3027 let run_style = run.style.resolve(Some(style), text_width);
3028 let run_href = run.href.as_deref().or(parent_href);
3029 let transform = run_style.text_transform;
3030 let run_content = substitute_page_placeholders(&run.content);
3031 let mut prev_is_whitespace = true;
3032 for ch in run_content.chars() {
3033 let transformed_ch = apply_char_transform(ch, transform, prev_is_whitespace);
3034 prev_is_whitespace = ch.is_whitespace();
3035 styled_chars.push(StyledChar {
3036 ch: transformed_ch,
3037 font_family: run_style.font_family.clone(),
3038 font_size: run_style.font_size,
3039 font_weight: run_style.font_weight,
3040 font_style: run_style.font_style,
3041 color: run_style.color,
3042 href: run_href.map(|s| s.to_string()),
3043 text_decoration: run_style.text_decoration,
3044 letter_spacing: run_style.letter_spacing,
3045 });
3046 }
3047 }
3048
3049 let justify = matches!(style.text_align, TextAlign::Justify);
3051 let broken_lines = match style.line_breaking {
3052 LineBreaking::Optimal => self.text_layout.break_runs_into_lines_optimal(
3053 font_context,
3054 &styled_chars,
3055 text_width,
3056 style.hyphens,
3057 style.lang.as_deref(),
3058 justify,
3059 ),
3060 LineBreaking::Greedy => self.text_layout.break_runs_into_lines(
3061 font_context,
3062 &styled_chars,
3063 text_width,
3064 style.hyphens,
3065 style.lang.as_deref(),
3066 ),
3067 };
3068
3069 let broken_lines = match style.text_overflow {
3071 TextOverflow::Ellipsis => {
3072 self.text_layout
3073 .truncate_runs_with_ellipsis(font_context, broken_lines, text_width)
3074 }
3075 TextOverflow::Clip => {
3076 self.text_layout
3077 .truncate_runs_clip(font_context, broken_lines, text_width)
3078 }
3079 TextOverflow::Wrap => broken_lines,
3080 };
3081
3082 let line_height = style.font_size * style.line_height;
3083
3084 let line_heights: Vec<f64> = vec![line_height; broken_lines.len()];
3086 let decision = page_break::decide_break(
3087 cursor.remaining_height(),
3088 &line_heights,
3089 true,
3090 style.min_orphan_lines as usize,
3091 style.min_widow_lines as usize,
3092 );
3093
3094 let mut snapshot = cursor.elements.len();
3095 let mut container_start_y = cursor.content_y + cursor.y;
3096 let mut is_first_element = true;
3097
3098 if matches!(decision, page_break::BreakDecision::MoveToNextPage) {
3099 pages.push(cursor.finalize());
3100 *cursor = cursor.new_page();
3101 snapshot = cursor.elements.len();
3102 container_start_y = cursor.content_y + cursor.y;
3103 }
3104
3105 let forced_break_at = match decision {
3106 page_break::BreakDecision::Split {
3107 items_on_current_page,
3108 } => Some(items_on_current_page),
3109 _ => None,
3110 };
3111 let mut first_break_done = false;
3112
3113 for (line_idx, run_line) in broken_lines.iter().enumerate() {
3114 let needs_break = if let Some(break_at) = forced_break_at {
3115 if !first_break_done && line_idx == break_at {
3116 true
3117 } else {
3118 line_height > cursor.remaining_height()
3119 }
3120 } else {
3121 line_height > cursor.remaining_height()
3122 };
3123
3124 if needs_break {
3125 first_break_done = true;
3126 let line_elements: Vec<LayoutElement> = cursor.elements.drain(snapshot..).collect();
3127 if !line_elements.is_empty() {
3128 let container_height = cursor.content_y + cursor.y - container_start_y;
3129 cursor.elements.push(LayoutElement {
3130 x: text_x,
3131 y: container_start_y,
3132 width: text_width,
3133 height: container_height,
3134 draw: DrawCommand::None,
3135 children: line_elements,
3136 node_type: Some("Text".to_string()),
3137 resolved_style: Some(style.clone()),
3138 source_location: source_location.cloned(),
3139 href: parent_href.map(|s| s.to_string()),
3140 bookmark: if is_first_element {
3141 bookmark.map(|s| s.to_string())
3142 } else {
3143 None
3144 },
3145 alt: None,
3146 is_header_row: false,
3147 overflow: Overflow::default(),
3148 opacity: 1.0,
3149 });
3150 is_first_element = false;
3151 }
3152
3153 pages.push(cursor.finalize());
3154 *cursor = cursor.new_page();
3155
3156 snapshot = cursor.elements.len();
3157 container_start_y = cursor.content_y + cursor.y;
3158 }
3159
3160 let line_x = match style.text_align {
3161 TextAlign::Left => text_x,
3162 TextAlign::Right => text_x + text_width - run_line.width,
3163 TextAlign::Center => text_x + (text_width - run_line.width) / 2.0,
3164 TextAlign::Justify => text_x,
3165 };
3166
3167 let glyphs = self.build_positioned_glyphs_runs(run_line, font_context, style.direction);
3168
3169 let is_last_line = line_idx == broken_lines.len() - 1;
3176 let user_ws = style.word_spacing;
3177 let (justified_width, word_spacing) =
3178 if matches!(style.text_align, TextAlign::Justify) && !is_last_line {
3179 let last_non_space = glyphs.iter().rposition(|g| g.char_value != ' ');
3180 let (natural_width, space_count) = if let Some(idx) = last_non_space {
3181 let w: f64 = glyphs[..=idx].iter().map(|g| g.x_advance).sum();
3182 let s = glyphs[..=idx]
3183 .iter()
3184 .filter(|g| g.char_value == ' ')
3185 .count();
3186 (w, s)
3187 } else {
3188 (0.0, 0)
3189 };
3190 let slack = text_width - natural_width;
3191 let ws = if space_count > 0 && slack.abs() > 0.01 {
3192 slack / space_count as f64
3193 } else {
3194 0.0
3195 };
3196 (text_width, user_ws + ws)
3197 } else {
3198 (run_line.width, user_ws)
3199 };
3200
3201 let text_line = TextLine {
3202 x: line_x,
3203 y: cursor.content_y + cursor.y + style.font_size,
3204 glyphs,
3205 width: justified_width,
3206 height: line_height,
3207 word_spacing,
3208 };
3209
3210 let text_dec = run_line
3212 .chars
3213 .iter()
3214 .find(|sc| !matches!(sc.text_decoration, TextDecoration::None))
3215 .map(|sc| sc.text_decoration)
3216 .unwrap_or(style.text_decoration);
3217
3218 cursor.elements.push(LayoutElement {
3219 x: line_x,
3220 y: cursor.content_y + cursor.y,
3221 width: justified_width,
3222 height: line_height,
3223 draw: DrawCommand::Text {
3224 lines: vec![text_line],
3225 color: style.color,
3226 text_decoration: text_dec,
3227 opacity: 1.0,
3228 },
3229 children: vec![],
3230 node_type: Some("TextLine".to_string()),
3231 resolved_style: Some(style.clone()),
3232 source_location: None,
3233 href: parent_href.map(|s| s.to_string()),
3234 bookmark: None,
3235 alt: None,
3236 is_header_row: false,
3237 overflow: Overflow::default(),
3238 opacity: 1.0,
3239 });
3240
3241 cursor.y += line_height;
3242 }
3243
3244 let line_elements: Vec<LayoutElement> = cursor.elements.drain(snapshot..).collect();
3245 if !line_elements.is_empty() {
3246 let container_height = cursor.content_y + cursor.y - container_start_y;
3247 cursor.elements.push(LayoutElement {
3248 x: text_x,
3249 y: container_start_y,
3250 width: text_width,
3251 height: container_height,
3252 draw: DrawCommand::None,
3253 children: line_elements,
3254 node_type: Some("Text".to_string()),
3255 resolved_style: Some(style.clone()),
3256 source_location: source_location.cloned(),
3257 href: parent_href.map(|s| s.to_string()),
3258 bookmark: if is_first_element {
3259 bookmark.map(|s| s.to_string())
3260 } else {
3261 None
3262 },
3263 alt: None,
3264 is_header_row: false,
3265 overflow: Overflow::default(),
3266 opacity: 1.0,
3267 });
3268 }
3269 }
3270
3271 fn build_positioned_glyphs_single_style(
3275 &self,
3276 line: &BrokenLine,
3277 style: &ResolvedStyle,
3278 href: Option<&str>,
3279 font_context: &FontContext,
3280 ) -> Vec<PositionedGlyph> {
3281 let italic = matches!(style.font_style, FontStyle::Italic | FontStyle::Oblique);
3282 let line_text: String = line.chars.iter().collect();
3283 let direction = style.direction;
3284 let has_bidi = !bidi::is_pure_ltr(&line_text, direction);
3286
3287 let font_runs = crate::font::fallback::segment_by_font(
3290 &line.chars,
3291 &style.font_family,
3292 style.font_weight,
3293 italic,
3294 font_context.registry(),
3295 );
3296 let needs_per_char_fallback = font_runs.len() > 1
3297 || (font_runs.len() == 1 && font_runs[0].family != style.font_family);
3298
3299 if needs_per_char_fallback {
3301 let bidi_runs = if has_bidi {
3302 bidi::analyze_bidi(&line_text, direction)
3303 } else {
3304 vec![crate::text::bidi::BidiRun {
3305 char_start: 0,
3306 char_end: line.chars.len(),
3307 level: unicode_bidi::Level::ltr(),
3308 is_rtl: false,
3309 }]
3310 };
3311
3312 let mut all_glyphs = Vec::new();
3313 let mut bidi_levels = Vec::new();
3314 let mut x = 0.0_f64;
3315
3316 for bidi_run in &bidi_runs {
3318 for font_run in &font_runs {
3320 let start = font_run.start.max(bidi_run.char_start);
3322 let end = font_run.end.min(bidi_run.char_end);
3323 if start >= end {
3324 continue;
3325 }
3326
3327 let sub_chars: Vec<char> = line.chars[start..end].to_vec();
3328 let sub_text: String = sub_chars.iter().collect();
3329 let resolved_family = &font_run.family;
3330
3331 if let Some(font_data) =
3332 font_context.font_data(resolved_family, style.font_weight, italic)
3333 {
3334 if let Some(shaped) = shaping::shape_text_with_direction(
3335 &sub_text,
3336 font_data,
3337 bidi_run.is_rtl,
3338 ) {
3339 let units_per_em = font_context.units_per_em(
3340 resolved_family,
3341 style.font_weight,
3342 italic,
3343 );
3344 let scale = style.font_size / units_per_em as f64;
3345
3346 for sg in &shaped {
3347 let cluster = sg.cluster as usize;
3348 let char_value = sub_chars.get(cluster).copied().unwrap_or(' ');
3349
3350 let cluster_text = if shaped.len() < sub_chars.len() {
3351 let cluster_end =
3352 self.find_cluster_end(&shaped, sg, sub_chars.len());
3353 if cluster_end > cluster + 1 {
3354 Some(
3355 sub_chars[cluster..cluster_end]
3356 .iter()
3357 .collect::<String>(),
3358 )
3359 } else {
3360 None
3361 }
3362 } else {
3363 None
3364 };
3365
3366 let glyph_x = x + sg.x_offset as f64 * scale;
3367 let glyph_y = sg.y_offset as f64 * scale;
3368 let advance = sg.x_advance as f64 * scale + style.letter_spacing;
3369
3370 all_glyphs.push(PositionedGlyph {
3371 glyph_id: sg.glyph_id,
3372 x_offset: glyph_x,
3373 y_offset: glyph_y,
3374 x_advance: advance,
3375 font_size: style.font_size,
3376 font_family: resolved_family.clone(),
3377 font_weight: style.font_weight,
3378 font_style: style.font_style,
3379 char_value,
3380 color: Some(style.color),
3381 href: href.map(|s| s.to_string()),
3382 text_decoration: style.text_decoration,
3383 letter_spacing: style.letter_spacing,
3384 cluster_text,
3385 });
3386 bidi_levels.push(bidi_run.level);
3387 x += advance;
3388 }
3389 continue;
3390 }
3391 }
3392
3393 for i in start..end {
3395 let ch = line.chars[i];
3396 let glyph_x = x;
3397 let char_width = font_context.char_width(
3398 ch,
3399 resolved_family,
3400 style.font_weight,
3401 italic,
3402 style.font_size,
3403 );
3404 let advance = char_width + style.letter_spacing;
3405 all_glyphs.push(PositionedGlyph {
3406 glyph_id: ch as u16,
3407 x_offset: glyph_x,
3408 y_offset: 0.0,
3409 x_advance: advance,
3410 font_size: style.font_size,
3411 font_family: resolved_family.clone(),
3412 font_weight: style.font_weight,
3413 font_style: style.font_style,
3414 char_value: ch,
3415 color: Some(style.color),
3416 href: href.map(|s| s.to_string()),
3417 text_decoration: style.text_decoration,
3418 letter_spacing: style.letter_spacing,
3419 cluster_text: None,
3420 });
3421 bidi_levels.push(bidi_run.level);
3422 x += advance;
3423 }
3424 }
3425 }
3426
3427 if has_bidi && !all_glyphs.is_empty() {
3429 all_glyphs = bidi::reorder_line_glyphs(all_glyphs, &bidi_levels);
3430 bidi::reposition_after_reorder(&mut all_glyphs, 0.0);
3431 }
3432 return all_glyphs;
3433 }
3434
3435 if let Some(font_data) =
3438 font_context.font_data(&style.font_family, style.font_weight, italic)
3439 {
3440 if has_bidi {
3441 let bidi_runs = bidi::analyze_bidi(&line_text, direction);
3443 let units_per_em =
3444 font_context.units_per_em(&style.font_family, style.font_weight, italic);
3445 let scale = style.font_size / units_per_em as f64;
3446
3447 let mut all_glyphs = Vec::new();
3448 let mut bidi_levels = Vec::new();
3449 let mut x = 0.0_f64;
3450
3451 for run in &bidi_runs {
3452 let run_chars: Vec<char> = line.chars[run.char_start..run.char_end].to_vec();
3453 let run_text: String = run_chars.iter().collect();
3454
3455 if let Some(shaped) =
3456 shaping::shape_text_with_direction(&run_text, font_data, run.is_rtl)
3457 {
3458 for sg in &shaped {
3459 let cluster = sg.cluster as usize;
3460 let char_value = run_chars.get(cluster).copied().unwrap_or(' ');
3461
3462 let cluster_text = if shaped.len() < run_chars.len() {
3463 let cluster_end =
3464 self.find_cluster_end(&shaped, sg, run_chars.len());
3465 if cluster_end > cluster + 1 {
3466 Some(run_chars[cluster..cluster_end].iter().collect::<String>())
3467 } else {
3468 None
3469 }
3470 } else {
3471 None
3472 };
3473
3474 let glyph_x = x + sg.x_offset as f64 * scale;
3475 let glyph_y = sg.y_offset as f64 * scale;
3476 let advance = sg.x_advance as f64 * scale + style.letter_spacing;
3477
3478 all_glyphs.push(PositionedGlyph {
3479 glyph_id: sg.glyph_id,
3480 x_offset: glyph_x,
3481 y_offset: glyph_y,
3482 x_advance: advance,
3483 font_size: style.font_size,
3484 font_family: style.font_family.clone(),
3485 font_weight: style.font_weight,
3486 font_style: style.font_style,
3487 char_value,
3488 color: Some(style.color),
3489 href: href.map(|s| s.to_string()),
3490 text_decoration: style.text_decoration,
3491 letter_spacing: style.letter_spacing,
3492 cluster_text,
3493 });
3494 bidi_levels.push(run.level);
3495
3496 x += advance;
3497 }
3498 }
3499 }
3500
3501 let mut glyphs = bidi::reorder_line_glyphs(all_glyphs, &bidi_levels);
3503 bidi::reposition_after_reorder(&mut glyphs, 0.0);
3504 return glyphs;
3505 }
3506
3507 if let Some(shaped) = shaping::shape_text(&line_text, font_data) {
3509 let units_per_em =
3510 font_context.units_per_em(&style.font_family, style.font_weight, italic);
3511 let scale = style.font_size / units_per_em as f64;
3512
3513 return self.shaped_glyphs_to_positioned(
3514 &shaped,
3515 &line.chars,
3516 &line.char_positions,
3517 scale,
3518 style.font_size,
3519 &style.font_family,
3520 style.font_weight,
3521 style.font_style,
3522 Some(style.color),
3523 href,
3524 style.text_decoration,
3525 style.letter_spacing,
3526 );
3527 }
3528 }
3529
3530 let mut glyphs: Vec<PositionedGlyph> = line
3532 .chars
3533 .iter()
3534 .enumerate()
3535 .map(|(j, ch)| {
3536 let glyph_x = line.char_positions.get(j).copied().unwrap_or(0.0);
3537 let char_width = font_context.char_width(
3538 *ch,
3539 &style.font_family,
3540 style.font_weight,
3541 italic,
3542 style.font_size,
3543 );
3544 PositionedGlyph {
3545 glyph_id: *ch as u16,
3546 x_offset: glyph_x,
3547 y_offset: 0.0,
3548 x_advance: char_width,
3549 font_size: style.font_size,
3550 font_family: style.font_family.clone(),
3551 font_weight: style.font_weight,
3552 font_style: style.font_style,
3553 char_value: *ch,
3554 color: Some(style.color),
3555 href: href.map(|s| s.to_string()),
3556 text_decoration: style.text_decoration,
3557 letter_spacing: style.letter_spacing,
3558 cluster_text: None,
3559 }
3560 })
3561 .collect();
3562
3563 if has_bidi && !glyphs.is_empty() {
3565 let bidi_runs = bidi::analyze_bidi(&line_text, direction);
3566 let mut levels = Vec::with_capacity(glyphs.len());
3567 let mut char_idx = 0;
3568 for run in &bidi_runs {
3569 for _ in run.char_start..run.char_end {
3570 if char_idx < glyphs.len() {
3571 levels.push(run.level);
3572 char_idx += 1;
3573 }
3574 }
3575 }
3576 while levels.len() < glyphs.len() {
3578 levels.push(unicode_bidi::Level::ltr());
3579 }
3580 glyphs = bidi::reorder_line_glyphs(glyphs, &levels);
3581 bidi::reposition_after_reorder(&mut glyphs, 0.0);
3582 }
3583
3584 glyphs
3585 }
3586
3587 fn build_positioned_glyphs_runs(
3592 &self,
3593 run_line: &RunBrokenLine,
3594 font_context: &FontContext,
3595 direction: Direction,
3596 ) -> Vec<PositionedGlyph> {
3597 let chars = &run_line.chars;
3598 if chars.is_empty() {
3599 return vec![];
3600 }
3601
3602 let resolved_families: Vec<String> = chars
3605 .iter()
3606 .map(|sc| {
3607 if !sc.font_family.contains(',') {
3608 sc.font_family.clone()
3609 } else {
3610 let italic = matches!(sc.font_style, FontStyle::Italic | FontStyle::Oblique);
3611 let (_, family) = font_context.registry().resolve_for_char(
3612 &sc.font_family,
3613 sc.ch,
3614 sc.font_weight,
3615 italic,
3616 );
3617 family
3618 }
3619 })
3620 .collect();
3621
3622 let line_text: String = chars.iter().map(|c| c.ch).collect();
3623 let has_bidi = !bidi::is_pure_ltr(&line_text, direction);
3624 let bidi_runs = if has_bidi {
3625 Some(bidi::analyze_bidi(&line_text, direction))
3626 } else {
3627 None
3628 };
3629
3630 let mut glyphs = Vec::new();
3631 let mut bidi_levels = Vec::new();
3632 let mut i = 0;
3633
3634 while i < chars.len() {
3635 let sc = &chars[i];
3636 let italic = matches!(sc.font_style, FontStyle::Italic | FontStyle::Oblique);
3637 let resolved_family = &resolved_families[i];
3638
3639 let is_rtl = bidi_runs.as_ref().is_some_and(|runs| {
3641 runs.iter()
3642 .any(|r| i >= r.char_start && i < r.char_end && r.is_rtl)
3643 });
3644
3645 if let Some(font_data) = font_context.font_data(resolved_family, sc.font_weight, italic)
3647 {
3648 let run_start = i;
3650 let mut run_end = i + 1;
3651 while run_end < chars.len() {
3652 let next = &chars[run_end];
3653 let next_italic =
3654 matches!(next.font_style, FontStyle::Italic | FontStyle::Oblique);
3655 let next_is_rtl = bidi_runs.as_ref().is_some_and(|runs| {
3656 runs.iter()
3657 .any(|r| run_end >= r.char_start && run_end < r.char_end && r.is_rtl)
3658 });
3659 if resolved_families[run_end] == *resolved_family
3661 && next.font_weight == sc.font_weight
3662 && next_italic == italic
3663 && (next.font_size - sc.font_size).abs() < 0.001
3664 && next_is_rtl == is_rtl
3665 {
3666 run_end += 1;
3667 } else {
3668 break;
3669 }
3670 }
3671
3672 let run_text: String = chars[run_start..run_end].iter().map(|c| c.ch).collect();
3673 if let Some(shaped) =
3674 shaping::shape_text_with_direction(&run_text, font_data, is_rtl)
3675 {
3676 let units_per_em =
3677 font_context.units_per_em(resolved_family, sc.font_weight, italic);
3678 let scale = sc.font_size / units_per_em as f64;
3679
3680 let run_chars: Vec<char> =
3682 chars[run_start..run_end].iter().map(|c| c.ch).collect();
3683 let run_positions: Vec<f64> = (run_start..run_end)
3684 .map(|j| run_line.char_positions.get(j).copied().unwrap_or(0.0))
3685 .collect();
3686
3687 let mut run_glyphs = self.shaped_glyphs_to_positioned_runs(
3689 &shaped,
3690 &chars[run_start..run_end],
3691 &run_chars,
3692 &run_positions,
3693 scale,
3694 );
3695 for g in &mut run_glyphs {
3697 g.font_family = resolved_family.clone();
3698 }
3699 let run_level = if is_rtl {
3701 unicode_bidi::Level::rtl()
3702 } else {
3703 unicode_bidi::Level::ltr()
3704 };
3705 for _ in &run_glyphs {
3706 bidi_levels.push(run_level);
3707 }
3708 glyphs.extend(run_glyphs);
3709 i = run_end;
3710 continue;
3711 }
3712 }
3713
3714 let glyph_x = run_line.char_positions.get(i).copied().unwrap_or(0.0);
3716 let char_width = font_context.char_width(
3717 sc.ch,
3718 resolved_family,
3719 sc.font_weight,
3720 italic,
3721 sc.font_size,
3722 );
3723 glyphs.push(PositionedGlyph {
3724 glyph_id: sc.ch as u16,
3725 x_offset: glyph_x,
3726 y_offset: 0.0,
3727 x_advance: char_width,
3728 font_size: sc.font_size,
3729 font_family: resolved_family.clone(),
3730 font_weight: sc.font_weight,
3731 font_style: sc.font_style,
3732 char_value: sc.ch,
3733 color: Some(sc.color),
3734 href: sc.href.clone(),
3735 text_decoration: sc.text_decoration,
3736 letter_spacing: sc.letter_spacing,
3737 cluster_text: None,
3738 });
3739 bidi_levels.push(if is_rtl {
3740 unicode_bidi::Level::rtl()
3741 } else {
3742 unicode_bidi::Level::ltr()
3743 });
3744 i += 1;
3745 }
3746
3747 if has_bidi && !glyphs.is_empty() {
3749 glyphs = bidi::reorder_line_glyphs(glyphs, &bidi_levels);
3750 bidi::reposition_after_reorder(&mut glyphs, 0.0);
3751 }
3752
3753 glyphs
3754 }
3755
3756 #[allow(clippy::too_many_arguments)]
3758 fn shaped_glyphs_to_positioned(
3759 &self,
3760 shaped: &[shaping::ShapedGlyph],
3761 chars: &[char],
3762 _char_positions: &[f64],
3763 scale: f64,
3764 font_size: f64,
3765 font_family: &str,
3766 font_weight: u32,
3767 font_style: FontStyle,
3768 color: Option<Color>,
3769 href: Option<&str>,
3770 text_decoration: TextDecoration,
3771 letter_spacing: f64,
3772 ) -> Vec<PositionedGlyph> {
3773 let mut result = Vec::with_capacity(shaped.len());
3774 let mut x = 0.0_f64;
3775
3776 for sg in shaped {
3777 let cluster = sg.cluster as usize;
3778 let char_value = chars.get(cluster).copied().unwrap_or(' ');
3779
3780 let cluster_text = if shaped.len() < chars.len() {
3782 let cluster_end = self.find_cluster_end(shaped, sg, chars.len());
3785 if cluster_end > cluster + 1 {
3786 Some(chars[cluster..cluster_end].iter().collect::<String>())
3787 } else {
3788 None
3789 }
3790 } else {
3791 None
3792 };
3793
3794 let glyph_x = x + sg.x_offset as f64 * scale;
3796 let glyph_y = sg.y_offset as f64 * scale;
3797 let advance = sg.x_advance as f64 * scale + letter_spacing;
3798
3799 result.push(PositionedGlyph {
3800 glyph_id: sg.glyph_id,
3801 x_offset: glyph_x,
3802 y_offset: glyph_y,
3803 x_advance: advance,
3804 font_size,
3805 font_family: font_family.to_string(),
3806 font_weight,
3807 font_style,
3808 char_value,
3809 color,
3810 href: href.map(|s| s.to_string()),
3811 text_decoration,
3812 letter_spacing,
3813 cluster_text,
3814 });
3815
3816 x += advance;
3817 }
3818
3819 result
3820 }
3821
3822 fn shaped_glyphs_to_positioned_runs(
3824 &self,
3825 shaped: &[shaping::ShapedGlyph],
3826 styled_chars: &[StyledChar],
3827 chars: &[char],
3828 char_positions: &[f64],
3829 scale: f64,
3830 ) -> Vec<PositionedGlyph> {
3831 let mut result = Vec::with_capacity(shaped.len());
3832 let base_x = char_positions.first().copied().unwrap_or(0.0);
3834 let mut x = 0.0_f64;
3835
3836 for sg in shaped {
3837 let cluster = sg.cluster as usize;
3838 let sc = styled_chars.get(cluster).unwrap_or(&styled_chars[0]);
3839 let char_value = chars.get(cluster).copied().unwrap_or(' ');
3840
3841 let cluster_text = if shaped.len() < chars.len() {
3842 let cluster_end = self.find_cluster_end(shaped, sg, chars.len());
3843 if cluster_end > cluster + 1 {
3844 Some(chars[cluster..cluster_end].iter().collect::<String>())
3845 } else {
3846 None
3847 }
3848 } else {
3849 None
3850 };
3851
3852 let glyph_x = base_x + x + sg.x_offset as f64 * scale;
3853 let glyph_y = sg.y_offset as f64 * scale;
3854 let advance = sg.x_advance as f64 * scale + sc.letter_spacing;
3855
3856 result.push(PositionedGlyph {
3857 glyph_id: sg.glyph_id,
3858 x_offset: glyph_x,
3859 y_offset: glyph_y,
3860 x_advance: advance,
3861 font_size: sc.font_size,
3862 font_family: sc.font_family.clone(),
3863 font_weight: sc.font_weight,
3864 font_style: sc.font_style,
3865 char_value,
3866 color: Some(sc.color),
3867 href: sc.href.clone(),
3868 text_decoration: sc.text_decoration,
3869 letter_spacing: sc.letter_spacing,
3870 cluster_text,
3871 });
3872
3873 x += advance;
3874 }
3875
3876 result
3877 }
3878
3879 fn find_cluster_end(
3881 &self,
3882 shaped: &[shaping::ShapedGlyph],
3883 current: &shaping::ShapedGlyph,
3884 num_chars: usize,
3885 ) -> usize {
3886 for sg in shaped {
3888 if sg.cluster > current.cluster {
3889 return sg.cluster as usize;
3890 }
3891 }
3892 num_chars
3894 }
3895
3896 #[allow(clippy::too_many_arguments)]
3897 fn layout_image(
3898 &self,
3899 node: &Node,
3900 style: &ResolvedStyle,
3901 cursor: &mut PageCursor,
3902 pages: &mut Vec<LayoutPage>,
3903 x: f64,
3904 available_width: f64,
3905 explicit_width: Option<f64>,
3906 explicit_height: Option<f64>,
3907 ) {
3908 let margin = &style.margin.to_edges();
3909
3910 let src = match &node.kind {
3912 NodeKind::Image { src, .. } => src.as_str(),
3913 _ => "",
3914 };
3915
3916 let loaded = if !src.is_empty() {
3917 crate::image_loader::load_image(src).ok()
3918 } else {
3919 None
3920 };
3921
3922 let (img_width, img_height) = if let Some(ref img) = loaded {
3924 let intrinsic_w = img.width_px as f64;
3925 let intrinsic_h = img.height_px as f64;
3926 let aspect = if intrinsic_w > 0.0 {
3927 intrinsic_h / intrinsic_w
3928 } else {
3929 0.75
3930 };
3931
3932 match (explicit_width, explicit_height) {
3933 (Some(w), Some(h)) => (w, h),
3934 (Some(w), None) => (w, w * aspect),
3935 (None, Some(h)) => (h / aspect, h),
3936 (None, None) => {
3937 let max_w = available_width - margin.horizontal();
3938 let w = intrinsic_w.min(max_w);
3939 (w, w * aspect)
3940 }
3941 }
3942 } else {
3943 let w = explicit_width.unwrap_or(available_width - margin.horizontal());
3945 let h = explicit_height.unwrap_or(w * 0.75);
3946 (w, h)
3947 };
3948
3949 let total_height = img_height + margin.vertical();
3950
3951 if total_height > cursor.remaining_height() {
3952 pages.push(cursor.finalize());
3953 *cursor = cursor.new_page();
3954 }
3955
3956 cursor.y += margin.top;
3957
3958 let draw = if let Some(image_data) = loaded {
3959 DrawCommand::Image { image_data }
3960 } else {
3961 DrawCommand::ImagePlaceholder
3962 };
3963
3964 cursor.elements.push(LayoutElement {
3965 x: x + margin.left,
3966 y: cursor.content_y + cursor.y,
3967 width: img_width,
3968 height: img_height,
3969 draw,
3970 children: vec![],
3971 node_type: Some(node_kind_name(&node.kind).to_string()),
3972 resolved_style: Some(style.clone()),
3973 source_location: node.source_location.clone(),
3974 href: node.href.clone(),
3975 bookmark: node.bookmark.clone(),
3976 alt: node.alt.clone(),
3977 is_header_row: false,
3978 overflow: style.overflow,
3979 opacity: style.opacity,
3980 });
3981
3982 cursor.y += img_height + margin.bottom;
3983 }
3984
3985 #[allow(clippy::too_many_arguments)]
3987 fn layout_svg(
3988 &self,
3989 node: &Node,
3990 style: &ResolvedStyle,
3991 cursor: &mut PageCursor,
3992 pages: &mut Vec<LayoutPage>,
3993 x: f64,
3994 _available_width: f64,
3995 svg_width: f64,
3996 svg_height: f64,
3997 view_box: Option<&str>,
3998 content: &str,
3999 ) {
4000 let margin = &style.margin.to_edges();
4001 let total_height = svg_height + margin.vertical();
4002
4003 if total_height > cursor.remaining_height() {
4004 pages.push(cursor.finalize());
4005 *cursor = cursor.new_page();
4006 }
4007
4008 cursor.y += margin.top;
4009
4010 let vb = view_box
4011 .and_then(crate::svg::parse_view_box)
4012 .unwrap_or(crate::svg::ViewBox {
4013 min_x: 0.0,
4014 min_y: 0.0,
4015 width: svg_width,
4016 height: svg_height,
4017 });
4018
4019 let commands = crate::svg::parse_svg(content, vb, svg_width, svg_height);
4020
4021 cursor.elements.push(LayoutElement {
4022 x: x + margin.left,
4023 y: cursor.content_y + cursor.y,
4024 width: svg_width,
4025 height: svg_height,
4026 draw: DrawCommand::Svg {
4027 commands,
4028 width: svg_width,
4029 height: svg_height,
4030 viewbox_min_x: vb.min_x,
4031 viewbox_min_y: vb.min_y,
4032 viewbox_width: vb.width,
4033 viewbox_height: vb.height,
4034 clip: false,
4035 },
4036 children: vec![],
4037 node_type: Some("Svg".to_string()),
4038 resolved_style: Some(style.clone()),
4039 source_location: node.source_location.clone(),
4040 href: node.href.clone(),
4041 bookmark: node.bookmark.clone(),
4042 alt: node.alt.clone(),
4043 is_header_row: false,
4044 overflow: style.overflow,
4045 opacity: style.opacity,
4046 });
4047
4048 cursor.y += svg_height + margin.bottom;
4049 }
4050
4051 fn canvas_ops_to_svg_commands(operations: &[CanvasOp]) -> Vec<crate::svg::SvgCommand> {
4053 use crate::svg::SvgCommand;
4054
4055 let mut commands = Vec::new();
4056 let mut cur_x = 0.0_f64;
4057 let mut cur_y = 0.0_f64;
4058
4059 for op in operations {
4060 match op {
4061 CanvasOp::MoveTo { x, y } => {
4062 commands.push(SvgCommand::MoveTo(*x, *y));
4063 cur_x = *x;
4064 cur_y = *y;
4065 }
4066 CanvasOp::LineTo { x, y } => {
4067 commands.push(SvgCommand::LineTo(*x, *y));
4068 cur_x = *x;
4069 cur_y = *y;
4070 }
4071 CanvasOp::BezierCurveTo {
4072 cp1x,
4073 cp1y,
4074 cp2x,
4075 cp2y,
4076 x,
4077 y,
4078 } => {
4079 commands.push(SvgCommand::CurveTo(*cp1x, *cp1y, *cp2x, *cp2y, *x, *y));
4080 cur_x = *x;
4081 cur_y = *y;
4082 }
4083 CanvasOp::QuadraticCurveTo { cpx, cpy, x, y } => {
4084 let cp1x = cur_x + 2.0 / 3.0 * (*cpx - cur_x);
4086 let cp1y = cur_y + 2.0 / 3.0 * (*cpy - cur_y);
4087 let cp2x = *x + 2.0 / 3.0 * (*cpx - *x);
4088 let cp2y = *y + 2.0 / 3.0 * (*cpy - *y);
4089 commands.push(SvgCommand::CurveTo(cp1x, cp1y, cp2x, cp2y, *x, *y));
4090 cur_x = *x;
4091 cur_y = *y;
4092 }
4093 CanvasOp::ClosePath => {
4094 commands.push(SvgCommand::ClosePath);
4095 }
4096 CanvasOp::Rect {
4097 x,
4098 y,
4099 width,
4100 height,
4101 } => {
4102 commands.push(SvgCommand::MoveTo(*x, *y));
4103 commands.push(SvgCommand::LineTo(*x + *width, *y));
4104 commands.push(SvgCommand::LineTo(*x + *width, *y + *height));
4105 commands.push(SvgCommand::LineTo(*x, *y + *height));
4106 commands.push(SvgCommand::ClosePath);
4107 cur_x = *x;
4108 cur_y = *y;
4109 }
4110 CanvasOp::Circle { cx, cy, r } => {
4111 commands.extend(crate::svg::ellipse_commands(*cx, *cy, *r, *r));
4112 }
4113 CanvasOp::Ellipse { cx, cy, rx, ry } => {
4114 commands.extend(crate::svg::ellipse_commands(*cx, *cy, *rx, *ry));
4115 }
4116 CanvasOp::Arc {
4117 cx,
4118 cy,
4119 r,
4120 start_angle,
4121 end_angle,
4122 counterclockwise,
4123 } => {
4124 let steps = 32;
4128 let mut sweep = end_angle - start_angle;
4129 if !counterclockwise && sweep < 0.0 {
4130 sweep += 2.0 * std::f64::consts::PI;
4131 }
4132 if *counterclockwise && sweep > 0.0 {
4133 sweep -= 2.0 * std::f64::consts::PI;
4134 }
4135 for i in 0..=steps {
4136 let t = *start_angle + sweep * (i as f64 / steps as f64);
4137 let px = cx + r * t.cos();
4138 let py = cy + r * t.sin();
4139 if i == 0 {
4140 commands.push(SvgCommand::MoveTo(px, py));
4141 } else {
4142 commands.push(SvgCommand::LineTo(px, py));
4143 }
4144 }
4145 }
4146 CanvasOp::Stroke => commands.push(SvgCommand::Stroke),
4147 CanvasOp::Fill => commands.push(SvgCommand::Fill),
4148 CanvasOp::FillAndStroke => commands.push(SvgCommand::FillAndStroke),
4149 CanvasOp::SetFillColor { r, g, b } => {
4150 commands.push(SvgCommand::SetFill(r / 255.0, g / 255.0, b / 255.0));
4152 }
4153 CanvasOp::SetStrokeColor { r, g, b } => {
4154 commands.push(SvgCommand::SetStroke(r / 255.0, g / 255.0, b / 255.0));
4155 }
4156 CanvasOp::SetLineWidth { width } => {
4157 commands.push(SvgCommand::SetStrokeWidth(*width));
4158 }
4159 CanvasOp::SetLineCap { cap } => {
4160 commands.push(SvgCommand::SetLineCap(*cap));
4161 }
4162 CanvasOp::SetLineJoin { join } => {
4163 commands.push(SvgCommand::SetLineJoin(*join));
4164 }
4165 CanvasOp::Save => commands.push(SvgCommand::SaveState),
4166 CanvasOp::Restore => commands.push(SvgCommand::RestoreState),
4167 }
4168 }
4169
4170 commands
4171 }
4172
4173 #[allow(clippy::too_many_arguments)]
4175 fn layout_canvas(
4176 &self,
4177 node: &Node,
4178 style: &ResolvedStyle,
4179 cursor: &mut PageCursor,
4180 pages: &mut Vec<LayoutPage>,
4181 x: f64,
4182 _available_width: f64,
4183 canvas_width: f64,
4184 canvas_height: f64,
4185 operations: &[CanvasOp],
4186 ) {
4187 let margin = style.margin.to_edges();
4188 let total_height = canvas_height + margin.top + margin.bottom;
4189
4190 if cursor.remaining_height() < total_height && cursor.y > 0.0 {
4192 pages.push(cursor.finalize());
4193 *cursor = cursor.new_page();
4194 }
4195
4196 cursor.y += margin.top;
4197
4198 let svg_commands = Self::canvas_ops_to_svg_commands(operations);
4199
4200 cursor.elements.push(LayoutElement {
4201 x: x + margin.left,
4202 y: cursor.content_y + cursor.y,
4203 width: canvas_width,
4204 height: canvas_height,
4205 draw: DrawCommand::Svg {
4206 commands: svg_commands,
4207 width: canvas_width,
4208 height: canvas_height,
4209 viewbox_min_x: 0.0,
4212 viewbox_min_y: 0.0,
4213 viewbox_width: canvas_width,
4214 viewbox_height: canvas_height,
4215 clip: true,
4216 },
4217 children: vec![],
4218 node_type: Some("Canvas".to_string()),
4219 resolved_style: Some(style.clone()),
4220 source_location: node.source_location.clone(),
4221 href: node.href.clone(),
4222 bookmark: node.bookmark.clone(),
4223 alt: node.alt.clone(),
4224 is_header_row: false,
4225 overflow: style.overflow,
4226 opacity: style.opacity,
4227 });
4228
4229 cursor.y += canvas_height + margin.bottom;
4230 }
4231
4232 #[allow(clippy::too_many_arguments)]
4234 #[allow(clippy::too_many_arguments)]
4236 fn layout_chart(
4237 &self,
4238 node: &Node,
4239 style: &ResolvedStyle,
4240 cursor: &mut PageCursor,
4241 pages: &mut Vec<LayoutPage>,
4242 x: f64,
4243 chart_width: f64,
4244 chart_height: f64,
4245 primitives: Vec<crate::chart::ChartPrimitive>,
4246 node_type_name: &str,
4247 ) {
4248 let margin = &style.margin.to_edges();
4249 let total_height = chart_height + margin.vertical();
4250
4251 if total_height > cursor.remaining_height() {
4252 pages.push(cursor.finalize());
4253 *cursor = cursor.new_page();
4254 }
4255
4256 cursor.y += margin.top;
4257
4258 let draw = DrawCommand::Chart { primitives };
4259
4260 cursor.elements.push(LayoutElement {
4261 x: x + margin.left,
4262 y: cursor.content_y + cursor.y,
4263 width: chart_width,
4264 height: chart_height,
4265 draw,
4266 children: vec![],
4267 node_type: Some(node_type_name.to_string()),
4268 resolved_style: Some(style.clone()),
4269 source_location: node.source_location.clone(),
4270 href: node.href.clone(),
4271 bookmark: node.bookmark.clone(),
4272 alt: node.alt.clone(),
4273 is_header_row: false,
4274 overflow: style.overflow,
4275 opacity: style.opacity,
4276 });
4277
4278 cursor.y += chart_height + margin.bottom;
4279 }
4280
4281 #[allow(clippy::too_many_arguments)]
4283 fn layout_form_field(
4284 &self,
4285 node: &Node,
4286 style: &ResolvedStyle,
4287 cursor: &mut PageCursor,
4288 pages: &mut Vec<LayoutPage>,
4289 x: f64,
4290 field_width: f64,
4291 field_height: f64,
4292 draw: DrawCommand,
4293 node_type_name: &str,
4294 ) {
4295 let margin = &style.margin.to_edges();
4296 let total_height = field_height + margin.vertical();
4297
4298 if total_height > cursor.remaining_height() {
4299 pages.push(cursor.finalize());
4300 *cursor = cursor.new_page();
4301 }
4302
4303 cursor.y += margin.top;
4304
4305 cursor.elements.push(LayoutElement {
4306 x: x + margin.left,
4307 y: cursor.content_y + cursor.y,
4308 width: field_width,
4309 height: field_height,
4310 draw,
4311 children: vec![],
4312 node_type: Some(node_type_name.to_string()),
4313 resolved_style: Some(style.clone()),
4314 source_location: node.source_location.clone(),
4315 href: node.href.clone(),
4316 bookmark: node.bookmark.clone(),
4317 alt: node.alt.clone(),
4318 is_header_row: false,
4319 overflow: style.overflow,
4320 opacity: style.opacity,
4321 });
4322
4323 cursor.y += field_height + margin.bottom;
4324 }
4325
4326 #[allow(clippy::too_many_arguments)]
4327 fn layout_barcode(
4328 &self,
4329 node: &Node,
4330 style: &ResolvedStyle,
4331 cursor: &mut PageCursor,
4332 pages: &mut Vec<LayoutPage>,
4333 x: f64,
4334 available_width: f64,
4335 data: &str,
4336 format: crate::barcode::BarcodeFormat,
4337 explicit_width: Option<f64>,
4338 bar_height: f64,
4339 ) {
4340 let margin = &style.margin.to_edges();
4341 let display_width = explicit_width.unwrap_or(available_width - margin.horizontal());
4342 let total_height = bar_height + margin.vertical();
4343
4344 if total_height > cursor.remaining_height() {
4345 pages.push(cursor.finalize());
4346 *cursor = cursor.new_page();
4347 }
4348
4349 cursor.y += margin.top;
4350
4351 let draw = match crate::barcode::generate_barcode(data, format) {
4352 Ok(barcode_data) => {
4353 let bar_width = if barcode_data.bars.is_empty() {
4354 0.0
4355 } else {
4356 display_width / barcode_data.bars.len() as f64
4357 };
4358 DrawCommand::Barcode {
4359 bars: barcode_data.bars,
4360 bar_width,
4361 height: bar_height,
4362 color: style.color,
4363 }
4364 }
4365 Err(_) => DrawCommand::None,
4366 };
4367
4368 cursor.elements.push(LayoutElement {
4369 x: x + margin.left,
4370 y: cursor.content_y + cursor.y,
4371 width: display_width,
4372 height: bar_height,
4373 draw,
4374 children: vec![],
4375 node_type: Some("Barcode".to_string()),
4376 resolved_style: Some(style.clone()),
4377 source_location: node.source_location.clone(),
4378 href: node.href.clone(),
4379 bookmark: node.bookmark.clone(),
4380 alt: node.alt.clone(),
4381 is_header_row: false,
4382 overflow: style.overflow,
4383 opacity: style.opacity,
4384 });
4385
4386 cursor.y += bar_height + margin.bottom;
4387 }
4388
4389 #[allow(clippy::too_many_arguments)]
4391 fn layout_qrcode(
4392 &self,
4393 node: &Node,
4394 style: &ResolvedStyle,
4395 cursor: &mut PageCursor,
4396 pages: &mut Vec<LayoutPage>,
4397 x: f64,
4398 available_width: f64,
4399 data: &str,
4400 explicit_size: Option<f64>,
4401 ) {
4402 let margin = &style.margin.to_edges();
4403 let display_size = explicit_size.unwrap_or(available_width - margin.horizontal());
4404 let total_height = display_size + margin.vertical();
4405
4406 if total_height > cursor.remaining_height() {
4407 pages.push(cursor.finalize());
4408 *cursor = cursor.new_page();
4409 }
4410
4411 cursor.y += margin.top;
4412
4413 let draw = match crate::qrcode::generate_qr(data) {
4414 Ok(matrix) => {
4415 let module_size = display_size / matrix.size as f64;
4416 DrawCommand::QrCode {
4417 modules: matrix.modules,
4418 module_size,
4419 color: style.color,
4420 }
4421 }
4422 Err(_) => DrawCommand::None,
4423 };
4424
4425 cursor.elements.push(LayoutElement {
4426 x: x + margin.left,
4427 y: cursor.content_y + cursor.y,
4428 width: display_size,
4429 height: display_size,
4430 draw,
4431 children: vec![],
4432 node_type: Some("QrCode".to_string()),
4433 resolved_style: Some(style.clone()),
4434 source_location: node.source_location.clone(),
4435 href: node.href.clone(),
4436 bookmark: node.bookmark.clone(),
4437 alt: node.alt.clone(),
4438 is_header_row: false,
4439 overflow: style.overflow,
4440 opacity: style.opacity,
4441 });
4442
4443 cursor.y += display_size + margin.bottom;
4444 }
4445
4446 fn measure_node_height(
4449 &self,
4450 node: &Node,
4451 available_width: f64,
4452 style: &ResolvedStyle,
4453 font_context: &FontContext,
4454 ) -> f64 {
4455 match &node.kind {
4456 NodeKind::Text { content, runs, .. } => {
4457 let measure_width = match style.width {
4460 SizeConstraint::Fixed(w) => (w - style.margin.horizontal()).max(0.0),
4461 SizeConstraint::Auto => available_width - style.margin.horizontal(),
4462 };
4463 if !runs.is_empty() {
4464 let mut styled_chars: Vec<StyledChar> = Vec::new();
4466 for run in runs {
4467 let run_style = run.style.resolve(Some(style), measure_width);
4468 let run_content = substitute_page_placeholders(&run.content);
4469 for ch in run_content.chars() {
4470 styled_chars.push(StyledChar {
4471 ch,
4472 font_family: run_style.font_family.clone(),
4473 font_size: run_style.font_size,
4474 font_weight: run_style.font_weight,
4475 font_style: run_style.font_style,
4476 color: run_style.color,
4477 href: None,
4478 text_decoration: run_style.text_decoration,
4479 letter_spacing: run_style.letter_spacing,
4480 });
4481 }
4482 }
4483 let broken_lines = self.text_layout.break_runs_into_lines(
4484 font_context,
4485 &styled_chars,
4486 measure_width,
4487 style.hyphens,
4488 style.lang.as_deref(),
4489 );
4490 let line_height = style.font_size * style.line_height;
4491 (broken_lines.len() as f64) * line_height + style.padding.vertical()
4492 } else {
4493 let content = substitute_page_placeholders(content);
4494 let lines = self.text_layout.break_into_lines(
4495 font_context,
4496 &content,
4497 measure_width,
4498 style.font_size,
4499 &style.font_family,
4500 style.font_weight,
4501 style.font_style,
4502 style.letter_spacing,
4503 style.hyphens,
4504 style.lang.as_deref(),
4505 );
4506 let line_height = style.font_size * style.line_height;
4507 (lines.len() as f64) * line_height + style.padding.vertical()
4508 }
4509 }
4510 NodeKind::Image {
4511 src,
4512 width: explicit_w,
4513 height: explicit_h,
4514 } => {
4515 if let SizeConstraint::Fixed(h) = style.height {
4517 return h + style.padding.vertical();
4518 }
4519 if let Some(h) = explicit_h {
4521 return *h + style.padding.vertical();
4522 }
4523 let aspect = self
4525 .get_image_dimensions(src)
4526 .map(|(w, h)| if w > 0 { h as f64 / w as f64 } else { 0.75 })
4527 .unwrap_or(0.75);
4528 let w = if let SizeConstraint::Fixed(w) = style.width {
4529 w
4530 } else {
4531 explicit_w.unwrap_or(available_width - style.margin.horizontal())
4532 };
4533 w * aspect + style.padding.vertical()
4534 }
4535 NodeKind::Svg { height, .. } => *height + style.margin.vertical(),
4536 NodeKind::Barcode { height, .. } => *height + style.margin.vertical(),
4537 NodeKind::QrCode { size, .. } => {
4538 let display_size = size.unwrap_or(available_width - style.margin.horizontal());
4539 display_size + style.margin.vertical()
4540 }
4541 NodeKind::Canvas { height, .. } => *height + style.margin.vertical(),
4542 NodeKind::BarChart { height, .. }
4543 | NodeKind::LineChart { height, .. }
4544 | NodeKind::PieChart { height, .. }
4545 | NodeKind::AreaChart { height, .. }
4546 | NodeKind::DotPlot { height, .. } => *height + style.margin.vertical(),
4547 NodeKind::TextField { height, .. }
4548 | NodeKind::Checkbox { height, .. }
4549 | NodeKind::Dropdown { height, .. }
4550 | NodeKind::RadioButton { height, .. } => *height + style.margin.vertical(),
4551 NodeKind::Watermark { .. } => 0.0, NodeKind::Table { columns } => {
4553 if let SizeConstraint::Fixed(h) = style.height {
4560 return h;
4561 }
4562 let outer_width = match style.width {
4563 SizeConstraint::Fixed(w) => w,
4564 SizeConstraint::Auto => available_width - style.margin.horizontal(),
4565 };
4566 let inner_width =
4567 outer_width - style.padding.horizontal() - style.border_width.horizontal();
4568 let col_widths = self.resolve_column_widths(columns, inner_width, &node.children);
4569 let row_gap = style.row_gap;
4570 let mut total = 0.0;
4571 for (i, row) in node.children.iter().enumerate() {
4572 if i > 0 {
4573 total += row_gap;
4574 }
4575 total += self.measure_table_row_height(row, &col_widths, style, font_context);
4576 }
4577 total + style.padding.vertical() + style.border_width.vertical()
4578 }
4579 NodeKind::TableRow { .. } => {
4580 let n = node.children.len().max(1);
4585 let usable = (available_width - style.margin.horizontal()).max(0.0);
4586 let col_w = usable / n as f64;
4587 let col_widths = vec![col_w; n];
4588 self.measure_table_row_height(node, &col_widths, style, font_context)
4589 }
4590 _ => {
4591 if let SizeConstraint::Fixed(h) = style.height {
4593 return h;
4594 }
4595 let outer_width = match style.width {
4597 SizeConstraint::Fixed(w) => w,
4598 SizeConstraint::Auto => available_width - style.margin.horizontal(),
4599 };
4600 let inner_width =
4601 outer_width - style.padding.horizontal() - style.border_width.horizontal();
4602 let children_height =
4603 self.measure_children_height(&node.children, inner_width, style, font_context);
4604 children_height + style.padding.vertical() + style.border_width.vertical()
4605 }
4606 }
4607 }
4608
4609 fn measure_children_height(
4610 &self,
4611 children: &[Node],
4612 available_width: f64,
4613 parent_style: &ResolvedStyle,
4614 font_context: &FontContext,
4615 ) -> f64 {
4616 if matches!(parent_style.display, Display::Grid) {
4618 if let Some(template_cols) = &parent_style.grid_template_columns {
4619 let num_columns = template_cols.len();
4620 if num_columns > 0 && !children.is_empty() {
4621 let col_gap = parent_style.column_gap;
4622 let row_gap = parent_style.row_gap;
4623
4624 let content_sizes: Vec<f64> = template_cols
4625 .iter()
4626 .map(|track| {
4627 if matches!(track, GridTrackSize::Auto) {
4628 available_width / num_columns as f64
4629 } else {
4630 0.0
4631 }
4632 })
4633 .collect();
4634
4635 let col_widths = grid::resolve_tracks(
4636 template_cols,
4637 available_width,
4638 col_gap,
4639 &content_sizes,
4640 );
4641
4642 let placements: Vec<Option<&GridPlacement>> = children
4643 .iter()
4644 .map(|child| child.style.grid_placement.as_ref())
4645 .collect();
4646
4647 let item_placements = grid::place_items(&placements, num_columns);
4648 let num_rows = grid::compute_num_rows(&item_placements);
4649
4650 if num_rows == 0 {
4651 return 0.0;
4652 }
4653
4654 let mut row_heights = vec![0.0_f64; num_rows];
4655 for placement in &item_placements {
4656 let cell_width = grid::span_width(
4657 placement.col_start,
4658 placement.col_end,
4659 &col_widths,
4660 col_gap,
4661 );
4662 let child = &children[placement.child_index];
4663 let child_style = child.style.resolve(Some(parent_style), cell_width);
4664 let h =
4665 self.measure_node_height(child, cell_width, &child_style, font_context);
4666 let span = placement.row_end - placement.row_start;
4667 let per_row = h / span as f64;
4668 for rh in row_heights
4669 .iter_mut()
4670 .take(placement.row_end.min(num_rows))
4671 .skip(placement.row_start)
4672 {
4673 if per_row > *rh {
4674 *rh = per_row;
4675 }
4676 }
4677 }
4678
4679 let total_row_gap = row_gap * (num_rows as f64 - 1.0).max(0.0);
4680 return row_heights.iter().sum::<f64>() + total_row_gap;
4681 }
4682 }
4683 }
4684
4685 let direction = parent_style.flex_direction;
4686 let row_gap = parent_style.row_gap;
4687 let column_gap = parent_style.column_gap;
4688
4689 match direction {
4690 FlexDirection::Row | FlexDirection::RowReverse => {
4691 let styles: Vec<ResolvedStyle> = children
4694 .iter()
4695 .map(|child| child.style.resolve(Some(parent_style), available_width))
4696 .collect();
4697
4698 let base_widths: Vec<f64> = children
4699 .iter()
4700 .zip(&styles)
4701 .map(|(child, style)| match style.flex_basis {
4702 SizeConstraint::Fixed(w) => w,
4703 SizeConstraint::Auto => match style.width {
4704 SizeConstraint::Fixed(w) => w,
4705 SizeConstraint::Auto => self
4706 .measure_intrinsic_width(child, style, font_context)
4707 .min(available_width),
4708 },
4709 })
4710 .collect();
4711
4712 let lines = match parent_style.flex_wrap {
4713 FlexWrap::NoWrap => {
4714 vec![flex::WrapLine {
4715 start: 0,
4716 end: children.len(),
4717 }]
4718 }
4719 FlexWrap::Wrap | FlexWrap::WrapReverse => {
4720 flex::partition_into_lines(&base_widths, column_gap, available_width)
4721 }
4722 };
4723
4724 let mut final_widths = base_widths.clone();
4726 for line in &lines {
4727 let line_count = line.end - line.start;
4728 let line_gap = column_gap * (line_count as f64 - 1.0).max(0.0);
4729 let distributable = available_width - line_gap;
4730 let total_base: f64 = base_widths[line.start..line.end].iter().sum();
4731 let remaining = distributable - total_base;
4732
4733 if remaining > 0.0 {
4734 let total_grow: f64 = styles[line.start..line.end]
4735 .iter()
4736 .map(|s| s.flex_grow)
4737 .sum();
4738 if total_grow > 0.0 {
4739 for (j, s) in styles[line.start..line.end].iter().enumerate() {
4740 final_widths[line.start + j] = base_widths[line.start + j]
4741 + remaining * (s.flex_grow / total_grow);
4742 }
4743 }
4744 } else if remaining < 0.0 {
4745 let total_shrink: f64 = styles[line.start..line.end]
4746 .iter()
4747 .enumerate()
4748 .map(|(j, s)| s.flex_shrink * base_widths[line.start + j])
4749 .sum();
4750 if total_shrink > 0.0 {
4751 for (j, s) in styles[line.start..line.end].iter().enumerate() {
4752 let factor =
4753 (s.flex_shrink * base_widths[line.start + j]) / total_shrink;
4754 let w = base_widths[line.start + j] + remaining * factor;
4755 final_widths[line.start + j] = w.max(s.min_width);
4756 }
4757 }
4758 }
4759 }
4760
4761 let mut total = 0.0;
4762 for (i, line) in lines.iter().enumerate() {
4763 let line_height: f64 = children[line.start..line.end]
4764 .iter()
4765 .enumerate()
4766 .map(|(j, child)| {
4767 let fw = final_widths[line.start + j];
4768 let child_style = child.style.resolve(Some(parent_style), fw);
4769 self.measure_node_height(child, fw, &child_style, font_context)
4770 + child_style.margin.vertical()
4771 })
4772 .fold(0.0f64, f64::max);
4773 total += line_height;
4774 if i > 0 {
4775 total += row_gap;
4776 }
4777 }
4778 total
4779 }
4780 FlexDirection::Column | FlexDirection::ColumnReverse => {
4781 let mut total = 0.0;
4782 for (i, child) in children.iter().enumerate() {
4783 let child_style = child.style.resolve(Some(parent_style), available_width);
4784 let child_height = self.measure_node_height(
4785 child,
4786 available_width,
4787 &child_style,
4788 font_context,
4789 );
4790 total += child_height + child_style.margin.vertical();
4791 if i > 0 {
4792 total += row_gap;
4793 }
4794 }
4795 total
4796 }
4797 }
4798 }
4799
4800 fn measure_intrinsic_width(
4802 &self,
4803 node: &Node,
4804 style: &ResolvedStyle,
4805 font_context: &FontContext,
4806 ) -> f64 {
4807 match &node.kind {
4808 NodeKind::Svg { width, .. } => {
4809 *width + style.padding.horizontal() + style.margin.horizontal()
4810 }
4811 NodeKind::Text { content, .. } => {
4812 let content = substitute_page_placeholders(content);
4813 let transformed = apply_text_transform(&content, style.text_transform);
4814 let italic = matches!(style.font_style, FontStyle::Italic | FontStyle::Oblique);
4815 let text_width = font_context.measure_string(
4816 &transformed,
4817 &style.font_family,
4818 style.font_weight,
4819 italic,
4820 style.font_size,
4821 style.letter_spacing,
4822 );
4823 text_width + 0.01 + style.padding.horizontal() + style.margin.horizontal()
4826 }
4827 NodeKind::Image {
4828 src, width, height, ..
4829 } => {
4830 let w = if let SizeConstraint::Fixed(w) = style.width {
4831 w
4832 } else if let Some(w) = width {
4833 *w
4834 } else if let Some((iw, ih)) = self.get_image_dimensions(src) {
4835 let pixel_w = iw as f64;
4836 let pixel_h = ih as f64;
4837 let aspect = if pixel_w > 0.0 {
4838 pixel_h / pixel_w
4839 } else {
4840 0.75
4841 };
4842 let constrained_h = match style.height {
4844 SizeConstraint::Fixed(h) => Some(h),
4845 SizeConstraint::Auto => *height,
4846 };
4847 if let Some(h) = constrained_h {
4848 h / aspect
4849 } else {
4850 pixel_w
4851 }
4852 } else {
4853 100.0
4854 };
4855 w + style.padding.horizontal() + style.margin.horizontal()
4856 }
4857 NodeKind::Barcode { width, .. } => {
4858 let w = width.unwrap_or(0.0);
4859 w + style.padding.horizontal() + style.margin.horizontal()
4860 }
4861 NodeKind::QrCode { size, .. } => {
4862 let display_size = size.unwrap_or(0.0);
4863 display_size + style.padding.horizontal() + style.margin.horizontal()
4864 }
4865 NodeKind::Canvas { width, .. } => {
4866 *width + style.padding.horizontal() + style.margin.horizontal()
4867 }
4868 NodeKind::BarChart { width, .. }
4869 | NodeKind::LineChart { width, .. }
4870 | NodeKind::PieChart { width, .. }
4871 | NodeKind::AreaChart { width, .. }
4872 | NodeKind::DotPlot { width, .. } => {
4873 *width + style.padding.horizontal() + style.margin.horizontal()
4874 }
4875 NodeKind::TextField { width, .. } | NodeKind::Dropdown { width, .. } => {
4876 *width + style.padding.horizontal() + style.margin.horizontal()
4877 }
4878 NodeKind::Checkbox { width, .. } | NodeKind::RadioButton { width, .. } => {
4879 *width + style.padding.horizontal() + style.margin.horizontal()
4880 }
4881 NodeKind::Watermark { .. } => 0.0, _ => {
4883 if node.children.is_empty() {
4885 style.padding.horizontal() + style.margin.horizontal()
4886 } else {
4887 let direction = style.flex_direction;
4888 let gap = style.gap;
4889 let mut total = 0.0f64;
4890 for (i, child) in node.children.iter().enumerate() {
4891 let child_style = child.style.resolve(Some(style), 0.0);
4892 let child_width =
4893 self.measure_intrinsic_width(child, &child_style, font_context);
4894 match direction {
4895 FlexDirection::Row | FlexDirection::RowReverse => {
4896 total += child_width;
4897 if i > 0 {
4898 total += gap;
4899 }
4900 }
4901 _ => {
4902 total = total.max(child_width);
4903 }
4904 }
4905 }
4906 total
4907 + style.padding.horizontal()
4908 + style.margin.horizontal()
4909 + style.border_width.horizontal()
4910 }
4911 }
4912 }
4913 }
4914
4915 pub fn measure_min_content_width(
4919 &self,
4920 node: &Node,
4921 style: &ResolvedStyle,
4922 font_context: &FontContext,
4923 ) -> f64 {
4924 match &node.kind {
4925 NodeKind::Text { content, runs, .. } => {
4926 let word_width = if !runs.is_empty() {
4927 runs.iter()
4929 .map(|run| {
4930 let run_style = run.style.resolve(Some(style), 0.0);
4931 let run_content = substitute_page_placeholders(&run.content);
4932 let transformed =
4933 apply_text_transform(&run_content, run_style.text_transform);
4934 self.text_layout.measure_widest_word(
4935 font_context,
4936 &transformed,
4937 run_style.font_size,
4938 &run_style.font_family,
4939 run_style.font_weight,
4940 run_style.font_style,
4941 run_style.letter_spacing,
4942 style.hyphens,
4943 style.lang.as_deref(),
4944 )
4945 })
4946 .fold(0.0f64, f64::max)
4947 } else {
4948 let content = substitute_page_placeholders(content);
4949 let transformed = apply_text_transform(&content, style.text_transform);
4950 self.text_layout.measure_widest_word(
4951 font_context,
4952 &transformed,
4953 style.font_size,
4954 &style.font_family,
4955 style.font_weight,
4956 style.font_style,
4957 style.letter_spacing,
4958 style.hyphens,
4959 style.lang.as_deref(),
4960 )
4961 };
4962 word_width + style.padding.horizontal() + style.margin.horizontal()
4963 }
4964 NodeKind::Image { width, .. } => {
4965 width.unwrap_or(0.0) + style.padding.horizontal() + style.margin.horizontal()
4966 }
4967 NodeKind::Svg { width, .. } => {
4968 *width + style.padding.horizontal() + style.margin.horizontal()
4969 }
4970 _ => {
4971 if node.children.is_empty() {
4972 style.padding.horizontal()
4973 + style.margin.horizontal()
4974 + style.border_width.horizontal()
4975 } else {
4976 let mut max_child_min = 0.0f64;
4977 for child in &node.children {
4978 let child_style = child.style.resolve(Some(style), 0.0);
4979 let child_min =
4980 self.measure_min_content_width(child, &child_style, font_context);
4981 max_child_min = max_child_min.max(child_min);
4982 }
4983 max_child_min
4984 + style.padding.horizontal()
4985 + style.margin.horizontal()
4986 + style.border_width.horizontal()
4987 }
4988 }
4989 }
4990 }
4991
4992 fn measure_table_row_height(
4993 &self,
4994 row: &Node,
4995 col_widths: &[f64],
4996 parent_style: &ResolvedStyle,
4997 font_context: &FontContext,
4998 ) -> f64 {
4999 let row_style = row
5000 .style
5001 .resolve(Some(parent_style), col_widths.iter().sum());
5002 let mut max_height: f64 = 0.0;
5003
5004 for (i, cell) in row.children.iter().enumerate() {
5005 let col_width = col_widths.get(i).copied().unwrap_or(0.0);
5006 let cell_style = cell.style.resolve(Some(&row_style), col_width);
5007 let inner_width =
5008 col_width - cell_style.padding.horizontal() - cell_style.border_width.horizontal();
5009
5010 let mut cell_content_height = 0.0;
5011 for child in &cell.children {
5012 let child_style = child.style.resolve(Some(&cell_style), inner_width);
5013 cell_content_height +=
5014 self.measure_node_height(child, inner_width, &child_style, font_context);
5015 }
5016
5017 let total = cell_content_height
5018 + cell_style.padding.vertical()
5019 + cell_style.border_width.vertical();
5020 max_height = max_height.max(total);
5021 }
5022
5023 max_height.max(row_style.min_height)
5024 }
5025
5026 fn resolve_column_widths(
5027 &self,
5028 defs: &[ColumnDef],
5029 available_width: f64,
5030 children: &[Node],
5031 ) -> Vec<f64> {
5032 if defs.is_empty() {
5033 let num_cols = children.first().map(|row| row.children.len()).unwrap_or(1);
5034 return vec![available_width / num_cols as f64; num_cols];
5035 }
5036
5037 let mut widths = Vec::new();
5038 let mut remaining = available_width;
5039 let mut auto_count = 0;
5040
5041 for def in defs {
5042 match def.width {
5043 ColumnWidth::Fixed(w) => {
5044 widths.push(w);
5045 remaining -= w;
5046 }
5047 ColumnWidth::Fraction(f) => {
5048 let w = available_width * f;
5049 widths.push(w);
5050 remaining -= w;
5051 }
5052 ColumnWidth::Auto => {
5053 widths.push(0.0);
5054 auto_count += 1;
5055 }
5056 }
5057 }
5058
5059 if auto_count > 0 {
5060 let auto_width = remaining / auto_count as f64;
5061 for (i, def) in defs.iter().enumerate() {
5062 if matches!(def.width, ColumnWidth::Auto) {
5063 widths[i] = auto_width;
5064 }
5065 }
5066 }
5067
5068 widths
5069 }
5070
5071 fn inject_fixed_elements(&self, pages: &mut [LayoutPage], font_context: &FontContext) {
5072 for page in pages.iter_mut() {
5073 if !page.watermarks.is_empty() {
5075 let (page_w, page_h) = page.config.size.dimensions();
5076 let cx = page_w / 2.0;
5077 let cy = page_h / 2.0;
5078
5079 let mut watermark_elements = Vec::new();
5080 for wm_node in &page.watermarks {
5081 if let NodeKind::Watermark {
5082 text,
5083 font_size,
5084 angle,
5085 } = &wm_node.kind
5086 {
5087 let style = wm_node.style.resolve(None, page_w);
5088 let color = style.color;
5089 let opacity = style.opacity;
5090 let angle_rad = angle.to_radians();
5091
5092 let italic =
5094 matches!(style.font_style, FontStyle::Italic | FontStyle::Oblique);
5095
5096 let shaped = self.text_layout.shape_text(
5098 font_context,
5099 text,
5100 &style.font_family,
5101 style.font_weight,
5102 style.font_style,
5103 );
5104
5105 let mut glyphs = Vec::new();
5106 let mut x_pos = 0.0;
5107 let text_chars: Vec<char> = text.chars().collect();
5108
5109 if let Some(shaped_glyphs) = shaped {
5110 let units_per_em = font_context.units_per_em(
5112 &style.font_family,
5113 style.font_weight,
5114 italic,
5115 ) as f64;
5116
5117 for sg in &shaped_glyphs {
5118 let advance = sg.x_advance as f64 / units_per_em * *font_size;
5119 let cluster_idx = sg.cluster as usize;
5120 let ch = text_chars.get(cluster_idx).copied().unwrap_or(' ');
5121 glyphs.push(PositionedGlyph {
5122 glyph_id: sg.glyph_id,
5123 char_value: ch,
5124 x_offset: x_pos,
5125 y_offset: 0.0,
5126 x_advance: advance,
5127 font_size: *font_size,
5128 font_family: style.font_family.clone(),
5129 font_weight: style.font_weight,
5130 font_style: style.font_style,
5131 color: Some(color),
5132 href: None,
5133 text_decoration: TextDecoration::None,
5134 letter_spacing: style.letter_spacing,
5135 cluster_text: None,
5136 });
5137 x_pos += advance + style.letter_spacing;
5138 }
5139 } else {
5140 for &ch in &text_chars {
5142 let w = font_context.char_width(
5143 ch,
5144 &style.font_family,
5145 style.font_weight,
5146 italic,
5147 *font_size,
5148 );
5149 glyphs.push(PositionedGlyph {
5150 glyph_id: ch as u16,
5151 char_value: ch,
5152 x_offset: x_pos,
5153 y_offset: 0.0,
5154 x_advance: w,
5155 font_size: *font_size,
5156 font_family: style.font_family.clone(),
5157 font_weight: style.font_weight,
5158 font_style: style.font_style,
5159 color: Some(color),
5160 href: None,
5161 text_decoration: TextDecoration::None,
5162 letter_spacing: style.letter_spacing,
5163 cluster_text: None,
5164 });
5165 x_pos += w + style.letter_spacing;
5166 }
5167 }
5168
5169 let text_width = x_pos;
5170
5171 let line = TextLine {
5172 x: 0.0,
5173 y: 0.0,
5174 glyphs,
5175 width: text_width,
5176 height: *font_size,
5177 word_spacing: 0.0,
5178 };
5179
5180 watermark_elements.push(LayoutElement {
5181 x: cx,
5182 y: cy,
5183 width: text_width,
5184 height: *font_size,
5185 draw: DrawCommand::Watermark {
5186 lines: vec![line],
5187 color,
5188 opacity,
5189 angle_rad,
5190 font_family: style.font_family.clone(),
5191 },
5192 children: vec![],
5193 node_type: Some("Watermark".to_string()),
5194 resolved_style: None,
5195 source_location: None,
5196 href: None,
5197 bookmark: None,
5198 alt: None,
5199 is_header_row: false,
5200 overflow: Overflow::default(),
5201 opacity: 1.0,
5202 });
5203 }
5204 }
5205
5206 watermark_elements.append(&mut page.elements);
5208 page.elements = watermark_elements;
5209 page.watermarks.clear();
5210 }
5211
5212 if page.fixed_header.is_empty() && page.fixed_footer.is_empty() {
5213 continue;
5214 }
5215
5216 if !page.fixed_header.is_empty() {
5218 let mut hdr_cursor = PageCursor::new(&page.config);
5219 for (node, _h) in &page.fixed_header {
5220 let cw = hdr_cursor.content_width;
5221 let cx = hdr_cursor.content_x;
5222 let style = node.style.resolve(None, cw);
5223 self.layout_view(
5224 node,
5225 &style,
5226 &mut hdr_cursor,
5227 &mut Vec::new(),
5228 cx,
5229 cw,
5230 font_context,
5231 );
5232 }
5233 let mut combined = hdr_cursor.elements;
5235 combined.append(&mut page.elements);
5236 page.elements = combined;
5237 }
5238
5239 if !page.fixed_footer.is_empty() {
5244 let mut ftr_cursor = PageCursor::new(&page.config);
5245 let total_ftr: f64 = page.fixed_footer.iter().map(|(_, h)| *h).sum();
5246 let target_y = ftr_cursor.content_height - total_ftr;
5247 for (node, _h) in &page.fixed_footer {
5249 let cw = ftr_cursor.content_width;
5250 let cx = ftr_cursor.content_x;
5251 let style = node.style.resolve(None, cw);
5252 self.layout_view(
5253 node,
5254 &style,
5255 &mut ftr_cursor,
5256 &mut Vec::new(),
5257 cx,
5258 cw,
5259 font_context,
5260 );
5261 }
5262 for el in &mut ftr_cursor.elements {
5266 offset_element_y(el, target_y);
5267 }
5268 page.elements.extend(ftr_cursor.elements);
5269 }
5270
5271 page.fixed_header.clear();
5273 page.fixed_footer.clear();
5274 }
5275 }
5276
5277 #[allow(clippy::too_many_arguments)]
5282 fn layout_grid_children(
5283 &self,
5284 children: &[Node],
5285 parent_style: &ResolvedStyle,
5286 cursor: &mut PageCursor,
5287 pages: &mut Vec<LayoutPage>,
5288 x: f64,
5289 available_width: f64,
5290 font_context: &FontContext,
5291 ) {
5292 let template_cols = match &parent_style.grid_template_columns {
5293 Some(cols) => cols,
5294 None => return, };
5296
5297 let num_columns = template_cols.len();
5298 if num_columns == 0 || children.is_empty() {
5299 return;
5300 }
5301
5302 let col_gap = parent_style.column_gap;
5303 let row_gap = parent_style.row_gap;
5304
5305 let content_sizes: Vec<f64> = template_cols
5308 .iter()
5309 .map(|track| {
5310 if matches!(track, GridTrackSize::Auto) {
5311 available_width / num_columns as f64
5314 } else {
5315 0.0
5316 }
5317 })
5318 .collect();
5319
5320 let col_widths =
5321 grid::resolve_tracks(template_cols, available_width, col_gap, &content_sizes);
5322
5323 let placements: Vec<Option<&GridPlacement>> = children
5325 .iter()
5326 .map(|child| child.style.grid_placement.as_ref())
5327 .collect();
5328
5329 let item_placements = grid::place_items(&placements, num_columns);
5331 let num_rows = grid::compute_num_rows(&item_placements);
5332
5333 if num_rows == 0 {
5334 return;
5335 }
5336
5337 let mut item_heights: Vec<f64> = vec![0.0; children.len()];
5339 for placement in &item_placements {
5340 let cell_width =
5341 grid::span_width(placement.col_start, placement.col_end, &col_widths, col_gap);
5342 let child = &children[placement.child_index];
5343 let child_style = child.style.resolve(Some(parent_style), cell_width);
5344 item_heights[placement.child_index] =
5345 self.measure_node_height(child, cell_width, &child_style, font_context);
5346 }
5347
5348 let template_rows = parent_style.grid_template_rows.as_deref();
5350 let mut row_heights = vec![0.0_f64; num_rows];
5351 for placement in &item_placements {
5352 let h = item_heights[placement.child_index];
5353 let span = placement.row_end - placement.row_start;
5354 let per_row = h / span as f64;
5355 for rh in row_heights
5356 .iter_mut()
5357 .take(placement.row_end.min(num_rows))
5358 .skip(placement.row_start)
5359 {
5360 if per_row > *rh {
5361 *rh = per_row;
5362 }
5363 }
5364 }
5365
5366 if let Some(template) = template_rows {
5368 let auto_row = parent_style.grid_auto_rows.as_ref();
5369 for (r, rh) in row_heights.iter_mut().enumerate() {
5370 let track = template.get(r).or(auto_row);
5371 if let Some(track) = track {
5372 match track {
5373 GridTrackSize::Pt(pts) => *rh = *pts,
5374 GridTrackSize::Auto => {} _ => {} }
5377 }
5378 }
5379 }
5380
5381 for (row, &row_height) in row_heights.iter().enumerate().take(num_rows) {
5383 if row_height > cursor.remaining_height() {
5388 pages.push(cursor.finalize());
5389 *cursor = cursor.new_page();
5390 }
5391
5392 let row_start_y = cursor.y;
5393
5394 for placement in &item_placements {
5396 if placement.row_start != row {
5397 continue; }
5399
5400 let cell_x = x + grid::column_x_offset(placement.col_start, &col_widths, col_gap);
5401 let cell_width =
5402 grid::span_width(placement.col_start, placement.col_end, &col_widths, col_gap);
5403
5404 let child = &children[placement.child_index];
5405
5406 self.layout_node(
5407 child,
5408 cursor,
5409 pages,
5410 cell_x,
5411 cell_width,
5412 Some(parent_style),
5413 font_context,
5414 None,
5415 None,
5416 );
5417 cursor.y = row_start_y;
5419 }
5420
5421 cursor.y = row_start_y + row_height + row_gap;
5422 }
5423
5424 if num_rows > 0 {
5426 cursor.y -= row_gap;
5427 }
5428 }
5429}
5430
5431struct FlexItem<'a> {
5432 node: &'a Node,
5433 style: ResolvedStyle,
5434 base_width: f64,
5435 min_content_width: f64,
5436}
5437
5438#[cfg(test)]
5439mod tests {
5440 use super::*;
5441 use crate::font::FontContext;
5442
5443 fn make_text(content: &str, font_size: f64) -> Node {
5444 Node {
5445 kind: NodeKind::Text {
5446 content: content.to_string(),
5447 href: None,
5448 runs: vec![],
5449 },
5450 style: Style {
5451 font_size: Some(font_size),
5452 ..Default::default()
5453 },
5454 children: vec![],
5455 id: None,
5456 source_location: None,
5457 bookmark: None,
5458 href: None,
5459 alt: None,
5460 }
5461 }
5462
5463 fn make_styled_view(style: Style, children: Vec<Node>) -> Node {
5464 Node {
5465 kind: NodeKind::View,
5466 style,
5467 children,
5468 id: None,
5469 source_location: None,
5470 bookmark: None,
5471 href: None,
5472 alt: None,
5473 }
5474 }
5475
5476 #[test]
5477 fn intrinsic_width_flex_row_sums_children() {
5478 let engine = LayoutEngine::new();
5479 let font_context = FontContext::new();
5480
5481 let child1 = make_text("Hello", 14.0);
5482 let child2 = make_text("World", 14.0);
5483
5484 let child1_style = child1.style.resolve(None, 0.0);
5485 let child2_style = child2.style.resolve(None, 0.0);
5486 let child1_w = engine.measure_intrinsic_width(&child1, &child1_style, &font_context);
5487 let child2_w = engine.measure_intrinsic_width(&child2, &child2_style, &font_context);
5488
5489 let row = make_styled_view(
5490 Style {
5491 flex_direction: Some(FlexDirection::Row),
5492 ..Default::default()
5493 },
5494 vec![make_text("Hello", 14.0), make_text("World", 14.0)],
5495 );
5496 let row_style = row.style.resolve(None, 0.0);
5497 let row_w = engine.measure_intrinsic_width(&row, &row_style, &font_context);
5498
5499 assert!(
5500 (row_w - (child1_w + child2_w)).abs() < 0.01,
5501 "Row intrinsic width ({}) should equal sum of children ({} + {})",
5502 row_w,
5503 child1_w,
5504 child2_w
5505 );
5506 }
5507
5508 #[test]
5509 fn intrinsic_width_flex_column_takes_max() {
5510 let engine = LayoutEngine::new();
5511 let font_context = FontContext::new();
5512
5513 let short = make_text("Hi", 14.0);
5514 let long = make_text("Hello World", 14.0);
5515
5516 let short_style = short.style.resolve(None, 0.0);
5517 let long_style = long.style.resolve(None, 0.0);
5518 let short_w = engine.measure_intrinsic_width(&short, &short_style, &font_context);
5519 let long_w = engine.measure_intrinsic_width(&long, &long_style, &font_context);
5520
5521 let col = make_styled_view(
5522 Style {
5523 flex_direction: Some(FlexDirection::Column),
5524 ..Default::default()
5525 },
5526 vec![make_text("Hi", 14.0), make_text("Hello World", 14.0)],
5527 );
5528 let col_style = col.style.resolve(None, 0.0);
5529 let col_w = engine.measure_intrinsic_width(&col, &col_style, &font_context);
5530
5531 assert!(
5532 (col_w - long_w).abs() < 0.01,
5533 "Column intrinsic width ({}) should equal max child ({}, short was {})",
5534 col_w,
5535 long_w,
5536 short_w
5537 );
5538 }
5539
5540 #[test]
5541 fn intrinsic_width_nested_containers() {
5542 let engine = LayoutEngine::new();
5543 let font_context = FontContext::new();
5544
5545 let inner = make_styled_view(
5546 Style {
5547 flex_direction: Some(FlexDirection::Row),
5548 ..Default::default()
5549 },
5550 vec![make_text("A", 12.0), make_text("B", 12.0)],
5551 );
5552 let inner_style = inner.style.resolve(None, 0.0);
5553 let inner_w = engine.measure_intrinsic_width(&inner, &inner_style, &font_context);
5554
5555 let outer = make_styled_view(
5556 Style::default(),
5557 vec![make_styled_view(
5558 Style {
5559 flex_direction: Some(FlexDirection::Row),
5560 ..Default::default()
5561 },
5562 vec![make_text("A", 12.0), make_text("B", 12.0)],
5563 )],
5564 );
5565 let outer_style = outer.style.resolve(None, 0.0);
5566 let outer_w = engine.measure_intrinsic_width(&outer, &outer_style, &font_context);
5567
5568 assert!(
5569 (outer_w - inner_w).abs() < 0.01,
5570 "Nested container ({}) should match inner container ({})",
5571 outer_w,
5572 inner_w
5573 );
5574 }
5575
5576 #[test]
5577 fn intrinsic_width_row_with_gap() {
5578 let engine = LayoutEngine::new();
5579 let font_context = FontContext::new();
5580
5581 let no_gap = make_styled_view(
5582 Style {
5583 flex_direction: Some(FlexDirection::Row),
5584 ..Default::default()
5585 },
5586 vec![make_text("A", 12.0), make_text("B", 12.0)],
5587 );
5588 let with_gap = make_styled_view(
5589 Style {
5590 flex_direction: Some(FlexDirection::Row),
5591 gap: Some(10.0),
5592 ..Default::default()
5593 },
5594 vec![make_text("A", 12.0), make_text("B", 12.0)],
5595 );
5596
5597 let no_gap_style = no_gap.style.resolve(None, 0.0);
5598 let with_gap_style = with_gap.style.resolve(None, 0.0);
5599 let no_gap_w = engine.measure_intrinsic_width(&no_gap, &no_gap_style, &font_context);
5600 let with_gap_w = engine.measure_intrinsic_width(&with_gap, &with_gap_style, &font_context);
5601
5602 assert!(
5603 (with_gap_w - no_gap_w - 10.0).abs() < 0.01,
5604 "Gap should add 10pt: with_gap={}, no_gap={}",
5605 with_gap_w,
5606 no_gap_w
5607 );
5608 }
5609
5610 #[test]
5611 fn intrinsic_width_empty_container() {
5612 let engine = LayoutEngine::new();
5613 let font_context = FontContext::new();
5614
5615 let padding = 8.0;
5616 let empty = make_styled_view(
5617 Style {
5618 padding: Some(Edges::uniform(padding)),
5619 ..Default::default()
5620 },
5621 vec![],
5622 );
5623 let style = empty.style.resolve(None, 0.0);
5624 let w = engine.measure_intrinsic_width(&empty, &style, &font_context);
5625
5626 assert!(
5627 (w - padding * 2.0).abs() < 0.01,
5628 "Empty container width ({}) should equal horizontal padding ({})",
5629 w,
5630 padding * 2.0
5631 );
5632 }
5633
5634 #[test]
5637 fn flex_shrink_respects_min_content_width() {
5638 let engine = LayoutEngine::new();
5642 let font_context = FontContext::new();
5643
5644 let sale_text = make_text("SALE", 12.0);
5645 let sale_style = sale_text.style.resolve(None, 0.0);
5646 let sale_word_width =
5647 engine.measure_min_content_width(&sale_text, &sale_style, &font_context);
5648 assert!(
5649 sale_word_width > 0.0,
5650 "SALE should have non-zero min-content width"
5651 );
5652
5653 let container = make_styled_view(
5656 Style {
5657 flex_direction: Some(FlexDirection::Row),
5658 width: Some(Dimension::Pt(100.0)),
5659 ..Default::default()
5660 },
5661 vec![
5662 make_styled_view(
5663 Style {
5664 width: Some(Dimension::Pt(80.0)),
5665 flex_shrink: Some(1.0),
5666 ..Default::default()
5667 },
5668 vec![],
5669 ),
5670 make_styled_view(
5671 Style {
5672 width: Some(Dimension::Pt(60.0)),
5673 flex_shrink: Some(1.0),
5674 ..Default::default()
5675 },
5676 vec![make_text("SALE", 12.0)],
5677 ),
5678 ],
5679 );
5680
5681 let doc = Document {
5682 children: vec![Node::page(
5683 PageConfig::default(),
5684 Style::default(),
5685 vec![container],
5686 )],
5687 metadata: Default::default(),
5688 default_page: PageConfig::default(),
5689 fonts: vec![],
5690 tagged: false,
5691 pdfa: None,
5692 default_style: None,
5693 embedded_data: None,
5694 flatten_forms: false,
5695 pdf_ua: false,
5696 certification: None,
5697 };
5698
5699 let pages = engine.layout(&doc, &font_context);
5700 assert!(!pages.is_empty());
5701
5702 let page = &pages[0];
5705 let container_el = page.elements.iter().find(|e| e.children.len() == 2);
5707 assert!(
5708 container_el.is_some(),
5709 "Should find container with 2 children"
5710 );
5711 let sale_child = &container_el.unwrap().children[1];
5712 assert!(
5713 sale_child.width >= sale_word_width - 0.01,
5714 "SALE child width ({}) should be >= min-content width ({})",
5715 sale_child.width,
5716 sale_word_width
5717 );
5718 }
5719
5720 #[test]
5723 fn column_justify_content_center() {
5724 let engine = LayoutEngine::new();
5727 let font_context = FontContext::new();
5728
5729 let container = make_styled_view(
5730 Style {
5731 flex_direction: Some(FlexDirection::Column),
5732 height: Some(Dimension::Pt(200.0)),
5733 justify_content: Some(JustifyContent::Center),
5734 ..Default::default()
5735 },
5736 vec![make_text("Centered", 12.0)],
5737 );
5738
5739 let doc = Document {
5740 children: vec![Node::page(
5741 PageConfig::default(),
5742 Style::default(),
5743 vec![container],
5744 )],
5745 metadata: Default::default(),
5746 default_page: PageConfig::default(),
5747 fonts: vec![],
5748 tagged: false,
5749 pdfa: None,
5750 default_style: None,
5751 embedded_data: None,
5752 flatten_forms: false,
5753 pdf_ua: false,
5754 certification: None,
5755 };
5756
5757 let pages = engine.layout(&doc, &font_context);
5758 let page = &pages[0];
5759
5760 let container_el = page.elements.iter().find(|e| !e.children.is_empty());
5763 assert!(
5764 container_el.is_some(),
5765 "Should find container with children"
5766 );
5767 let container_el = container_el.unwrap();
5768 let child = &container_el.children[0];
5769
5770 let child_offset = child.y - container_el.y;
5772 let expected_offset = (200.0 - child.height) / 2.0;
5773 assert!(
5774 (child_offset - expected_offset).abs() < 2.0,
5775 "Child offset ({}) should be near center ({})",
5776 child_offset,
5777 expected_offset
5778 );
5779 }
5780
5781 #[test]
5782 fn column_align_items_center() {
5783 let engine = LayoutEngine::new();
5786 let font_context = FontContext::new();
5787
5788 let container = make_styled_view(
5789 Style {
5790 flex_direction: Some(FlexDirection::Column),
5791 width: Some(Dimension::Pt(300.0)),
5792 align_items: Some(AlignItems::Center),
5793 ..Default::default()
5794 },
5795 vec![make_text("Hi", 12.0)],
5796 );
5797
5798 let doc = Document {
5799 children: vec![Node::page(
5800 PageConfig::default(),
5801 Style::default(),
5802 vec![container],
5803 )],
5804 metadata: Default::default(),
5805 default_page: PageConfig::default(),
5806 fonts: vec![],
5807 tagged: false,
5808 pdfa: None,
5809 default_style: None,
5810 embedded_data: None,
5811 flatten_forms: false,
5812 pdf_ua: false,
5813 certification: None,
5814 };
5815
5816 let pages = engine.layout(&doc, &font_context);
5817 let page = &pages[0];
5818
5819 let container_el = page.elements.iter().find(|e| !e.children.is_empty());
5820 assert!(container_el.is_some());
5821 let container_el = container_el.unwrap();
5822 let child = &container_el.children[0];
5823
5824 let child_center = child.x + child.width / 2.0;
5826 let container_center = container_el.x + container_el.width / 2.0;
5827 assert!(
5828 (child_center - container_center).abs() < 2.0,
5829 "Child center ({}) should be near container center ({})",
5830 child_center,
5831 container_center
5832 );
5833 }
5834
5835 #[test]
5838 fn absolute_child_positioned_relative_to_parent() {
5839 let engine = LayoutEngine::new();
5842 let font_context = FontContext::new();
5843
5844 let parent = make_styled_view(
5845 Style {
5846 margin: Some(MarginEdges::from_edges(Edges {
5847 top: 50.0,
5848 left: 50.0,
5849 ..Default::default()
5850 })),
5851 width: Some(Dimension::Pt(200.0)),
5852 height: Some(Dimension::Pt(200.0)),
5853 ..Default::default()
5854 },
5855 vec![make_styled_view(
5856 Style {
5857 position: Some(crate::model::Position::Absolute),
5858 top: Some(10.0),
5859 left: Some(10.0),
5860 width: Some(Dimension::Pt(50.0)),
5861 height: Some(Dimension::Pt(50.0)),
5862 ..Default::default()
5863 },
5864 vec![],
5865 )],
5866 );
5867
5868 let doc = Document {
5869 children: vec![Node::page(
5870 PageConfig::default(),
5871 Style::default(),
5872 vec![parent],
5873 )],
5874 metadata: Default::default(),
5875 default_page: PageConfig::default(),
5876 fonts: vec![],
5877 tagged: false,
5878 pdfa: None,
5879 default_style: None,
5880 embedded_data: None,
5881 flatten_forms: false,
5882 pdf_ua: false,
5883 certification: None,
5884 };
5885
5886 let pages = engine.layout(&doc, &font_context);
5887 let page = &pages[0];
5888
5889 let parent_el = page
5892 .elements
5893 .iter()
5894 .find(|e| e.width > 190.0 && e.width < 210.0);
5895 assert!(parent_el.is_some(), "Should find the 200x200 parent");
5896 let parent_el = parent_el.unwrap();
5897
5898 let abs_child = parent_el
5900 .children
5901 .iter()
5902 .find(|e| e.width > 45.0 && e.width < 55.0);
5903 assert!(abs_child.is_some(), "Should find 50x50 absolute child");
5904 let abs_child = abs_child.unwrap();
5905
5906 let expected_x = parent_el.x + 10.0;
5907 let expected_y = parent_el.y + 10.0;
5908 assert!(
5909 (abs_child.x - expected_x).abs() < 1.0,
5910 "Absolute child x ({}) should be parent.x + 10 ({})",
5911 abs_child.x,
5912 expected_x
5913 );
5914 assert!(
5915 (abs_child.y - expected_y).abs() < 1.0,
5916 "Absolute child y ({}) should be parent.y + 10 ({})",
5917 abs_child.y,
5918 expected_y
5919 );
5920 }
5921
5922 #[test]
5923 fn text_transform_none_passthrough() {
5924 assert_eq!(
5925 apply_text_transform("Hello World", TextTransform::None),
5926 "Hello World"
5927 );
5928 }
5929
5930 #[test]
5931 fn text_transform_uppercase() {
5932 assert_eq!(
5933 apply_text_transform("hello world", TextTransform::Uppercase),
5934 "HELLO WORLD"
5935 );
5936 }
5937
5938 #[test]
5939 fn text_transform_lowercase() {
5940 assert_eq!(
5941 apply_text_transform("HELLO WORLD", TextTransform::Lowercase),
5942 "hello world"
5943 );
5944 }
5945
5946 #[test]
5947 fn text_transform_capitalize() {
5948 assert_eq!(
5949 apply_text_transform("hello world", TextTransform::Capitalize),
5950 "Hello World"
5951 );
5952 assert_eq!(
5953 apply_text_transform(" hello world ", TextTransform::Capitalize),
5954 " Hello World "
5955 );
5956 assert_eq!(
5957 apply_text_transform("already Capitalized", TextTransform::Capitalize),
5958 "Already Capitalized"
5959 );
5960 }
5961
5962 #[test]
5963 fn text_transform_capitalize_empty() {
5964 assert_eq!(apply_text_transform("", TextTransform::Capitalize), "");
5965 }
5966
5967 #[test]
5968 fn apply_char_transform_uppercase() {
5969 assert_eq!(
5970 apply_char_transform('a', TextTransform::Uppercase, false),
5971 'A'
5972 );
5973 assert_eq!(
5974 apply_char_transform('A', TextTransform::Uppercase, false),
5975 'A'
5976 );
5977 }
5978
5979 #[test]
5980 fn apply_char_transform_capitalize_word_start() {
5981 assert_eq!(
5982 apply_char_transform('h', TextTransform::Capitalize, true),
5983 'H'
5984 );
5985 assert_eq!(
5986 apply_char_transform('h', TextTransform::Capitalize, false),
5987 'h'
5988 );
5989 }
5990
5991 #[test]
5994 fn column_flex_grow_single_child_fills_container() {
5995 let engine = LayoutEngine::new();
5998 let font_context = FontContext::new();
5999
6000 let child = make_styled_view(
6001 Style {
6002 flex_grow: Some(1.0),
6003 ..Default::default()
6004 },
6005 vec![make_text("Short", 12.0)],
6006 );
6007
6008 let container = make_styled_view(
6009 Style {
6010 flex_direction: Some(FlexDirection::Column),
6011 height: Some(Dimension::Pt(300.0)),
6012 ..Default::default()
6013 },
6014 vec![child],
6015 );
6016
6017 let doc = Document {
6018 children: vec![Node::page(
6019 PageConfig::default(),
6020 Style::default(),
6021 vec![container],
6022 )],
6023 metadata: Default::default(),
6024 default_page: PageConfig::default(),
6025 fonts: vec![],
6026 tagged: false,
6027 pdfa: None,
6028 default_style: None,
6029 embedded_data: None,
6030 flatten_forms: false,
6031 pdf_ua: false,
6032 certification: None,
6033 };
6034
6035 let pages = engine.layout(&doc, &font_context);
6036 let page = &pages[0];
6037
6038 let container_el = page.elements.iter().find(|e| !e.children.is_empty());
6039 assert!(container_el.is_some());
6040 let container_el = container_el.unwrap();
6041 assert!(
6042 (container_el.height - 300.0).abs() < 1.0,
6043 "Container should be 300pt, got {}",
6044 container_el.height
6045 );
6046
6047 let child_el = &container_el.children[0];
6048 assert!(
6049 (child_el.height - 300.0).abs() < 1.0,
6050 "flex-grow child should expand to 300pt, got {}",
6051 child_el.height
6052 );
6053 }
6054
6055 #[test]
6056 fn column_flex_grow_two_children_proportional() {
6057 let engine = LayoutEngine::new();
6060 let font_context = FontContext::new();
6061
6062 let child1 = make_styled_view(
6063 Style {
6064 flex_grow: Some(1.0),
6065 ..Default::default()
6066 },
6067 vec![make_text("A", 12.0)],
6068 );
6069 let child2 = make_styled_view(
6070 Style {
6071 flex_grow: Some(2.0),
6072 ..Default::default()
6073 },
6074 vec![make_text("B", 12.0)],
6075 );
6076
6077 let container = make_styled_view(
6078 Style {
6079 flex_direction: Some(FlexDirection::Column),
6080 height: Some(Dimension::Pt(300.0)),
6081 ..Default::default()
6082 },
6083 vec![child1, child2],
6084 );
6085
6086 let doc = Document {
6087 children: vec![Node::page(
6088 PageConfig::default(),
6089 Style::default(),
6090 vec![container],
6091 )],
6092 metadata: Default::default(),
6093 default_page: PageConfig::default(),
6094 fonts: vec![],
6095 tagged: false,
6096 pdfa: None,
6097 default_style: None,
6098 embedded_data: None,
6099 flatten_forms: false,
6100 pdf_ua: false,
6101 certification: None,
6102 };
6103
6104 let pages = engine.layout(&doc, &font_context);
6105 let page = &pages[0];
6106
6107 let container_el = page
6108 .elements
6109 .iter()
6110 .find(|e| e.children.len() == 2)
6111 .expect("Should find container with two children");
6112
6113 let c1 = &container_el.children[0];
6114 let c2 = &container_el.children[1];
6115
6116 let total = c1.height + c2.height;
6120 assert!(
6121 (total - 300.0).abs() < 2.0,
6122 "Children should sum to ~300pt, got {}",
6123 total
6124 );
6125
6126 let ratio = c2.height / c1.height;
6129 assert!(
6130 ratio > 1.3 && ratio < 2.5,
6131 "child2/child1 ratio should be between 1.3 and 2.5, got {}",
6132 ratio
6133 );
6134 }
6135
6136 #[test]
6137 fn column_flex_grow_mixed_grow_and_fixed() {
6138 let engine = LayoutEngine::new();
6141 let font_context = FontContext::new();
6142
6143 let fixed_child = make_styled_view(
6144 Style {
6145 height: Some(Dimension::Pt(50.0)),
6146 ..Default::default()
6147 },
6148 vec![make_text("Fixed", 12.0)],
6149 );
6150 let grow_child = make_styled_view(
6151 Style {
6152 flex_grow: Some(1.0),
6153 ..Default::default()
6154 },
6155 vec![make_text("Grow", 12.0)],
6156 );
6157
6158 let container = make_styled_view(
6159 Style {
6160 flex_direction: Some(FlexDirection::Column),
6161 height: Some(Dimension::Pt(300.0)),
6162 ..Default::default()
6163 },
6164 vec![fixed_child, grow_child],
6165 );
6166
6167 let doc = Document {
6168 children: vec![Node::page(
6169 PageConfig::default(),
6170 Style::default(),
6171 vec![container],
6172 )],
6173 metadata: Default::default(),
6174 default_page: PageConfig::default(),
6175 fonts: vec![],
6176 tagged: false,
6177 pdfa: None,
6178 default_style: None,
6179 embedded_data: None,
6180 flatten_forms: false,
6181 pdf_ua: false,
6182 certification: None,
6183 };
6184
6185 let pages = engine.layout(&doc, &font_context);
6186 let page = &pages[0];
6187
6188 let container_el = page
6189 .elements
6190 .iter()
6191 .find(|e| e.children.len() == 2)
6192 .expect("Should find container with two children");
6193
6194 let fixed_el = &container_el.children[0];
6195 let grow_el = &container_el.children[1];
6196
6197 assert!(
6199 (fixed_el.height - 50.0).abs() < 1.0,
6200 "Fixed child should stay at 50pt, got {}",
6201 fixed_el.height
6202 );
6203
6204 assert!(
6206 (grow_el.height - 250.0).abs() < 2.0,
6207 "Grow child should expand to ~250pt, got {}",
6208 grow_el.height
6209 );
6210 }
6211
6212 #[test]
6213 fn column_flex_grow_page_level() {
6214 let engine = LayoutEngine::new();
6216 let font_context = FontContext::new();
6217
6218 let grow_child = make_styled_view(
6219 Style {
6220 flex_grow: Some(1.0),
6221 ..Default::default()
6222 },
6223 vec![make_text("Fill page", 12.0)],
6224 );
6225
6226 let doc = Document {
6227 children: vec![Node::page(
6228 PageConfig::default(),
6229 Style::default(),
6230 vec![grow_child],
6231 )],
6232 metadata: Default::default(),
6233 default_page: PageConfig::default(),
6234 fonts: vec![],
6235 tagged: false,
6236 pdfa: None,
6237 default_style: None,
6238 embedded_data: None,
6239 flatten_forms: false,
6240 pdf_ua: false,
6241 certification: None,
6242 };
6243
6244 let pages = engine.layout(&doc, &font_context);
6245 let page = &pages[0];
6246
6247 assert!(
6249 !page.elements.is_empty(),
6250 "Page should have at least one element"
6251 );
6252
6253 let content_height = page.height - page.config.margin.top - page.config.margin.bottom;
6254 let el = &page.elements[0];
6255 assert!(
6256 (el.height - content_height).abs() < 2.0,
6257 "Page-level flex-grow child should fill content height ({}), got {}",
6258 content_height,
6259 el.height
6260 );
6261 }
6262
6263 #[test]
6264 fn column_flex_grow_with_justify_content() {
6265 let engine = LayoutEngine::new();
6269 let font_context = FontContext::new();
6270
6271 let fixed_child = make_styled_view(
6272 Style {
6273 height: Some(Dimension::Pt(50.0)),
6274 ..Default::default()
6275 },
6276 vec![make_text("Top", 12.0)],
6277 );
6278 let grow_child = make_styled_view(
6279 Style {
6280 flex_grow: Some(1.0),
6281 ..Default::default()
6282 },
6283 vec![make_text("Fill", 12.0)],
6284 );
6285
6286 let container = make_styled_view(
6287 Style {
6288 flex_direction: Some(FlexDirection::Column),
6289 height: Some(Dimension::Pt(300.0)),
6290 justify_content: Some(JustifyContent::Center),
6291 ..Default::default()
6292 },
6293 vec![fixed_child, grow_child],
6294 );
6295
6296 let doc = Document {
6297 children: vec![Node::page(
6298 PageConfig::default(),
6299 Style::default(),
6300 vec![container],
6301 )],
6302 metadata: Default::default(),
6303 default_page: PageConfig::default(),
6304 fonts: vec![],
6305 tagged: false,
6306 pdfa: None,
6307 default_style: None,
6308 embedded_data: None,
6309 flatten_forms: false,
6310 pdf_ua: false,
6311 certification: None,
6312 };
6313
6314 let pages = engine.layout(&doc, &font_context);
6315 let page = &pages[0];
6316
6317 let container_el = page
6318 .elements
6319 .iter()
6320 .find(|e| e.children.len() == 2)
6321 .expect("Should find container");
6322
6323 let first_child = &container_el.children[0];
6326 assert!(
6327 (first_child.y - container_el.y).abs() < 1.0,
6328 "First child should be at top of container"
6329 );
6330
6331 let total = container_el.children[0].height + container_el.children[1].height;
6333 assert!(
6334 (total - 300.0).abs() < 2.0,
6335 "Children should fill container, got {}",
6336 total
6337 );
6338 }
6339
6340 #[test]
6341 fn column_flex_grow_child_justify_content_center() {
6342 let engine = LayoutEngine::new();
6345 let font_context = FontContext::new();
6346
6347 let inner_box = make_styled_view(
6349 Style {
6350 height: Some(Dimension::Pt(40.0)),
6351 ..Default::default()
6352 },
6353 vec![make_text("Centered", 12.0)],
6354 );
6355
6356 let grow_child = make_styled_view(
6358 Style {
6359 flex_grow: Some(1.0),
6360 flex_direction: Some(FlexDirection::Column),
6361 justify_content: Some(JustifyContent::Center),
6362 ..Default::default()
6363 },
6364 vec![inner_box],
6365 );
6366
6367 let container = make_styled_view(
6369 Style {
6370 flex_direction: Some(FlexDirection::Column),
6371 height: Some(Dimension::Pt(400.0)),
6372 ..Default::default()
6373 },
6374 vec![grow_child],
6375 );
6376
6377 let doc = Document {
6378 children: vec![Node::page(
6379 PageConfig::default(),
6380 Style::default(),
6381 vec![container],
6382 )],
6383 metadata: Default::default(),
6384 default_page: PageConfig::default(),
6385 fonts: vec![],
6386 tagged: false,
6387 pdfa: None,
6388 default_style: None,
6389 embedded_data: None,
6390 flatten_forms: false,
6391 pdf_ua: false,
6392 certification: None,
6393 };
6394
6395 let pages = engine.layout(&doc, &font_context);
6396 let page = &pages[0];
6397
6398 let container_el = page
6400 .elements
6401 .iter()
6402 .find(|e| e.height > 350.0 && e.children.len() == 1)
6403 .expect("Should find outer container");
6404
6405 let grow_el = &container_el.children[0];
6406 assert!(
6407 (grow_el.height - 400.0).abs() < 2.0,
6408 "Grow child should expand to 400, got {}",
6409 grow_el.height
6410 );
6411
6412 let inner_el = &grow_el.children[0];
6414 let expected_center = grow_el.y + grow_el.height / 2.0;
6415 let actual_center = inner_el.y + inner_el.height / 2.0;
6416 assert!(
6417 (actual_center - expected_center).abs() < 2.0,
6418 "Inner box should be vertically centered. Expected center ~{}, got ~{}",
6419 expected_center,
6420 actual_center
6421 );
6422 }
6423
6424 #[test]
6425 fn column_flex_grow_child_justify_content_flex_end() {
6426 let engine = LayoutEngine::new();
6428 let font_context = FontContext::new();
6429
6430 let inner_box = make_styled_view(
6431 Style {
6432 height: Some(Dimension::Pt(30.0)),
6433 ..Default::default()
6434 },
6435 vec![make_text("Bottom", 12.0)],
6436 );
6437
6438 let grow_child = make_styled_view(
6439 Style {
6440 flex_grow: Some(1.0),
6441 flex_direction: Some(FlexDirection::Column),
6442 justify_content: Some(JustifyContent::FlexEnd),
6443 ..Default::default()
6444 },
6445 vec![inner_box],
6446 );
6447
6448 let container = make_styled_view(
6449 Style {
6450 flex_direction: Some(FlexDirection::Column),
6451 height: Some(Dimension::Pt(300.0)),
6452 ..Default::default()
6453 },
6454 vec![grow_child],
6455 );
6456
6457 let doc = Document {
6458 children: vec![Node::page(
6459 PageConfig::default(),
6460 Style::default(),
6461 vec![container],
6462 )],
6463 metadata: Default::default(),
6464 default_page: PageConfig::default(),
6465 fonts: vec![],
6466 tagged: false,
6467 pdfa: None,
6468 default_style: None,
6469 embedded_data: None,
6470 flatten_forms: false,
6471 pdf_ua: false,
6472 certification: None,
6473 };
6474
6475 let pages = engine.layout(&doc, &font_context);
6476 let page = &pages[0];
6477
6478 let container_el = page
6479 .elements
6480 .iter()
6481 .find(|e| e.height > 250.0 && e.children.len() == 1)
6482 .expect("Should find outer container");
6483
6484 let grow_el = &container_el.children[0];
6485 let inner_el = &grow_el.children[0];
6486
6487 let inner_bottom = inner_el.y + inner_el.height;
6489 let grow_bottom = grow_el.y + grow_el.height;
6490 assert!(
6491 (inner_bottom - grow_bottom).abs() < 2.0,
6492 "Inner box bottom ({}) should align with grow child bottom ({})",
6493 inner_bottom,
6494 grow_bottom
6495 );
6496 }
6497
6498 #[test]
6499 fn column_flex_grow_child_no_justify_unchanged() {
6500 let engine = LayoutEngine::new();
6502 let font_context = FontContext::new();
6503
6504 let inner_box = make_styled_view(
6505 Style {
6506 height: Some(Dimension::Pt(50.0)),
6507 ..Default::default()
6508 },
6509 vec![make_text("Top", 12.0)],
6510 );
6511
6512 let grow_child = make_styled_view(
6513 Style {
6514 flex_grow: Some(1.0),
6515 flex_direction: Some(FlexDirection::Column),
6516 ..Default::default()
6518 },
6519 vec![inner_box],
6520 );
6521
6522 let container = make_styled_view(
6523 Style {
6524 flex_direction: Some(FlexDirection::Column),
6525 height: Some(Dimension::Pt(300.0)),
6526 ..Default::default()
6527 },
6528 vec![grow_child],
6529 );
6530
6531 let doc = Document {
6532 children: vec![Node::page(
6533 PageConfig::default(),
6534 Style::default(),
6535 vec![container],
6536 )],
6537 metadata: Default::default(),
6538 default_page: PageConfig::default(),
6539 fonts: vec![],
6540 tagged: false,
6541 pdfa: None,
6542 default_style: None,
6543 embedded_data: None,
6544 flatten_forms: false,
6545 pdf_ua: false,
6546 certification: None,
6547 };
6548
6549 let pages = engine.layout(&doc, &font_context);
6550 let page = &pages[0];
6551
6552 let container_el = page
6553 .elements
6554 .iter()
6555 .find(|e| e.height > 250.0 && e.children.len() == 1)
6556 .expect("Should find outer container");
6557
6558 let grow_el = &container_el.children[0];
6559 let inner_el = &grow_el.children[0];
6560
6561 assert!(
6563 (inner_el.y - grow_el.y).abs() < 2.0,
6564 "Inner box ({}) should be at top of grow child ({})",
6565 inner_el.y,
6566 grow_el.y
6567 );
6568 }
6569
6570 #[test]
6571 fn column_flex_grow_child_align_items_center() {
6572 let engine = LayoutEngine::new();
6574 let font_context = FontContext::new();
6575
6576 let text = make_text("Hello", 12.0);
6577
6578 let grow_child = make_styled_view(
6579 Style {
6580 flex_grow: Some(1.0),
6581 flex_direction: Some(FlexDirection::Column),
6582 align_items: Some(AlignItems::Center),
6583 ..Default::default()
6584 },
6585 vec![text],
6586 );
6587
6588 let container = make_styled_view(
6589 Style {
6590 flex_direction: Some(FlexDirection::Column),
6591 height: Some(Dimension::Pt(300.0)),
6592 ..Default::default()
6593 },
6594 vec![grow_child],
6595 );
6596
6597 let doc = Document {
6598 children: vec![Node::page(
6599 PageConfig::default(),
6600 Style::default(),
6601 vec![container],
6602 )],
6603 metadata: Default::default(),
6604 default_page: PageConfig::default(),
6605 fonts: vec![],
6606 tagged: false,
6607 pdfa: None,
6608 default_style: None,
6609 embedded_data: None,
6610 flatten_forms: false,
6611 pdf_ua: false,
6612 certification: None,
6613 };
6614
6615 let pages = engine.layout(&doc, &font_context);
6616 let page = &pages[0];
6617
6618 let container_el = page
6619 .elements
6620 .iter()
6621 .find(|e| e.height > 250.0 && e.children.len() == 1)
6622 .expect("Should find outer container");
6623
6624 let grow_el = &container_el.children[0];
6625 assert!(
6626 !grow_el.children.is_empty(),
6627 "Grow child should have text child"
6628 );
6629
6630 let text_el = &grow_el.children[0];
6631 let text_center = text_el.x + text_el.width / 2.0;
6632 let grow_center = grow_el.x + grow_el.width / 2.0;
6633 assert!(
6634 (text_center - grow_center).abs() < 2.0,
6635 "Text center ({}) should be near grow child center ({})",
6636 text_center,
6637 grow_center
6638 );
6639 }
6640
6641 #[test]
6642 fn image_intrinsic_width_respects_height_constraint() {
6643 let engine = LayoutEngine::new();
6647 let font_context = FontContext::new();
6648
6649 let one_px_png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
6651
6652 let image_node = Node {
6653 kind: NodeKind::Image {
6654 src: one_px_png.to_string(),
6655 width: None,
6656 height: Some(36.0),
6657 },
6658 style: Style::default(),
6659 children: vec![],
6660 id: None,
6661 source_location: None,
6662 bookmark: None,
6663 href: None,
6664 alt: None,
6665 };
6666
6667 let resolved = image_node.style.resolve(None, 0.0);
6668 let intrinsic = engine.measure_intrinsic_width(&image_node, &resolved, &font_context);
6669
6670 assert!(
6672 (intrinsic - 36.0).abs() < 1.0,
6673 "Intrinsic width should be ~36 for 1:1 aspect image with height 36, got {}",
6674 intrinsic
6675 );
6676 }
6677}