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