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}
513
514/// Document metadata to pass through to PDF output.
515#[derive(Debug, Clone, Default)]
516pub struct DocumentMetadata {
517    /// Document title.
518    pub title: Option<String>,
519    /// Document author.
520    pub author: Option<String>,
521    /// Document subject.
522    pub subject: Option<String>,
523    /// Document keywords.
524    pub keywords: Option<String>,
525    /// Creator application.
526    pub creator: Option<String>,
527}
528
529/// An outline/bookmark entry for PDF generation.
530#[derive(Debug, Clone)]
531pub struct OutlineEntry {
532    /// The heading text.
533    pub title: String,
534    /// Heading level (1 for Heading1, 2 for Heading2, etc.).
535    pub level: u32,
536    /// 0-based page index this heading appears on.
537    pub page_index: usize,
538    /// Y position on the page (in points from top).
539    pub y_position: f64,
540}
541
542/// The complete result of laying out a document.
543#[derive(Debug, Clone)]
544#[non_exhaustive]
545pub struct LayoutResult {
546    /// Laid-out pages.
547    pub pages: Vec<Arc<PageFrame>>,
548    /// Font data for all fonts used.
549    pub fonts: Vec<FontData>,
550    /// Optional document metadata for PDF output.
551    pub metadata: Option<DocumentMetadata>,
552    /// Outline/bookmark entries from headings.
553    pub outlines: Vec<OutlineEntry>,
554    /// Rendering approximations and fallbacks collected during layout.
555    pub diagnostics: Vec<Diagnostic>,
556    /// Logical structure for tagged PDF output, when the producer has source
557    /// semantics to preserve.
558    pub structure: Option<DocumentStructure>,
559}
560
561impl LayoutResult {
562    /// Construct a result with no diagnostics.
563    ///
564    /// ```
565    /// use oxml_layout::LayoutResult;
566    ///
567    /// let result = LayoutResult::new(Vec::new(), Vec::new(), None, Vec::new());
568    /// assert!(result.diagnostics.is_empty());
569    /// ```
570    pub fn new(
571        pages: Vec<Arc<PageFrame>>,
572        fonts: Vec<FontData>,
573        metadata: Option<DocumentMetadata>,
574        outlines: Vec<OutlineEntry>,
575    ) -> Self {
576        Self {
577            pages,
578            fonts,
579            metadata,
580            outlines,
581            diagnostics: Vec::new(),
582            structure: None,
583        }
584    }
585}
586
587#[cfg(test)]
588mod media_id_tests {
589    use std::collections::HashSet;
590
591    use super::{MediaId, PositionedElement, Rect};
592
593    #[test]
594    fn the_same_image_bytes_inserted_twice_produce_one_media_id() {
595        let ids = HashSet::from([
596            MediaId::from_bytes(b"same image"),
597            MediaId::from_bytes(b"same image"),
598        ]);
599        assert_eq!(ids.len(), 1);
600    }
601
602    #[test]
603    fn media_id_depends_on_bytes_not_relationship_context() {
604        assert_eq!(
605            MediaId::from_bytes(b"image bytes"),
606            MediaId::from_bytes(b"image bytes")
607        );
608    }
609
610    #[test]
611    fn different_image_bytes_have_different_fixture_ids() {
612        assert_ne!(
613            MediaId::from_bytes(b"first image"),
614            MediaId::from_bytes(b"second image")
615        );
616    }
617
618    #[test]
619    fn staged_output_image_uses_media_id_instead_of_embed_id() {
620        let media_id = MediaId::from_bytes(b"image bytes");
621        let image = PositionedElement::Image {
622            rect: Rect {
623                x: 0.0,
624                y: 0.0,
625                width: 10.0,
626                height: 20.0,
627            },
628            data: b"image bytes".to_vec(),
629            content_type: "image/png".to_owned(),
630            media_id,
631        };
632        let PositionedElement::Image {
633            media_id: actual, ..
634        } = image
635        else {
636            panic!("constructed image should remain an image");
637        };
638        assert_eq!(actual, media_id);
639    }
640}
641
642#[cfg(test)]
643mod tagged_pdf_start_feature_tests {
644    use super::{
645        Color, DocumentStructure, PositionedElement, Rect, StructureId, StructureNode,
646        StructureRole, walk,
647    };
648    use crate::Transform;
649
650    #[test]
651    fn marked_content_is_backend_neutral_and_non_drawing() {
652        let root = StructureId::new(1).expect("non-zero root");
653        let paragraph = StructureId::new(2).expect("non-zero paragraph");
654        let structure = DocumentStructure {
655            root,
656            nodes: vec![
657                StructureNode {
658                    id: root,
659                    role: StructureRole::Document,
660                    children: vec![paragraph],
661                    alternate_text: None,
662                },
663                StructureNode {
664                    id: paragraph,
665                    role: StructureRole::Paragraph,
666                    children: Vec::new(),
667                    alternate_text: None,
668                },
669            ],
670        };
671        assert_eq!(
672            structure.node(paragraph).map(|node| node.role),
673            Some(StructureRole::Paragraph)
674        );
675
676        let elements = vec![PositionedElement::MarkedContent {
677            structure: Some(paragraph),
678            children: vec![PositionedElement::FilledRect {
679                rect: Rect {
680                    x: 1.0,
681                    y: 2.0,
682                    width: 3.0,
683                    height: 4.0,
684                },
685                color: Color::BLACK,
686            }],
687        }];
688        let mut leaves = 0;
689        walk(&elements, &mut |element, transform| {
690            assert!(matches!(element, PositionedElement::FilledRect { .. }));
691            assert_eq!(*transform, Transform::IDENTITY);
692            leaves += 1;
693        });
694        assert_eq!(leaves, 1);
695    }
696}
697
698#[cfg(test)]
699mod group_output_tests {
700    use std::sync::Arc;
701
702    use super::{
703        Color, Diagnostic, Effect, FontData, FontId, GroupElement, LayoutResult, PageFrame,
704        PathElement, PositionedElement, Rect,
705    };
706    use crate::{FillRule, Paint, Path, Stroke, Transform};
707
708    #[test]
709    fn path_and_group_arms_preserve_their_payloads() {
710        let path = Path::rect(Rect {
711            x: 1.0,
712            y: 2.0,
713            width: 3.0,
714            height: 4.0,
715        });
716        let path_element = PathElement {
717            path: path.clone(),
718            fill: Some(Paint::Solid(Color::BLACK)),
719            stroke: Some(Stroke::new(Paint::Solid(Color::WHITE), 2.0)),
720        };
721        let element = PositionedElement::Path(path_element.clone());
722        assert!(matches!(
723            element,
724            PositionedElement::Path(actual) if actual == path_element
725        ));
726
727        let transform = Transform::rotate_about(15.0, 2.0, 3.0);
728        let clip = Path {
729            commands: Vec::new(),
730            fill_rule: FillRule::EvenOdd,
731        };
732        let effect = Effect::OuterShadow {
733            dx: 1.0,
734            dy: 2.0,
735            blur: 3.0,
736            color: Color::BLACK,
737        };
738        let child_rect = Rect {
739            x: 5.0,
740            y: 6.0,
741            width: 7.0,
742            height: 8.0,
743        };
744        let group = GroupElement {
745            transform,
746            clip: Some(clip.clone()),
747            opacity: 0.5,
748            effects: vec![effect.clone()],
749            children: vec![PositionedElement::FilledRect {
750                rect: child_rect,
751                color: Color::WHITE,
752            }],
753        };
754        let element = PositionedElement::Group(group);
755        let PositionedElement::Group(actual) = element else {
756            panic!("constructed group should remain a group");
757        };
758        assert_eq!(actual.transform, transform);
759        assert_eq!(actual.clip, Some(clip));
760        assert_eq!(actual.opacity, 0.5);
761        assert_eq!(actual.effects, vec![effect]);
762        assert!(matches!(
763            actual.children.as_slice(),
764            [PositionedElement::FilledRect { rect, color }]
765                if *rect == child_rect && *color == Color::WHITE
766        ));
767    }
768
769    #[test]
770    fn page_frame_new_defaults_background_to_none() {
771        let page = PageFrame::new(1, 612.0, 792.0, Vec::new());
772        assert_eq!(page.page_number, 1);
773        assert_eq!(page.background, None);
774    }
775
776    #[test]
777    fn layout_result_new_defaults_diagnostics_to_empty() {
778        let result = LayoutResult::new(Vec::new(), Vec::new(), None, Vec::new());
779        assert_eq!(result.diagnostics, Vec::<Diagnostic>::new());
780    }
781
782    #[test]
783    fn cloned_layout_shares_font_bytes_and_page_frames() {
784        let page = Arc::new(PageFrame::new(1, 612.0, 792.0, Vec::new()));
785        let font_bytes: Arc<[u8]> = Arc::from([1, 2, 3, 4]);
786        let result = LayoutResult::new(
787            vec![Arc::clone(&page)],
788            vec![FontData {
789                id: FontId(1),
790                family: "Shared".to_owned(),
791                data: Arc::clone(&font_bytes),
792                face_index: 0,
793                bold: false,
794                italic: false,
795            }],
796            None,
797            Vec::new(),
798        );
799
800        let cloned = result.clone();
801        assert!(Arc::ptr_eq(&result.pages[0], &cloned.pages[0]));
802        assert!(Arc::ptr_eq(&result.fonts[0].data, &cloned.fonts[0].data));
803        assert_eq!(result.pages[0].page_number, cloned.pages[0].page_number);
804        assert_eq!(result.fonts[0].data.as_ref(), cloned.fonts[0].data.as_ref());
805    }
806
807    #[test]
808    fn group_transform_maps_child_coordinates_into_parent_coordinates() {
809        let child_to_parent = Transform {
810            a: 1.0,
811            b: 0.0,
812            c: 0.0,
813            d: 1.0,
814            e: 10.0,
815            f: 20.0,
816        };
817        let group = GroupElement {
818            transform: child_to_parent,
819            clip: None,
820            opacity: 1.0,
821            effects: Vec::new(),
822            children: Vec::new(),
823        };
824        assert_eq!(
825            group.transform.apply(super::Point { x: 1.0, y: 2.0 }),
826            super::Point { x: 11.0, y: 22.0 }
827        );
828    }
829}
830
831#[cfg(test)]
832mod walk_tests {
833    use super::{Color, GroupElement, PositionedElement, Rect, walk};
834    use crate::{Point, Transform};
835
836    fn translate(x: f64, y: f64) -> Transform {
837        Transform {
838            e: x,
839            f: y,
840            ..Transform::IDENTITY
841        }
842    }
843
844    fn scale(value: f64) -> Transform {
845        Transform {
846            a: value,
847            d: value,
848            ..Transform::IDENTITY
849        }
850    }
851
852    fn leaf(id: f64) -> PositionedElement {
853        PositionedElement::FilledRect {
854            rect: Rect {
855                x: id,
856                y: 0.0,
857                width: 1.0,
858                height: 1.0,
859            },
860            color: Color::BLACK,
861        }
862    }
863
864    #[test]
865    fn three_deep_groups_yield_every_leaf_once_with_the_correct_accumulated_transform() {
866        let elements = vec![
867            leaf(1.0),
868            PositionedElement::Group(GroupElement {
869                transform: translate(10.0, 0.0),
870                clip: None,
871                opacity: 1.0,
872                effects: Vec::new(),
873                children: vec![PositionedElement::Group(GroupElement {
874                    transform: scale(2.0),
875                    clip: None,
876                    opacity: 1.0,
877                    effects: Vec::new(),
878                    children: vec![PositionedElement::Group(GroupElement {
879                        transform: translate(0.0, 5.0),
880                        clip: None,
881                        opacity: 1.0,
882                        effects: Vec::new(),
883                        children: vec![leaf(2.0)],
884                    })],
885                })],
886            }),
887            leaf(3.0),
888        ];
889        let mut visited = Vec::new();
890        walk(&elements, &mut |element, transform| {
891            let PositionedElement::FilledRect { rect, .. } = element else {
892                panic!("walk should yield leaves only");
893            };
894            visited.push((rect.x, transform.apply(Point { x: 1.0, y: 1.0 })));
895        });
896        assert_eq!(
897            visited,
898            vec![
899                (1.0, Point { x: 1.0, y: 1.0 }),
900                (2.0, Point { x: 12.0, y: 12.0 }),
901                (3.0, Point { x: 1.0, y: 1.0 }),
902            ]
903        );
904    }
905
906    #[test]
907    fn nested_group_transform_order_applies_child_before_parent() {
908        let group = PositionedElement::Group(GroupElement {
909            transform: translate(10.0, 0.0),
910            clip: None,
911            opacity: 1.0,
912            effects: Vec::new(),
913            children: vec![PositionedElement::Group(GroupElement {
914                transform: scale(2.0),
915                clip: None,
916                opacity: 1.0,
917                effects: Vec::new(),
918                children: vec![leaf(1.0)],
919            })],
920        });
921        let mut points = Vec::new();
922        walk(&[group], &mut |_, transform| {
923            points.push(transform.apply(Point { x: 1.0, y: 1.0 }));
924        });
925        assert_eq!(points, vec![Point { x: 12.0, y: 2.0 }]);
926    }
927
928    #[test]
929    fn walk_does_not_yield_group_nodes() {
930        let group = PositionedElement::Group(GroupElement {
931            transform: Transform::IDENTITY,
932            clip: None,
933            opacity: 1.0,
934            effects: Vec::new(),
935            children: vec![leaf(1.0)],
936        });
937        walk(&[group], &mut |element, _| {
938            assert!(!matches!(element, PositionedElement::Group(_)));
939        });
940    }
941
942    #[test]
943    fn walk_passes_identity_for_root_leaves() {
944        walk(&[leaf(1.0)], &mut |_, transform| {
945            assert_eq!(*transform, Transform::IDENTITY);
946        });
947    }
948}