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 element on a page.
228#[derive(Debug, Clone, PartialEq)]
229#[non_exhaustive]
230pub enum PositionedElement {
231    /// A run of shaped text glyphs.
232    Text(GlyphRun),
233    /// A line segment (for borders, underlines, strikethrough).
234    Line {
235        start: Point,
236        end: Point,
237        width: f64,
238        color: Color,
239        /// Optional dash pattern (dash_on, dash_off) in points. None = solid line.
240        dash_pattern: Option<(f64, f64)>,
241    },
242    /// A filled rectangle (for shading, highlights).
243    FilledRect { rect: Rect, color: Color },
244    /// An inline image.
245    Image {
246        rect: Rect,
247        data: Vec<u8>,
248        content_type: String,
249        media_id: MediaId,
250    },
251    /// A link annotation (hyperlink).
252    LinkAnnotation { rect: Rect, url: String },
253    /// A backend-neutral filled or stroked path.
254    Path(PathElement),
255    /// A nested group with one child-local transform.
256    Group(GroupElement),
257    /// Non-drawing semantic ownership for a sequence of positioned elements.
258    ///
259    /// `structure` is `None` for an artifact that must not enter the logical
260    /// reading order.
261    MarkedContent {
262        structure: Option<StructureId>,
263        children: Vec<PositionedElement>,
264    },
265}
266
267/// One path with optional fill and stroke paints.
268#[derive(Debug, Clone, PartialEq)]
269pub struct PathElement {
270    pub path: Path,
271    pub fill: Option<Paint>,
272    pub stroke: Option<Stroke>,
273}
274
275/// A rendering approximation or fallback message.
276#[derive(Debug, Clone, PartialEq)]
277pub struct Diagnostic {
278    pub message: String,
279}
280
281/// An effect applied to a group.
282#[derive(Debug, Clone, PartialEq)]
283#[non_exhaustive]
284pub enum Effect {
285    OuterShadow {
286        dx: f64,
287        dy: f64,
288        blur: f64,
289        color: Color,
290    },
291}
292
293/// A group of positioned children in one local coordinate system.
294#[derive(Debug, Clone, PartialEq)]
295pub struct GroupElement {
296    /// Maps child-local coordinates into the parent coordinate system.
297    pub transform: Transform,
298    pub clip: Option<Path>,
299    pub opacity: f64,
300    pub effects: Vec<Effect>,
301    pub children: Vec<PositionedElement>,
302}
303
304/// Visit every non-group element in depth-first document order.
305pub fn walk(elements: &[PositionedElement], f: &mut impl FnMut(&PositionedElement, &Transform)) {
306    fn visit(
307        elements: &[PositionedElement],
308        accumulated: Transform,
309        f: &mut dyn FnMut(&PositionedElement, &Transform),
310    ) {
311        for element in elements {
312            match element {
313                PositionedElement::Group(group) => {
314                    let child_to_page = group.transform.then(accumulated);
315                    visit(&group.children, child_to_page, f);
316                }
317                PositionedElement::MarkedContent { children, .. } => {
318                    visit(children, accumulated, f);
319                }
320                leaf => f(leaf, &accumulated),
321            }
322        }
323    }
324
325    visit(elements, Transform::IDENTITY, f);
326}
327
328/// A single page of laid-out content.
329#[derive(Debug, Clone)]
330#[non_exhaustive]
331pub struct PageFrame {
332    /// 1-based page number.
333    pub page_number: usize,
334    /// Page width in points.
335    pub width: f64,
336    /// Page height in points.
337    pub height: f64,
338    /// All positioned elements on this page.
339    pub elements: Vec<PositionedElement>,
340    /// Optional paint behind every page element.
341    pub background: Option<Paint>,
342}
343
344impl PageFrame {
345    /// Construct a page with no background paint.
346    ///
347    /// ```
348    /// use oxml_layout::PageFrame;
349    ///
350    /// let page = PageFrame::new(1, 612.0, 792.0, Vec::new());
351    /// assert!(page.background.is_none());
352    /// ```
353    pub fn new(
354        page_number: usize,
355        width: f64,
356        height: f64,
357        elements: Vec<PositionedElement>,
358    ) -> Self {
359        Self {
360            page_number,
361            width,
362            height,
363            elements,
364            background: None,
365        }
366    }
367}
368
369/// Font data for embedding in PDF output.
370#[derive(Debug, Clone)]
371pub struct FontData {
372    /// Font identifier.
373    pub id: FontId,
374    /// Font family name.
375    pub family: String,
376    /// Raw TTF/OTF bytes for PDF embedding.
377    pub data: Arc<[u8]>,
378    /// Face index within a font collection.
379    pub face_index: u32,
380    /// Whether this is a bold variant.
381    pub bold: bool,
382    /// Whether this is an italic variant.
383    pub italic: bool,
384}
385
386/// Document metadata to pass through to PDF output.
387#[derive(Debug, Clone, Default)]
388pub struct DocumentMetadata {
389    /// Document title.
390    pub title: Option<String>,
391    /// Document author.
392    pub author: Option<String>,
393    /// Document subject.
394    pub subject: Option<String>,
395    /// Document keywords.
396    pub keywords: Option<String>,
397    /// Creator application.
398    pub creator: Option<String>,
399}
400
401/// An outline/bookmark entry for PDF generation.
402#[derive(Debug, Clone)]
403pub struct OutlineEntry {
404    /// The heading text.
405    pub title: String,
406    /// Heading level (1 for Heading1, 2 for Heading2, etc.).
407    pub level: u32,
408    /// 0-based page index this heading appears on.
409    pub page_index: usize,
410    /// Y position on the page (in points from top).
411    pub y_position: f64,
412}
413
414/// The complete result of laying out a document.
415#[derive(Debug, Clone)]
416#[non_exhaustive]
417pub struct LayoutResult {
418    /// Laid-out pages.
419    pub pages: Vec<Arc<PageFrame>>,
420    /// Font data for all fonts used.
421    pub fonts: Vec<FontData>,
422    /// Optional document metadata for PDF output.
423    pub metadata: Option<DocumentMetadata>,
424    /// Outline/bookmark entries from headings.
425    pub outlines: Vec<OutlineEntry>,
426    /// Rendering approximations and fallbacks collected during layout.
427    pub diagnostics: Vec<Diagnostic>,
428    /// Logical structure for tagged PDF output, when the producer has source
429    /// semantics to preserve.
430    pub structure: Option<DocumentStructure>,
431}
432
433impl LayoutResult {
434    /// Construct a result with no diagnostics.
435    ///
436    /// ```
437    /// use oxml_layout::LayoutResult;
438    ///
439    /// let result = LayoutResult::new(Vec::new(), Vec::new(), None, Vec::new());
440    /// assert!(result.diagnostics.is_empty());
441    /// ```
442    pub fn new(
443        pages: Vec<Arc<PageFrame>>,
444        fonts: Vec<FontData>,
445        metadata: Option<DocumentMetadata>,
446        outlines: Vec<OutlineEntry>,
447    ) -> Self {
448        Self {
449            pages,
450            fonts,
451            metadata,
452            outlines,
453            diagnostics: Vec::new(),
454            structure: None,
455        }
456    }
457}
458
459#[cfg(test)]
460mod media_id_tests {
461    use std::collections::HashSet;
462
463    use super::{MediaId, PositionedElement, Rect};
464
465    #[test]
466    fn the_same_image_bytes_inserted_twice_produce_one_media_id() {
467        let ids = HashSet::from([
468            MediaId::from_bytes(b"same image"),
469            MediaId::from_bytes(b"same image"),
470        ]);
471        assert_eq!(ids.len(), 1);
472    }
473
474    #[test]
475    fn media_id_depends_on_bytes_not_relationship_context() {
476        assert_eq!(
477            MediaId::from_bytes(b"image bytes"),
478            MediaId::from_bytes(b"image bytes")
479        );
480    }
481
482    #[test]
483    fn different_image_bytes_have_different_fixture_ids() {
484        assert_ne!(
485            MediaId::from_bytes(b"first image"),
486            MediaId::from_bytes(b"second image")
487        );
488    }
489
490    #[test]
491    fn staged_output_image_uses_media_id_instead_of_embed_id() {
492        let media_id = MediaId::from_bytes(b"image bytes");
493        let image = PositionedElement::Image {
494            rect: Rect {
495                x: 0.0,
496                y: 0.0,
497                width: 10.0,
498                height: 20.0,
499            },
500            data: b"image bytes".to_vec(),
501            content_type: "image/png".to_owned(),
502            media_id,
503        };
504        let PositionedElement::Image {
505            media_id: actual, ..
506        } = image
507        else {
508            panic!("constructed image should remain an image");
509        };
510        assert_eq!(actual, media_id);
511    }
512}
513
514#[cfg(test)]
515mod tagged_pdf_start_feature_tests {
516    use super::{
517        Color, DocumentStructure, PositionedElement, Rect, StructureId, StructureNode,
518        StructureRole, walk,
519    };
520    use crate::Transform;
521
522    #[test]
523    fn marked_content_is_backend_neutral_and_non_drawing() {
524        let root = StructureId::new(1).expect("non-zero root");
525        let paragraph = StructureId::new(2).expect("non-zero paragraph");
526        let structure = DocumentStructure {
527            root,
528            nodes: vec![
529                StructureNode {
530                    id: root,
531                    role: StructureRole::Document,
532                    children: vec![paragraph],
533                    alternate_text: None,
534                },
535                StructureNode {
536                    id: paragraph,
537                    role: StructureRole::Paragraph,
538                    children: Vec::new(),
539                    alternate_text: None,
540                },
541            ],
542        };
543        assert_eq!(
544            structure.node(paragraph).map(|node| node.role),
545            Some(StructureRole::Paragraph)
546        );
547
548        let elements = vec![PositionedElement::MarkedContent {
549            structure: Some(paragraph),
550            children: vec![PositionedElement::FilledRect {
551                rect: Rect {
552                    x: 1.0,
553                    y: 2.0,
554                    width: 3.0,
555                    height: 4.0,
556                },
557                color: Color::BLACK,
558            }],
559        }];
560        let mut leaves = 0;
561        walk(&elements, &mut |element, transform| {
562            assert!(matches!(element, PositionedElement::FilledRect { .. }));
563            assert_eq!(*transform, Transform::IDENTITY);
564            leaves += 1;
565        });
566        assert_eq!(leaves, 1);
567    }
568}
569
570#[cfg(test)]
571mod group_output_tests {
572    use std::sync::Arc;
573
574    use super::{
575        Color, Diagnostic, Effect, FontData, FontId, GroupElement, LayoutResult, PageFrame,
576        PathElement, PositionedElement, Rect,
577    };
578    use crate::{FillRule, Paint, Path, Stroke, Transform};
579
580    #[test]
581    fn path_and_group_arms_preserve_their_payloads() {
582        let path = Path::rect(Rect {
583            x: 1.0,
584            y: 2.0,
585            width: 3.0,
586            height: 4.0,
587        });
588        let path_element = PathElement {
589            path: path.clone(),
590            fill: Some(Paint::Solid(Color::BLACK)),
591            stroke: Some(Stroke::new(Paint::Solid(Color::WHITE), 2.0)),
592        };
593        let element = PositionedElement::Path(path_element.clone());
594        assert!(matches!(
595            element,
596            PositionedElement::Path(actual) if actual == path_element
597        ));
598
599        let transform = Transform::rotate_about(15.0, 2.0, 3.0);
600        let clip = Path {
601            commands: Vec::new(),
602            fill_rule: FillRule::EvenOdd,
603        };
604        let effect = Effect::OuterShadow {
605            dx: 1.0,
606            dy: 2.0,
607            blur: 3.0,
608            color: Color::BLACK,
609        };
610        let child_rect = Rect {
611            x: 5.0,
612            y: 6.0,
613            width: 7.0,
614            height: 8.0,
615        };
616        let group = GroupElement {
617            transform,
618            clip: Some(clip.clone()),
619            opacity: 0.5,
620            effects: vec![effect.clone()],
621            children: vec![PositionedElement::FilledRect {
622                rect: child_rect,
623                color: Color::WHITE,
624            }],
625        };
626        let element = PositionedElement::Group(group);
627        let PositionedElement::Group(actual) = element else {
628            panic!("constructed group should remain a group");
629        };
630        assert_eq!(actual.transform, transform);
631        assert_eq!(actual.clip, Some(clip));
632        assert_eq!(actual.opacity, 0.5);
633        assert_eq!(actual.effects, vec![effect]);
634        assert!(matches!(
635            actual.children.as_slice(),
636            [PositionedElement::FilledRect { rect, color }]
637                if *rect == child_rect && *color == Color::WHITE
638        ));
639    }
640
641    #[test]
642    fn page_frame_new_defaults_background_to_none() {
643        let page = PageFrame::new(1, 612.0, 792.0, Vec::new());
644        assert_eq!(page.page_number, 1);
645        assert_eq!(page.background, None);
646    }
647
648    #[test]
649    fn layout_result_new_defaults_diagnostics_to_empty() {
650        let result = LayoutResult::new(Vec::new(), Vec::new(), None, Vec::new());
651        assert_eq!(result.diagnostics, Vec::<Diagnostic>::new());
652    }
653
654    #[test]
655    fn cloned_layout_shares_font_bytes_and_page_frames() {
656        let page = Arc::new(PageFrame::new(1, 612.0, 792.0, Vec::new()));
657        let font_bytes: Arc<[u8]> = Arc::from([1, 2, 3, 4]);
658        let result = LayoutResult::new(
659            vec![Arc::clone(&page)],
660            vec![FontData {
661                id: FontId(1),
662                family: "Shared".to_owned(),
663                data: Arc::clone(&font_bytes),
664                face_index: 0,
665                bold: false,
666                italic: false,
667            }],
668            None,
669            Vec::new(),
670        );
671
672        let cloned = result.clone();
673        assert!(Arc::ptr_eq(&result.pages[0], &cloned.pages[0]));
674        assert!(Arc::ptr_eq(&result.fonts[0].data, &cloned.fonts[0].data));
675        assert_eq!(result.pages[0].page_number, cloned.pages[0].page_number);
676        assert_eq!(result.fonts[0].data.as_ref(), cloned.fonts[0].data.as_ref());
677    }
678
679    #[test]
680    fn group_transform_maps_child_coordinates_into_parent_coordinates() {
681        let child_to_parent = Transform {
682            a: 1.0,
683            b: 0.0,
684            c: 0.0,
685            d: 1.0,
686            e: 10.0,
687            f: 20.0,
688        };
689        let group = GroupElement {
690            transform: child_to_parent,
691            clip: None,
692            opacity: 1.0,
693            effects: Vec::new(),
694            children: Vec::new(),
695        };
696        assert_eq!(
697            group.transform.apply(super::Point { x: 1.0, y: 2.0 }),
698            super::Point { x: 11.0, y: 22.0 }
699        );
700    }
701}
702
703#[cfg(test)]
704mod walk_tests {
705    use super::{Color, GroupElement, PositionedElement, Rect, walk};
706    use crate::{Point, Transform};
707
708    fn translate(x: f64, y: f64) -> Transform {
709        Transform {
710            e: x,
711            f: y,
712            ..Transform::IDENTITY
713        }
714    }
715
716    fn scale(value: f64) -> Transform {
717        Transform {
718            a: value,
719            d: value,
720            ..Transform::IDENTITY
721        }
722    }
723
724    fn leaf(id: f64) -> PositionedElement {
725        PositionedElement::FilledRect {
726            rect: Rect {
727                x: id,
728                y: 0.0,
729                width: 1.0,
730                height: 1.0,
731            },
732            color: Color::BLACK,
733        }
734    }
735
736    #[test]
737    fn three_deep_groups_yield_every_leaf_once_with_the_correct_accumulated_transform() {
738        let elements = vec![
739            leaf(1.0),
740            PositionedElement::Group(GroupElement {
741                transform: translate(10.0, 0.0),
742                clip: None,
743                opacity: 1.0,
744                effects: Vec::new(),
745                children: vec![PositionedElement::Group(GroupElement {
746                    transform: scale(2.0),
747                    clip: None,
748                    opacity: 1.0,
749                    effects: Vec::new(),
750                    children: vec![PositionedElement::Group(GroupElement {
751                        transform: translate(0.0, 5.0),
752                        clip: None,
753                        opacity: 1.0,
754                        effects: Vec::new(),
755                        children: vec![leaf(2.0)],
756                    })],
757                })],
758            }),
759            leaf(3.0),
760        ];
761        let mut visited = Vec::new();
762        walk(&elements, &mut |element, transform| {
763            let PositionedElement::FilledRect { rect, .. } = element else {
764                panic!("walk should yield leaves only");
765            };
766            visited.push((rect.x, transform.apply(Point { x: 1.0, y: 1.0 })));
767        });
768        assert_eq!(
769            visited,
770            vec![
771                (1.0, Point { x: 1.0, y: 1.0 }),
772                (2.0, Point { x: 12.0, y: 12.0 }),
773                (3.0, Point { x: 1.0, y: 1.0 }),
774            ]
775        );
776    }
777
778    #[test]
779    fn nested_group_transform_order_applies_child_before_parent() {
780        let group = PositionedElement::Group(GroupElement {
781            transform: translate(10.0, 0.0),
782            clip: None,
783            opacity: 1.0,
784            effects: Vec::new(),
785            children: vec![PositionedElement::Group(GroupElement {
786                transform: scale(2.0),
787                clip: None,
788                opacity: 1.0,
789                effects: Vec::new(),
790                children: vec![leaf(1.0)],
791            })],
792        });
793        let mut points = Vec::new();
794        walk(&[group], &mut |_, transform| {
795            points.push(transform.apply(Point { x: 1.0, y: 1.0 }));
796        });
797        assert_eq!(points, vec![Point { x: 12.0, y: 2.0 }]);
798    }
799
800    #[test]
801    fn walk_does_not_yield_group_nodes() {
802        let group = PositionedElement::Group(GroupElement {
803            transform: Transform::IDENTITY,
804            clip: None,
805            opacity: 1.0,
806            effects: Vec::new(),
807            children: vec![leaf(1.0)],
808        });
809        walk(&[group], &mut |element, _| {
810            assert!(!matches!(element, PositionedElement::Group(_)));
811        });
812    }
813
814    #[test]
815    fn walk_passes_identity_for_root_leaves() {
816        walk(&[leaf(1.0)], &mut |_, transform| {
817            assert_eq!(*transform, Transform::IDENTITY);
818        });
819    }
820}