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