Skip to main content

oxml_layout/
output.rs

1//! Output types for the layout engine: positioned page frames, glyph runs, etc.
2
3use std::num::NonZeroU32;
4use std::sync::Arc;
5
6use crate::paint::{Paint, Stroke};
7use crate::path::Path;
8use crate::transform::Transform;
9
10/// A point in 2D space (in typographic points from the top-left corner).
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub struct Point {
13    pub x: f64,
14    pub y: f64,
15}
16
17/// An axis-aligned rectangle.
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub struct Rect {
20    pub x: f64,
21    pub y: f64,
22    pub width: f64,
23    pub height: f64,
24}
25
26/// An RGBA color with components in [0.0, 1.0].
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct Color {
29    pub r: f64,
30    pub g: f64,
31    pub b: f64,
32    pub a: f64,
33}
34
35impl Color {
36    pub const BLACK: Color = Color {
37        r: 0.0,
38        g: 0.0,
39        b: 0.0,
40        a: 1.0,
41    };
42    pub const WHITE: Color = Color {
43        r: 1.0,
44        g: 1.0,
45        b: 1.0,
46        a: 1.0,
47    };
48
49    /// Parse a hex color string like "FF0000" to Color.
50    pub fn from_hex(hex: &str) -> Self {
51        let hex = hex.trim_start_matches('#');
52        if hex.len() >= 6 {
53            let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0) as f64 / 255.0;
54            let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0) as f64 / 255.0;
55            let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0) as f64 / 255.0;
56            Color { r, g, b, a: 1.0 }
57        } else {
58            Color::BLACK
59        }
60    }
61}
62
63/// Opaque font identifier assigned by FontManager.
64///
65/// Ordered, so a backend keying a map on it can iterate in a fixed order rather
66/// than a hashed one. The PDF writer does exactly that, and the order it
67/// iterates in reaches the bytes it writes.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
69pub struct FontId(pub u32);
70
71/// Stable content-addressed media key for renderer-local reuse.
72///
73/// This compact key is not a collision-free content guarantee.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
75pub struct MediaId(pub u64);
76
77impl MediaId {
78    /// Derive a stable key from raw media bytes using 64-bit FNV-1a.
79    pub fn from_bytes(bytes: &[u8]) -> Self {
80        let mut hash = 0xcbf2_9ce4_8422_2325_u64;
81        for byte in bytes {
82            hash ^= u64::from(*byte);
83            hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
84        }
85        Self(hash)
86    }
87}
88
89/// Result-local identity of one format-specific source node.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
91pub struct SourceNodeId(NonZeroU32);
92
93impl SourceNodeId {
94    /// Construct an identity from its one-based side-table index.
95    pub const fn new(value: u32) -> Option<Self> {
96        match NonZeroU32::new(value) {
97            Some(value) => Some(Self(value)),
98            None => None,
99        }
100    }
101
102    /// Return the one-based side-table index.
103    pub const fn get(self) -> u32 {
104        self.0.get()
105    }
106}
107
108/// Exclusive Unicode-scalar range within one source node.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub struct SourceSpan {
111    pub node: SourceNodeId,
112    pub char_start: u32,
113    pub char_end: u32,
114}
115
116/// Stable result-local identity of one logical structure element.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
118pub struct StructureId(NonZeroU32);
119
120impl StructureId {
121    /// Construct an identity from its one-based node index.
122    pub const fn new(value: u32) -> Option<Self> {
123        match NonZeroU32::new(value) {
124            Some(value) => Some(Self(value)),
125            None => None,
126        }
127    }
128
129    /// Return the one-based node index.
130    pub const fn get(self) -> u32 {
131        self.0.get()
132    }
133}
134
135/// Backend-neutral semantic role for one logical structure element.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137#[non_exhaustive]
138pub enum StructureRole {
139    Document,
140    Paragraph,
141    Heading(u8),
142    List,
143    ListItem,
144    Table,
145    TableRow,
146    TableHeaderCell,
147    TableCell,
148    Figure,
149}
150
151/// One node in the result-local logical structure tree.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct StructureNode {
154    pub id: StructureId,
155    pub role: StructureRole,
156    pub children: Vec<StructureId>,
157    pub alternate_text: Option<String>,
158}
159
160/// Backend-neutral logical structure for a laid-out document.
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct DocumentStructure {
163    pub root: StructureId,
164    pub nodes: Vec<StructureNode>,
165}
166
167impl DocumentStructure {
168    /// Resolve a result-local structure identity.
169    pub fn node(&self, id: StructureId) -> Option<&StructureNode> {
170        self.nodes
171            .get(id.get() as usize - 1)
172            .filter(|node| node.id == id)
173    }
174}
175
176/// Kind of field for post-pagination substitution.
177///
178/// Target carriers stay format-neutral at this shared layout boundary.
179///
180/// ```
181/// use oxml_layout::FieldKind;
182///
183/// assert_eq!(FieldKind::TargetPage(3), FieldKind::TargetPage(3));
184/// assert_eq!(FieldKind::Target(3), FieldKind::Target(3));
185/// ```
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum FieldKind {
188    /// Current page number.
189    Page,
190    /// Total number of pages.
191    NumPages,
192    /// Page containing a target.
193    TargetPage(usize),
194    /// Zero-width target position retained until page locations are collected.
195    Target(usize),
196}
197
198/// A positioned run of shaped glyphs.
199#[derive(Debug, Clone, PartialEq)]
200pub struct GlyphRun {
201    /// Baseline origin of the first glyph (in points).
202    pub origin: Point,
203    /// Font identifier (from FontManager).
204    pub font_id: FontId,
205    /// Font size in points.
206    pub font_size: f64,
207    /// Shaped glyph IDs.
208    pub glyph_ids: Vec<u16>,
209    /// Per-glyph advances in points.
210    pub advances: Vec<f64>,
211    /// Original text (for PDF ToUnicode mapping).
212    pub text: String,
213    /// Exact source range for this run, when it is a direct text projection.
214    pub source: Option<SourceSpan>,
215    /// Text color.
216    pub color: Color,
217    /// Whether the font is bold.
218    pub bold: bool,
219    /// Whether the font is italic.
220    pub italic: bool,
221    /// If this glyph run is a field placeholder, the kind of field.
222    pub field_kind: Option<FieldKind>,
223    /// If this glyph run is a footnote/endnote reference marker, its ID.
224    pub note: Option<crate::line::NoteRef>,
225}
226
227/// A positioned multilingual span with complete two-axis glyph positioning.
228#[derive(Debug, Clone, PartialEq)]
229pub struct MultilingualGlyphRun {
230    pub origin: Point,
231    pub font_id: FontId,
232    pub font_size: f64,
233    pub glyph_ids: Vec<u16>,
234    pub x_advances: Vec<f64>,
235    pub y_advances: Vec<f64>,
236    pub x_offsets: Vec<f64>,
237    pub y_offsets: Vec<f64>,
238    pub clusters: Vec<crate::font::GlyphCluster>,
239    pub logical_text: String,
240    pub logical_index: usize,
241    pub source: Option<SourceSpan>,
242    pub script: crate::font::TextScript,
243    pub language: Option<String>,
244    pub direction: crate::font::TextDirection,
245    pub bidi_level: u8,
246    pub color: Color,
247    pub bold: bool,
248    pub italic: bool,
249    pub field_kind: Option<FieldKind>,
250    pub note: Option<crate::line::NoteRef>,
251}
252
253impl MultilingualGlyphRun {
254    /// Whether all glyph-position and logical-cluster vectors form one complete run.
255    pub fn is_valid(&self) -> bool {
256        let glyph_count = self.glyph_ids.len();
257        self.x_advances.len() == glyph_count
258            && self.y_advances.len() == glyph_count
259            && self.x_offsets.len() == glyph_count
260            && self.y_offsets.len() == glyph_count
261            && unicode_bidi::Level::new(self.bidi_level).is_ok()
262            && crate::font::position_values_are_finite(
263                &self.x_advances,
264                &self.y_advances,
265                &self.x_offsets,
266                &self.y_offsets,
267                self.x_advances.iter().sum(),
268            )
269            && crate::font::cluster_ranges_are_valid(
270                &self.clusters,
271                glyph_count,
272                self.logical_text.chars().count(),
273                self.bidi_level % 2 == 1,
274            )
275    }
276
277    /// Return a legacy horizontal projection for backends that cannot consume
278    /// the richer positioning and cluster metadata directly.
279    pub fn legacy_projection(&self) -> GlyphRun {
280        GlyphRun {
281            origin: self.origin,
282            font_id: self.font_id,
283            font_size: self.font_size,
284            glyph_ids: self.glyph_ids.clone(),
285            advances: self.x_advances.clone(),
286            text: self.logical_text.clone(),
287            source: self.source,
288            color: self.color,
289            bold: self.bold,
290            italic: self.italic,
291            field_kind: self.field_kind,
292            note: self.note,
293        }
294    }
295}
296
297/// A positioned element on a page.
298#[derive(Debug, Clone, PartialEq)]
299#[non_exhaustive]
300pub enum PositionedElement {
301    /// A run of shaped text glyphs.
302    Text(GlyphRun),
303    /// A visually positioned span that retains logical text and clusters.
304    MultilingualText(MultilingualGlyphRun),
305    /// A line segment (for borders, underlines, strikethrough).
306    Line {
307        start: Point,
308        end: Point,
309        width: f64,
310        color: Color,
311        /// Optional dash pattern (dash_on, dash_off) in points. None = solid line.
312        dash_pattern: Option<(f64, f64)>,
313    },
314    /// A filled rectangle (for shading, highlights).
315    FilledRect { rect: Rect, color: Color },
316    /// An inline image.
317    Image {
318        rect: Rect,
319        data: Vec<u8>,
320        content_type: String,
321        media_id: MediaId,
322    },
323    /// A link annotation (hyperlink).
324    LinkAnnotation { rect: Rect, url: String },
325    /// A backend-neutral filled or stroked path.
326    Path(PathElement),
327    /// A nested group with one child-local transform.
328    Group(GroupElement),
329    /// Non-drawing semantic ownership for a sequence of positioned elements.
330    ///
331    /// `structure` is `None` for an artifact that must not enter the logical
332    /// reading order.
333    MarkedContent {
334        structure: Option<StructureId>,
335        children: Vec<PositionedElement>,
336    },
337}
338
339/// One path with optional fill and stroke paints.
340#[derive(Debug, Clone, PartialEq)]
341pub struct PathElement {
342    pub path: Path,
343    pub fill: Option<Paint>,
344    pub stroke: Option<Stroke>,
345}
346
347/// A rendering approximation or fallback message.
348#[derive(Debug, Clone, PartialEq)]
349pub struct Diagnostic {
350    pub message: String,
351}
352
353/// An effect applied to a group.
354#[derive(Debug, Clone, PartialEq)]
355#[non_exhaustive]
356pub enum Effect {
357    OuterShadow {
358        dx: f64,
359        dy: f64,
360        blur: f64,
361        color: Color,
362    },
363}
364
365/// A group of positioned children in one local coordinate system.
366#[derive(Debug, Clone, PartialEq)]
367pub struct GroupElement {
368    /// Maps child-local coordinates into the parent coordinate system.
369    pub transform: Transform,
370    pub clip: Option<Path>,
371    pub opacity: f64,
372    pub effects: Vec<Effect>,
373    pub children: Vec<PositionedElement>,
374}
375
376/// Visit every non-group element in depth-first document order.
377pub fn walk(elements: &[PositionedElement], f: &mut impl FnMut(&PositionedElement, &Transform)) {
378    fn visit(
379        elements: &[PositionedElement],
380        accumulated: Transform,
381        f: &mut dyn FnMut(&PositionedElement, &Transform),
382    ) {
383        for element in elements {
384            match element {
385                PositionedElement::Group(group) => {
386                    let child_to_page = group.transform.then(accumulated);
387                    visit(&group.children, child_to_page, f);
388                }
389                PositionedElement::MarkedContent { children, .. } => {
390                    visit(children, accumulated, f);
391                }
392                leaf => f(leaf, &accumulated),
393            }
394        }
395    }
396
397    visit(elements, Transform::IDENTITY, f);
398}
399
400/// A single page of laid-out content.
401#[derive(Debug, Clone)]
402#[non_exhaustive]
403pub struct PageFrame {
404    /// 1-based page number.
405    pub page_number: usize,
406    /// Page width in points.
407    pub width: f64,
408    /// Page height in points.
409    pub height: f64,
410    /// All positioned elements on this page.
411    pub elements: Vec<PositionedElement>,
412    /// Optional paint behind every page element.
413    pub background: Option<Paint>,
414}
415
416impl PageFrame {
417    /// Construct a page with no background paint.
418    ///
419    /// ```
420    /// use oxml_layout::PageFrame;
421    ///
422    /// let page = PageFrame::new(1, 612.0, 792.0, Vec::new());
423    /// assert!(page.background.is_none());
424    /// ```
425    pub fn new(
426        page_number: usize,
427        width: f64,
428        height: f64,
429        elements: Vec<PositionedElement>,
430    ) -> Self {
431        Self {
432            page_number,
433            width,
434            height,
435            elements,
436            background: None,
437        }
438    }
439}
440
441/// Font data for embedding in PDF output.
442#[derive(Debug, Clone)]
443pub struct FontData {
444    /// Font identifier.
445    pub id: FontId,
446    /// Font family name.
447    pub family: String,
448    /// Raw TTF/OTF bytes for PDF embedding.
449    pub data: Arc<[u8]>,
450    /// Face index within a font collection.
451    pub face_index: u32,
452    /// Whether this is a bold variant.
453    pub bold: bool,
454    /// Whether this is an italic variant.
455    pub italic: bool,
456}
457
458/// Document metadata to pass through to PDF output.
459#[derive(Debug, Clone, Default)]
460pub struct DocumentMetadata {
461    /// Document title.
462    pub title: Option<String>,
463    /// Document author.
464    pub author: Option<String>,
465    /// Document subject.
466    pub subject: Option<String>,
467    /// Document keywords.
468    pub keywords: Option<String>,
469    /// Creator application.
470    pub creator: Option<String>,
471}
472
473/// An outline/bookmark entry for PDF generation.
474#[derive(Debug, Clone)]
475pub struct OutlineEntry {
476    /// The heading text.
477    pub title: String,
478    /// Heading level (1 for Heading1, 2 for Heading2, etc.).
479    pub level: u32,
480    /// 0-based page index this heading appears on.
481    pub page_index: usize,
482    /// Y position on the page (in points from top).
483    pub y_position: f64,
484}
485
486/// The complete result of laying out a document.
487#[derive(Debug, Clone)]
488#[non_exhaustive]
489pub struct LayoutResult {
490    /// Laid-out pages.
491    pub pages: Vec<Arc<PageFrame>>,
492    /// Font data for all fonts used.
493    pub fonts: Vec<FontData>,
494    /// Optional document metadata for PDF output.
495    pub metadata: Option<DocumentMetadata>,
496    /// Outline/bookmark entries from headings.
497    pub outlines: Vec<OutlineEntry>,
498    /// Rendering approximations and fallbacks collected during layout.
499    pub diagnostics: Vec<Diagnostic>,
500    /// Logical structure for tagged PDF output, when the producer has source
501    /// semantics to preserve.
502    pub structure: Option<DocumentStructure>,
503}
504
505impl LayoutResult {
506    /// Construct a result with no diagnostics.
507    ///
508    /// ```
509    /// use oxml_layout::LayoutResult;
510    ///
511    /// let result = LayoutResult::new(Vec::new(), Vec::new(), None, Vec::new());
512    /// assert!(result.diagnostics.is_empty());
513    /// ```
514    pub fn new(
515        pages: Vec<Arc<PageFrame>>,
516        fonts: Vec<FontData>,
517        metadata: Option<DocumentMetadata>,
518        outlines: Vec<OutlineEntry>,
519    ) -> Self {
520        Self {
521            pages,
522            fonts,
523            metadata,
524            outlines,
525            diagnostics: Vec::new(),
526            structure: None,
527        }
528    }
529}
530
531#[cfg(test)]
532mod media_id_tests {
533    use std::collections::HashSet;
534
535    use super::{MediaId, PositionedElement, Rect};
536
537    #[test]
538    fn the_same_image_bytes_inserted_twice_produce_one_media_id() {
539        let ids = HashSet::from([
540            MediaId::from_bytes(b"same image"),
541            MediaId::from_bytes(b"same image"),
542        ]);
543        assert_eq!(ids.len(), 1);
544    }
545
546    #[test]
547    fn media_id_depends_on_bytes_not_relationship_context() {
548        assert_eq!(
549            MediaId::from_bytes(b"image bytes"),
550            MediaId::from_bytes(b"image bytes")
551        );
552    }
553
554    #[test]
555    fn different_image_bytes_have_different_fixture_ids() {
556        assert_ne!(
557            MediaId::from_bytes(b"first image"),
558            MediaId::from_bytes(b"second image")
559        );
560    }
561
562    #[test]
563    fn staged_output_image_uses_media_id_instead_of_embed_id() {
564        let media_id = MediaId::from_bytes(b"image bytes");
565        let image = PositionedElement::Image {
566            rect: Rect {
567                x: 0.0,
568                y: 0.0,
569                width: 10.0,
570                height: 20.0,
571            },
572            data: b"image bytes".to_vec(),
573            content_type: "image/png".to_owned(),
574            media_id,
575        };
576        let PositionedElement::Image {
577            media_id: actual, ..
578        } = image
579        else {
580            panic!("constructed image should remain an image");
581        };
582        assert_eq!(actual, media_id);
583    }
584}
585
586#[cfg(test)]
587mod tagged_pdf_start_feature_tests {
588    use super::{
589        Color, DocumentStructure, PositionedElement, Rect, StructureId, StructureNode,
590        StructureRole, walk,
591    };
592    use crate::Transform;
593
594    #[test]
595    fn marked_content_is_backend_neutral_and_non_drawing() {
596        let root = StructureId::new(1).expect("non-zero root");
597        let paragraph = StructureId::new(2).expect("non-zero paragraph");
598        let structure = DocumentStructure {
599            root,
600            nodes: vec![
601                StructureNode {
602                    id: root,
603                    role: StructureRole::Document,
604                    children: vec![paragraph],
605                    alternate_text: None,
606                },
607                StructureNode {
608                    id: paragraph,
609                    role: StructureRole::Paragraph,
610                    children: Vec::new(),
611                    alternate_text: None,
612                },
613            ],
614        };
615        assert_eq!(
616            structure.node(paragraph).map(|node| node.role),
617            Some(StructureRole::Paragraph)
618        );
619
620        let elements = vec![PositionedElement::MarkedContent {
621            structure: Some(paragraph),
622            children: vec![PositionedElement::FilledRect {
623                rect: Rect {
624                    x: 1.0,
625                    y: 2.0,
626                    width: 3.0,
627                    height: 4.0,
628                },
629                color: Color::BLACK,
630            }],
631        }];
632        let mut leaves = 0;
633        walk(&elements, &mut |element, transform| {
634            assert!(matches!(element, PositionedElement::FilledRect { .. }));
635            assert_eq!(*transform, Transform::IDENTITY);
636            leaves += 1;
637        });
638        assert_eq!(leaves, 1);
639    }
640}
641
642#[cfg(test)]
643mod group_output_tests {
644    use std::sync::Arc;
645
646    use super::{
647        Color, Diagnostic, Effect, FontData, FontId, GroupElement, LayoutResult, PageFrame,
648        PathElement, PositionedElement, Rect,
649    };
650    use crate::{FillRule, Paint, Path, Stroke, Transform};
651
652    #[test]
653    fn path_and_group_arms_preserve_their_payloads() {
654        let path = Path::rect(Rect {
655            x: 1.0,
656            y: 2.0,
657            width: 3.0,
658            height: 4.0,
659        });
660        let path_element = PathElement {
661            path: path.clone(),
662            fill: Some(Paint::Solid(Color::BLACK)),
663            stroke: Some(Stroke::new(Paint::Solid(Color::WHITE), 2.0)),
664        };
665        let element = PositionedElement::Path(path_element.clone());
666        assert!(matches!(
667            element,
668            PositionedElement::Path(actual) if actual == path_element
669        ));
670
671        let transform = Transform::rotate_about(15.0, 2.0, 3.0);
672        let clip = Path {
673            commands: Vec::new(),
674            fill_rule: FillRule::EvenOdd,
675        };
676        let effect = Effect::OuterShadow {
677            dx: 1.0,
678            dy: 2.0,
679            blur: 3.0,
680            color: Color::BLACK,
681        };
682        let child_rect = Rect {
683            x: 5.0,
684            y: 6.0,
685            width: 7.0,
686            height: 8.0,
687        };
688        let group = GroupElement {
689            transform,
690            clip: Some(clip.clone()),
691            opacity: 0.5,
692            effects: vec![effect.clone()],
693            children: vec![PositionedElement::FilledRect {
694                rect: child_rect,
695                color: Color::WHITE,
696            }],
697        };
698        let element = PositionedElement::Group(group);
699        let PositionedElement::Group(actual) = element else {
700            panic!("constructed group should remain a group");
701        };
702        assert_eq!(actual.transform, transform);
703        assert_eq!(actual.clip, Some(clip));
704        assert_eq!(actual.opacity, 0.5);
705        assert_eq!(actual.effects, vec![effect]);
706        assert!(matches!(
707            actual.children.as_slice(),
708            [PositionedElement::FilledRect { rect, color }]
709                if *rect == child_rect && *color == Color::WHITE
710        ));
711    }
712
713    #[test]
714    fn page_frame_new_defaults_background_to_none() {
715        let page = PageFrame::new(1, 612.0, 792.0, Vec::new());
716        assert_eq!(page.page_number, 1);
717        assert_eq!(page.background, None);
718    }
719
720    #[test]
721    fn layout_result_new_defaults_diagnostics_to_empty() {
722        let result = LayoutResult::new(Vec::new(), Vec::new(), None, Vec::new());
723        assert_eq!(result.diagnostics, Vec::<Diagnostic>::new());
724    }
725
726    #[test]
727    fn cloned_layout_shares_font_bytes_and_page_frames() {
728        let page = Arc::new(PageFrame::new(1, 612.0, 792.0, Vec::new()));
729        let font_bytes: Arc<[u8]> = Arc::from([1, 2, 3, 4]);
730        let result = LayoutResult::new(
731            vec![Arc::clone(&page)],
732            vec![FontData {
733                id: FontId(1),
734                family: "Shared".to_owned(),
735                data: Arc::clone(&font_bytes),
736                face_index: 0,
737                bold: false,
738                italic: false,
739            }],
740            None,
741            Vec::new(),
742        );
743
744        let cloned = result.clone();
745        assert!(Arc::ptr_eq(&result.pages[0], &cloned.pages[0]));
746        assert!(Arc::ptr_eq(&result.fonts[0].data, &cloned.fonts[0].data));
747        assert_eq!(result.pages[0].page_number, cloned.pages[0].page_number);
748        assert_eq!(result.fonts[0].data.as_ref(), cloned.fonts[0].data.as_ref());
749    }
750
751    #[test]
752    fn group_transform_maps_child_coordinates_into_parent_coordinates() {
753        let child_to_parent = Transform {
754            a: 1.0,
755            b: 0.0,
756            c: 0.0,
757            d: 1.0,
758            e: 10.0,
759            f: 20.0,
760        };
761        let group = GroupElement {
762            transform: child_to_parent,
763            clip: None,
764            opacity: 1.0,
765            effects: Vec::new(),
766            children: Vec::new(),
767        };
768        assert_eq!(
769            group.transform.apply(super::Point { x: 1.0, y: 2.0 }),
770            super::Point { x: 11.0, y: 22.0 }
771        );
772    }
773}
774
775#[cfg(test)]
776mod walk_tests {
777    use super::{Color, GroupElement, PositionedElement, Rect, walk};
778    use crate::{Point, Transform};
779
780    fn translate(x: f64, y: f64) -> Transform {
781        Transform {
782            e: x,
783            f: y,
784            ..Transform::IDENTITY
785        }
786    }
787
788    fn scale(value: f64) -> Transform {
789        Transform {
790            a: value,
791            d: value,
792            ..Transform::IDENTITY
793        }
794    }
795
796    fn leaf(id: f64) -> PositionedElement {
797        PositionedElement::FilledRect {
798            rect: Rect {
799                x: id,
800                y: 0.0,
801                width: 1.0,
802                height: 1.0,
803            },
804            color: Color::BLACK,
805        }
806    }
807
808    #[test]
809    fn three_deep_groups_yield_every_leaf_once_with_the_correct_accumulated_transform() {
810        let elements = vec![
811            leaf(1.0),
812            PositionedElement::Group(GroupElement {
813                transform: translate(10.0, 0.0),
814                clip: None,
815                opacity: 1.0,
816                effects: Vec::new(),
817                children: vec![PositionedElement::Group(GroupElement {
818                    transform: scale(2.0),
819                    clip: None,
820                    opacity: 1.0,
821                    effects: Vec::new(),
822                    children: vec![PositionedElement::Group(GroupElement {
823                        transform: translate(0.0, 5.0),
824                        clip: None,
825                        opacity: 1.0,
826                        effects: Vec::new(),
827                        children: vec![leaf(2.0)],
828                    })],
829                })],
830            }),
831            leaf(3.0),
832        ];
833        let mut visited = Vec::new();
834        walk(&elements, &mut |element, transform| {
835            let PositionedElement::FilledRect { rect, .. } = element else {
836                panic!("walk should yield leaves only");
837            };
838            visited.push((rect.x, transform.apply(Point { x: 1.0, y: 1.0 })));
839        });
840        assert_eq!(
841            visited,
842            vec![
843                (1.0, Point { x: 1.0, y: 1.0 }),
844                (2.0, Point { x: 12.0, y: 12.0 }),
845                (3.0, Point { x: 1.0, y: 1.0 }),
846            ]
847        );
848    }
849
850    #[test]
851    fn nested_group_transform_order_applies_child_before_parent() {
852        let group = PositionedElement::Group(GroupElement {
853            transform: translate(10.0, 0.0),
854            clip: None,
855            opacity: 1.0,
856            effects: Vec::new(),
857            children: vec![PositionedElement::Group(GroupElement {
858                transform: scale(2.0),
859                clip: None,
860                opacity: 1.0,
861                effects: Vec::new(),
862                children: vec![leaf(1.0)],
863            })],
864        });
865        let mut points = Vec::new();
866        walk(&[group], &mut |_, transform| {
867            points.push(transform.apply(Point { x: 1.0, y: 1.0 }));
868        });
869        assert_eq!(points, vec![Point { x: 12.0, y: 2.0 }]);
870    }
871
872    #[test]
873    fn walk_does_not_yield_group_nodes() {
874        let group = PositionedElement::Group(GroupElement {
875            transform: Transform::IDENTITY,
876            clip: None,
877            opacity: 1.0,
878            effects: Vec::new(),
879            children: vec![leaf(1.0)],
880        });
881        walk(&[group], &mut |element, _| {
882            assert!(!matches!(element, PositionedElement::Group(_)));
883        });
884    }
885
886    #[test]
887    fn walk_passes_identity_for_root_leaves() {
888        walk(&[leaf(1.0)], &mut |_, transform| {
889            assert_eq!(*transform, Transform::IDENTITY);
890        });
891    }
892}