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;
4
5use crate::paint::{Paint, Stroke};
6use crate::path::Path;
7use crate::transform::Transform;
8
9/// A point in 2D space (in typographic points from the top-left corner).
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub struct Point {
12    pub x: f64,
13    pub y: f64,
14}
15
16/// An axis-aligned rectangle.
17#[derive(Debug, Clone, Copy, PartialEq)]
18pub struct Rect {
19    pub x: f64,
20    pub y: f64,
21    pub width: f64,
22    pub height: f64,
23}
24
25/// An RGBA color with components in [0.0, 1.0].
26#[derive(Debug, Clone, Copy, PartialEq)]
27pub struct Color {
28    pub r: f64,
29    pub g: f64,
30    pub b: f64,
31    pub a: f64,
32}
33
34impl Color {
35    pub const BLACK: Color = Color {
36        r: 0.0,
37        g: 0.0,
38        b: 0.0,
39        a: 1.0,
40    };
41    pub const WHITE: Color = Color {
42        r: 1.0,
43        g: 1.0,
44        b: 1.0,
45        a: 1.0,
46    };
47
48    /// Parse a hex color string like "FF0000" to Color.
49    pub fn from_hex(hex: &str) -> Self {
50        let hex = hex.trim_start_matches('#');
51        if hex.len() >= 6 {
52            let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0) as f64 / 255.0;
53            let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0) as f64 / 255.0;
54            let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0) as f64 / 255.0;
55            Color { r, g, b, a: 1.0 }
56        } else {
57            Color::BLACK
58        }
59    }
60}
61
62/// Opaque font identifier assigned by FontManager.
63///
64/// Ordered, so a backend keying a map on it can iterate in a fixed order rather
65/// than a hashed one. The PDF writer does exactly that, and the order it
66/// iterates in reaches the bytes it writes.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
68pub struct FontId(pub u32);
69
70/// Stable content-addressed media key for renderer-local reuse.
71///
72/// This compact key is not a collision-free content guarantee.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
74pub struct MediaId(pub u64);
75
76impl MediaId {
77    /// Derive a stable key from raw media bytes using 64-bit FNV-1a.
78    pub fn from_bytes(bytes: &[u8]) -> Self {
79        let mut hash = 0xcbf2_9ce4_8422_2325_u64;
80        for byte in bytes {
81            hash ^= u64::from(*byte);
82            hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
83        }
84        Self(hash)
85    }
86}
87
88/// Result-local identity of one format-specific source node.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
90pub struct SourceNodeId(NonZeroU32);
91
92impl SourceNodeId {
93    /// Construct an identity from its one-based side-table index.
94    pub const fn new(value: u32) -> Option<Self> {
95        match NonZeroU32::new(value) {
96            Some(value) => Some(Self(value)),
97            None => None,
98        }
99    }
100
101    /// Return the one-based side-table index.
102    pub const fn get(self) -> u32 {
103        self.0.get()
104    }
105}
106
107/// Exclusive Unicode-scalar range within one source node.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub struct SourceSpan {
110    pub node: SourceNodeId,
111    pub char_start: u32,
112    pub char_end: u32,
113}
114
115/// Kind of field for post-pagination substitution.
116///
117/// Target carriers stay format-neutral at this shared layout boundary.
118///
119/// ```
120/// use oxml_layout::FieldKind;
121///
122/// assert_eq!(FieldKind::TargetPage(3), FieldKind::TargetPage(3));
123/// assert_eq!(FieldKind::Target(3), FieldKind::Target(3));
124/// ```
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum FieldKind {
127    /// Current page number.
128    Page,
129    /// Total number of pages.
130    NumPages,
131    /// Page containing a target.
132    TargetPage(usize),
133    /// Zero-width target position retained until page locations are collected.
134    Target(usize),
135}
136
137/// A positioned run of shaped glyphs.
138#[derive(Debug, Clone, PartialEq)]
139pub struct GlyphRun {
140    /// Baseline origin of the first glyph (in points).
141    pub origin: Point,
142    /// Font identifier (from FontManager).
143    pub font_id: FontId,
144    /// Font size in points.
145    pub font_size: f64,
146    /// Shaped glyph IDs.
147    pub glyph_ids: Vec<u16>,
148    /// Per-glyph advances in points.
149    pub advances: Vec<f64>,
150    /// Original text (for PDF ToUnicode mapping).
151    pub text: String,
152    /// Exact source range for this run, when it is a direct text projection.
153    pub source: Option<SourceSpan>,
154    /// Text color.
155    pub color: Color,
156    /// Whether the font is bold.
157    pub bold: bool,
158    /// Whether the font is italic.
159    pub italic: bool,
160    /// If this glyph run is a field placeholder, the kind of field.
161    pub field_kind: Option<FieldKind>,
162    /// If this glyph run is a footnote/endnote reference marker, its ID.
163    pub note: Option<crate::line::NoteRef>,
164}
165
166/// A positioned element on a page.
167#[derive(Debug, Clone, PartialEq)]
168#[non_exhaustive]
169pub enum PositionedElement {
170    /// A run of shaped text glyphs.
171    Text(GlyphRun),
172    /// A line segment (for borders, underlines, strikethrough).
173    Line {
174        start: Point,
175        end: Point,
176        width: f64,
177        color: Color,
178        /// Optional dash pattern (dash_on, dash_off) in points. None = solid line.
179        dash_pattern: Option<(f64, f64)>,
180    },
181    /// A filled rectangle (for shading, highlights).
182    FilledRect { rect: Rect, color: Color },
183    /// An inline image.
184    Image {
185        rect: Rect,
186        data: Vec<u8>,
187        content_type: String,
188        media_id: MediaId,
189    },
190    /// A link annotation (hyperlink).
191    LinkAnnotation { rect: Rect, url: String },
192    /// A backend-neutral filled or stroked path.
193    Path(PathElement),
194    /// A nested group with one child-local transform.
195    Group(GroupElement),
196}
197
198/// One path with optional fill and stroke paints.
199#[derive(Debug, Clone, PartialEq)]
200pub struct PathElement {
201    pub path: Path,
202    pub fill: Option<Paint>,
203    pub stroke: Option<Stroke>,
204}
205
206/// A rendering approximation or fallback message.
207#[derive(Debug, Clone, PartialEq)]
208pub struct Diagnostic {
209    pub message: String,
210}
211
212/// An effect applied to a group.
213#[derive(Debug, Clone, PartialEq)]
214#[non_exhaustive]
215pub enum Effect {
216    OuterShadow {
217        dx: f64,
218        dy: f64,
219        blur: f64,
220        color: Color,
221    },
222}
223
224/// A group of positioned children in one local coordinate system.
225#[derive(Debug, Clone, PartialEq)]
226pub struct GroupElement {
227    /// Maps child-local coordinates into the parent coordinate system.
228    pub transform: Transform,
229    pub clip: Option<Path>,
230    pub opacity: f64,
231    pub effects: Vec<Effect>,
232    pub children: Vec<PositionedElement>,
233}
234
235/// Visit every non-group element in depth-first document order.
236pub fn walk(elements: &[PositionedElement], f: &mut impl FnMut(&PositionedElement, &Transform)) {
237    fn visit(
238        elements: &[PositionedElement],
239        accumulated: Transform,
240        f: &mut dyn FnMut(&PositionedElement, &Transform),
241    ) {
242        for element in elements {
243            match element {
244                PositionedElement::Group(group) => {
245                    let child_to_page = group.transform.then(accumulated);
246                    visit(&group.children, child_to_page, f);
247                }
248                leaf => f(leaf, &accumulated),
249            }
250        }
251    }
252
253    visit(elements, Transform::IDENTITY, f);
254}
255
256/// A single page of laid-out content.
257#[derive(Debug, Clone)]
258#[non_exhaustive]
259pub struct PageFrame {
260    /// 1-based page number.
261    pub page_number: usize,
262    /// Page width in points.
263    pub width: f64,
264    /// Page height in points.
265    pub height: f64,
266    /// All positioned elements on this page.
267    pub elements: Vec<PositionedElement>,
268    /// Optional paint behind every page element.
269    pub background: Option<Paint>,
270}
271
272impl PageFrame {
273    /// Construct a page with no background paint.
274    ///
275    /// ```
276    /// use oxml_layout::PageFrame;
277    ///
278    /// let page = PageFrame::new(1, 612.0, 792.0, Vec::new());
279    /// assert!(page.background.is_none());
280    /// ```
281    pub fn new(
282        page_number: usize,
283        width: f64,
284        height: f64,
285        elements: Vec<PositionedElement>,
286    ) -> Self {
287        Self {
288            page_number,
289            width,
290            height,
291            elements,
292            background: None,
293        }
294    }
295}
296
297/// Font data for embedding in PDF output.
298#[derive(Debug, Clone)]
299pub struct FontData {
300    /// Font identifier.
301    pub id: FontId,
302    /// Font family name.
303    pub family: String,
304    /// Raw TTF/OTF bytes for PDF embedding.
305    pub data: Vec<u8>,
306    /// Face index within a font collection.
307    pub face_index: u32,
308    /// Whether this is a bold variant.
309    pub bold: bool,
310    /// Whether this is an italic variant.
311    pub italic: bool,
312}
313
314/// Document metadata to pass through to PDF output.
315#[derive(Debug, Clone, Default)]
316pub struct DocumentMetadata {
317    /// Document title.
318    pub title: Option<String>,
319    /// Document author.
320    pub author: Option<String>,
321    /// Document subject.
322    pub subject: Option<String>,
323    /// Document keywords.
324    pub keywords: Option<String>,
325    /// Creator application.
326    pub creator: Option<String>,
327}
328
329/// An outline/bookmark entry for PDF generation.
330#[derive(Debug, Clone)]
331pub struct OutlineEntry {
332    /// The heading text.
333    pub title: String,
334    /// Heading level (1 for Heading1, 2 for Heading2, etc.).
335    pub level: u32,
336    /// 0-based page index this heading appears on.
337    pub page_index: usize,
338    /// Y position on the page (in points from top).
339    pub y_position: f64,
340}
341
342/// The complete result of laying out a document.
343#[derive(Debug, Clone)]
344#[non_exhaustive]
345pub struct LayoutResult {
346    /// Laid-out pages.
347    pub pages: Vec<PageFrame>,
348    /// Font data for all fonts used.
349    pub fonts: Vec<FontData>,
350    /// Optional document metadata for PDF output.
351    pub metadata: Option<DocumentMetadata>,
352    /// Outline/bookmark entries from headings.
353    pub outlines: Vec<OutlineEntry>,
354    /// Rendering approximations and fallbacks collected during layout.
355    pub diagnostics: Vec<Diagnostic>,
356}
357
358impl LayoutResult {
359    /// Construct a result with no diagnostics.
360    ///
361    /// ```
362    /// use oxml_layout::LayoutResult;
363    ///
364    /// let result = LayoutResult::new(Vec::new(), Vec::new(), None, Vec::new());
365    /// assert!(result.diagnostics.is_empty());
366    /// ```
367    pub fn new(
368        pages: Vec<PageFrame>,
369        fonts: Vec<FontData>,
370        metadata: Option<DocumentMetadata>,
371        outlines: Vec<OutlineEntry>,
372    ) -> Self {
373        Self {
374            pages,
375            fonts,
376            metadata,
377            outlines,
378            diagnostics: Vec::new(),
379        }
380    }
381}
382
383#[cfg(test)]
384mod media_id_tests {
385    use std::collections::HashSet;
386
387    use super::{MediaId, PositionedElement, Rect};
388
389    #[test]
390    fn the_same_image_bytes_inserted_twice_produce_one_media_id() {
391        let ids = HashSet::from([
392            MediaId::from_bytes(b"same image"),
393            MediaId::from_bytes(b"same image"),
394        ]);
395        assert_eq!(ids.len(), 1);
396    }
397
398    #[test]
399    fn media_id_depends_on_bytes_not_relationship_context() {
400        assert_eq!(
401            MediaId::from_bytes(b"image bytes"),
402            MediaId::from_bytes(b"image bytes")
403        );
404    }
405
406    #[test]
407    fn different_image_bytes_have_different_fixture_ids() {
408        assert_ne!(
409            MediaId::from_bytes(b"first image"),
410            MediaId::from_bytes(b"second image")
411        );
412    }
413
414    #[test]
415    fn staged_output_image_uses_media_id_instead_of_embed_id() {
416        let media_id = MediaId::from_bytes(b"image bytes");
417        let image = PositionedElement::Image {
418            rect: Rect {
419                x: 0.0,
420                y: 0.0,
421                width: 10.0,
422                height: 20.0,
423            },
424            data: b"image bytes".to_vec(),
425            content_type: "image/png".to_owned(),
426            media_id,
427        };
428        let PositionedElement::Image {
429            media_id: actual, ..
430        } = image
431        else {
432            panic!("constructed image should remain an image");
433        };
434        assert_eq!(actual, media_id);
435    }
436}
437
438#[cfg(test)]
439mod group_output_tests {
440    use super::{
441        Color, Diagnostic, Effect, GroupElement, LayoutResult, PageFrame, PathElement,
442        PositionedElement, Rect,
443    };
444    use crate::{FillRule, Paint, Path, Stroke, Transform};
445
446    #[test]
447    fn path_and_group_arms_preserve_their_payloads() {
448        let path = Path::rect(Rect {
449            x: 1.0,
450            y: 2.0,
451            width: 3.0,
452            height: 4.0,
453        });
454        let path_element = PathElement {
455            path: path.clone(),
456            fill: Some(Paint::Solid(Color::BLACK)),
457            stroke: Some(Stroke::new(Paint::Solid(Color::WHITE), 2.0)),
458        };
459        let element = PositionedElement::Path(path_element.clone());
460        assert!(matches!(
461            element,
462            PositionedElement::Path(actual) if actual == path_element
463        ));
464
465        let transform = Transform::rotate_about(15.0, 2.0, 3.0);
466        let clip = Path {
467            commands: Vec::new(),
468            fill_rule: FillRule::EvenOdd,
469        };
470        let effect = Effect::OuterShadow {
471            dx: 1.0,
472            dy: 2.0,
473            blur: 3.0,
474            color: Color::BLACK,
475        };
476        let child_rect = Rect {
477            x: 5.0,
478            y: 6.0,
479            width: 7.0,
480            height: 8.0,
481        };
482        let group = GroupElement {
483            transform,
484            clip: Some(clip.clone()),
485            opacity: 0.5,
486            effects: vec![effect.clone()],
487            children: vec![PositionedElement::FilledRect {
488                rect: child_rect,
489                color: Color::WHITE,
490            }],
491        };
492        let element = PositionedElement::Group(group);
493        let PositionedElement::Group(actual) = element else {
494            panic!("constructed group should remain a group");
495        };
496        assert_eq!(actual.transform, transform);
497        assert_eq!(actual.clip, Some(clip));
498        assert_eq!(actual.opacity, 0.5);
499        assert_eq!(actual.effects, vec![effect]);
500        assert!(matches!(
501            actual.children.as_slice(),
502            [PositionedElement::FilledRect { rect, color }]
503                if *rect == child_rect && *color == Color::WHITE
504        ));
505    }
506
507    #[test]
508    fn page_frame_new_defaults_background_to_none() {
509        let page = PageFrame::new(1, 612.0, 792.0, Vec::new());
510        assert_eq!(page.page_number, 1);
511        assert_eq!(page.background, None);
512    }
513
514    #[test]
515    fn layout_result_new_defaults_diagnostics_to_empty() {
516        let result = LayoutResult::new(Vec::new(), Vec::new(), None, Vec::new());
517        assert_eq!(result.diagnostics, Vec::<Diagnostic>::new());
518    }
519
520    #[test]
521    fn group_transform_maps_child_coordinates_into_parent_coordinates() {
522        let child_to_parent = Transform {
523            a: 1.0,
524            b: 0.0,
525            c: 0.0,
526            d: 1.0,
527            e: 10.0,
528            f: 20.0,
529        };
530        let group = GroupElement {
531            transform: child_to_parent,
532            clip: None,
533            opacity: 1.0,
534            effects: Vec::new(),
535            children: Vec::new(),
536        };
537        assert_eq!(
538            group.transform.apply(super::Point { x: 1.0, y: 2.0 }),
539            super::Point { x: 11.0, y: 22.0 }
540        );
541    }
542}
543
544#[cfg(test)]
545mod walk_tests {
546    use super::{Color, GroupElement, PositionedElement, Rect, walk};
547    use crate::{Point, Transform};
548
549    fn translate(x: f64, y: f64) -> Transform {
550        Transform {
551            e: x,
552            f: y,
553            ..Transform::IDENTITY
554        }
555    }
556
557    fn scale(value: f64) -> Transform {
558        Transform {
559            a: value,
560            d: value,
561            ..Transform::IDENTITY
562        }
563    }
564
565    fn leaf(id: f64) -> PositionedElement {
566        PositionedElement::FilledRect {
567            rect: Rect {
568                x: id,
569                y: 0.0,
570                width: 1.0,
571                height: 1.0,
572            },
573            color: Color::BLACK,
574        }
575    }
576
577    #[test]
578    fn three_deep_groups_yield_every_leaf_once_with_the_correct_accumulated_transform() {
579        let elements = vec![
580            leaf(1.0),
581            PositionedElement::Group(GroupElement {
582                transform: translate(10.0, 0.0),
583                clip: None,
584                opacity: 1.0,
585                effects: Vec::new(),
586                children: vec![PositionedElement::Group(GroupElement {
587                    transform: scale(2.0),
588                    clip: None,
589                    opacity: 1.0,
590                    effects: Vec::new(),
591                    children: vec![PositionedElement::Group(GroupElement {
592                        transform: translate(0.0, 5.0),
593                        clip: None,
594                        opacity: 1.0,
595                        effects: Vec::new(),
596                        children: vec![leaf(2.0)],
597                    })],
598                })],
599            }),
600            leaf(3.0),
601        ];
602        let mut visited = Vec::new();
603        walk(&elements, &mut |element, transform| {
604            let PositionedElement::FilledRect { rect, .. } = element else {
605                panic!("walk should yield leaves only");
606            };
607            visited.push((rect.x, transform.apply(Point { x: 1.0, y: 1.0 })));
608        });
609        assert_eq!(
610            visited,
611            vec![
612                (1.0, Point { x: 1.0, y: 1.0 }),
613                (2.0, Point { x: 12.0, y: 12.0 }),
614                (3.0, Point { x: 1.0, y: 1.0 }),
615            ]
616        );
617    }
618
619    #[test]
620    fn nested_group_transform_order_applies_child_before_parent() {
621        let group = PositionedElement::Group(GroupElement {
622            transform: translate(10.0, 0.0),
623            clip: None,
624            opacity: 1.0,
625            effects: Vec::new(),
626            children: vec![PositionedElement::Group(GroupElement {
627                transform: scale(2.0),
628                clip: None,
629                opacity: 1.0,
630                effects: Vec::new(),
631                children: vec![leaf(1.0)],
632            })],
633        });
634        let mut points = Vec::new();
635        walk(&[group], &mut |_, transform| {
636            points.push(transform.apply(Point { x: 1.0, y: 1.0 }));
637        });
638        assert_eq!(points, vec![Point { x: 12.0, y: 2.0 }]);
639    }
640
641    #[test]
642    fn walk_does_not_yield_group_nodes() {
643        let group = PositionedElement::Group(GroupElement {
644            transform: Transform::IDENTITY,
645            clip: None,
646            opacity: 1.0,
647            effects: Vec::new(),
648            children: vec![leaf(1.0)],
649        });
650        walk(&[group], &mut |element, _| {
651            assert!(!matches!(element, PositionedElement::Group(_)));
652        });
653    }
654
655    #[test]
656    fn walk_passes_identity_for_root_leaves() {
657        walk(&[leaf(1.0)], &mut |_, transform| {
658            assert_eq!(*transform, Transform::IDENTITY);
659        });
660    }
661}