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