Skip to main content

oxml_layout/
output.rs

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