Skip to main content

cdx_core/presentation/
precise.rs

1//! Precise layout presentation layer.
2//!
3//! Precise layouts provide exact coordinates for every element, enabling
4//! pixel-perfect reproduction regardless of rendering implementation.
5//! They are **required** for FROZEN and PUBLISHED documents.
6
7use std::collections::HashMap;
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12use super::paginated::Margins;
13use super::style::Transform;
14
15/// Precise layout for a specific page format.
16///
17/// Precise layouts store exact positions for all elements, ensuring
18/// identical rendering across different implementations. This is
19/// required for documents in FROZEN or PUBLISHED state.
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct PreciseLayout {
23    /// Format version.
24    pub version: String,
25
26    /// Presentation type (always "precise").
27    pub presentation_type: String,
28
29    /// Target page format name (e.g., "letter", "a4", "legal", "custom").
30    pub target_format: String,
31
32    /// Exact page dimensions.
33    pub page_size: PrecisePageSize,
34
35    /// Hash of the semantic content layer when this layout was generated.
36    /// Used to detect staleness when content changes.
37    /// Note: The document ID covers semantic content only; this layout hash
38    /// can be included in scoped signatures for layout attestation.
39    pub content_hash: String,
40
41    /// Timestamp when this layout was generated.
42    pub generated_at: DateTime<Utc>,
43
44    /// Optional page template for headers/footers/margins.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub page_template: Option<PageTemplate>,
47
48    /// Page definitions with precise element positions.
49    pub pages: Vec<PrecisePage>,
50
51    /// Font metrics for exact text reproduction.
52    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
53    pub fonts: HashMap<String, FontMetrics>,
54}
55
56impl PreciseLayout {
57    /// Create a new precise layout for US Letter format.
58    #[must_use]
59    pub fn new_letter(content_hash: impl Into<String>) -> Self {
60        Self {
61            version: crate::SPEC_VERSION.to_string(),
62            presentation_type: "precise".to_string(),
63            target_format: "letter".to_string(),
64            page_size: PrecisePageSize::letter(),
65            content_hash: content_hash.into(),
66            generated_at: Utc::now(),
67            page_template: None,
68            pages: Vec::new(),
69            fonts: HashMap::new(),
70        }
71    }
72
73    /// Create a new precise layout for A4 format.
74    #[must_use]
75    pub fn new_a4(content_hash: impl Into<String>) -> Self {
76        Self {
77            version: crate::SPEC_VERSION.to_string(),
78            presentation_type: "precise".to_string(),
79            target_format: "a4".to_string(),
80            page_size: PrecisePageSize::a4(),
81            content_hash: content_hash.into(),
82            generated_at: Utc::now(),
83            page_template: None,
84            pages: Vec::new(),
85            fonts: HashMap::new(),
86        }
87    }
88
89    /// Create a new precise layout for US Legal format.
90    #[must_use]
91    pub fn new_legal(content_hash: impl Into<String>) -> Self {
92        Self {
93            version: crate::SPEC_VERSION.to_string(),
94            presentation_type: "precise".to_string(),
95            target_format: "legal".to_string(),
96            page_size: PrecisePageSize::legal(),
97            content_hash: content_hash.into(),
98            generated_at: Utc::now(),
99            page_template: None,
100            pages: Vec::new(),
101            fonts: HashMap::new(),
102        }
103    }
104
105    /// Check if this layout is stale (content has changed).
106    #[must_use]
107    pub fn is_stale(&self, current_content_hash: &str) -> bool {
108        self.content_hash != current_content_hash
109    }
110
111    /// Add a page to this layout.
112    pub fn add_page(&mut self, page: PrecisePage) {
113        self.pages.push(page);
114    }
115
116    /// Set the page template.
117    #[must_use]
118    pub fn with_template(mut self, template: PageTemplate) -> Self {
119        self.page_template = Some(template);
120        self
121    }
122
123    /// Add font metrics.
124    #[must_use]
125    pub fn with_font(mut self, name: impl Into<String>, metrics: FontMetrics) -> Self {
126        self.fonts.insert(name.into(), metrics);
127        self
128    }
129}
130
131/// Exact page dimensions for precise layouts.
132#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133pub struct PrecisePageSize {
134    /// Page width with units (e.g., "8.5in", "210mm").
135    pub width: String,
136    /// Page height with units (e.g., "11in", "297mm").
137    pub height: String,
138}
139
140impl PrecisePageSize {
141    /// US Letter size (8.5 x 11 in).
142    #[must_use]
143    pub fn letter() -> Self {
144        Self {
145            width: "8.5in".to_string(),
146            height: "11in".to_string(),
147        }
148    }
149
150    /// US Legal size (8.5 x 14 in).
151    #[must_use]
152    pub fn legal() -> Self {
153        Self {
154            width: "8.5in".to_string(),
155            height: "14in".to_string(),
156        }
157    }
158
159    /// A4 size (210 x 297 mm).
160    #[must_use]
161    pub fn a4() -> Self {
162        Self {
163            width: "210mm".to_string(),
164            height: "297mm".to_string(),
165        }
166    }
167
168    /// A5 size (148 x 210 mm).
169    #[must_use]
170    pub fn a5() -> Self {
171        Self {
172            width: "148mm".to_string(),
173            height: "210mm".to_string(),
174        }
175    }
176
177    /// Custom page size.
178    #[must_use]
179    pub fn custom(width: impl Into<String>, height: impl Into<String>) -> Self {
180        Self {
181            width: width.into(),
182            height: height.into(),
183        }
184    }
185}
186
187/// Page template for headers, footers, and margins.
188#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
189pub struct PageTemplate {
190    /// Page margins.
191    pub margins: Margins,
192
193    /// Header region.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub header: Option<PageRegion>,
196
197    /// Footer region.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub footer: Option<PageRegion>,
200}
201
202impl PageTemplate {
203    /// Create a template with default margins and no header/footer.
204    #[must_use]
205    pub fn new() -> Self {
206        Self::default()
207    }
208
209    /// Set custom margins.
210    #[must_use]
211    pub fn with_margins(mut self, margins: Margins) -> Self {
212        self.margins = margins;
213        self
214    }
215
216    /// Set header region.
217    #[must_use]
218    pub fn with_header(mut self, header: PageRegion) -> Self {
219        self.header = Some(header);
220        self
221    }
222
223    /// Set footer region.
224    #[must_use]
225    pub fn with_footer(mut self, footer: PageRegion) -> Self {
226        self.footer = Some(footer);
227        self
228    }
229}
230
231/// Header or footer region.
232#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
233pub struct PageRegion {
234    /// Content template. Supports placeholders:
235    /// - `{pageNumber}` - Current page number
236    /// - `{totalPages}` - Total page count
237    pub content: String,
238
239    /// Y position from top of page.
240    pub y: String,
241
242    /// Style name to apply.
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub style: Option<String>,
245}
246
247impl PageRegion {
248    /// Create a new page region.
249    #[must_use]
250    pub fn new(content: impl Into<String>, y: impl Into<String>) -> Self {
251        Self {
252            content: content.into(),
253            y: y.into(),
254            style: None,
255        }
256    }
257
258    /// Create a page number footer.
259    #[must_use]
260    pub fn page_number_footer(y: impl Into<String>) -> Self {
261        Self {
262            content: "Page {pageNumber} of {totalPages}".to_string(),
263            y: y.into(),
264            style: Some("footer".to_string()),
265        }
266    }
267
268    /// Set style name.
269    #[must_use]
270    pub fn with_style(mut self, style: impl Into<String>) -> Self {
271        self.style = Some(style.into());
272        self
273    }
274}
275
276/// A page in a precise layout.
277#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
278pub struct PrecisePage {
279    /// Page number (1-indexed).
280    pub number: u32,
281
282    /// Precisely positioned elements on this page.
283    #[serde(default)]
284    pub elements: Vec<PrecisePageElement>,
285}
286
287impl PrecisePage {
288    /// Create a new page with the given number.
289    #[must_use]
290    pub fn new(number: u32) -> Self {
291        Self {
292            number,
293            elements: Vec::new(),
294        }
295    }
296
297    /// Add an element to this page.
298    pub fn add_element(&mut self, element: PrecisePageElement) {
299        self.elements.push(element);
300    }
301
302    /// Add an element and return self for chaining.
303    #[must_use]
304    pub fn with_element(mut self, element: PrecisePageElement) -> Self {
305        self.elements.push(element);
306        self
307    }
308}
309
310/// A precisely positioned element on a page.
311#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
312#[serde(rename_all = "camelCase")]
313pub struct PrecisePageElement {
314    /// Reference to content block ID.
315    pub block_id: String,
316
317    /// Horizontal position from left edge.
318    pub x: String,
319
320    /// Vertical position from top edge.
321    pub y: String,
322
323    /// Element width.
324    pub width: String,
325
326    /// Element height.
327    pub height: String,
328
329    /// True if this element continues to the next page.
330    #[serde(default, skip_serializing_if = "is_false")]
331    pub continues: bool,
332
333    /// True if this element is continued from the previous page.
334    #[serde(default, skip_serializing_if = "is_false")]
335    pub continuation: bool,
336
337    /// Line-level precision for legal documents.
338    #[serde(default, skip_serializing_if = "Vec::is_empty")]
339    pub lines: Vec<LineInfo>,
340
341    /// 2D transform for rotation, scale, skew.
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub transform: Option<Transform>,
344}
345
346#[allow(clippy::trivially_copy_pass_by_ref)] // Required by serde skip_serializing_if
347fn is_false(b: &bool) -> bool {
348    !*b
349}
350
351impl PrecisePageElement {
352    /// Create a new element with precise positioning.
353    #[must_use]
354    pub fn new(
355        block_id: impl Into<String>,
356        x: impl Into<String>,
357        y: impl Into<String>,
358        width: impl Into<String>,
359        height: impl Into<String>,
360    ) -> Self {
361        Self {
362            block_id: block_id.into(),
363            x: x.into(),
364            y: y.into(),
365            width: width.into(),
366            height: height.into(),
367            continues: false,
368            continuation: false,
369            lines: Vec::new(),
370            transform: None,
371        }
372    }
373
374    /// Set the transform for this element.
375    #[must_use]
376    pub fn with_transform(mut self, transform: Transform) -> Self {
377        self.transform = Some(transform);
378        self
379    }
380
381    /// Mark this element as continuing to the next page.
382    #[must_use]
383    pub fn continues(mut self) -> Self {
384        self.continues = true;
385        self
386    }
387
388    /// Mark this element as a continuation from the previous page.
389    #[must_use]
390    pub fn continuation(mut self) -> Self {
391        self.continuation = true;
392        self
393    }
394
395    /// Add line-level precision information.
396    #[must_use]
397    pub fn with_lines(mut self, lines: Vec<LineInfo>) -> Self {
398        self.lines = lines;
399        self
400    }
401}
402
403/// Line-level precision for legal documents.
404///
405/// Optional - only needed for legal/court documents where line numbers
406/// are referenced (e.g., "page 7, line 23").
407#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
408pub struct LineInfo {
409    /// Line number (1-indexed within the block).
410    pub number: u32,
411
412    /// Y position of this line.
413    pub y: String,
414
415    /// Height of this line.
416    pub height: String,
417}
418
419impl LineInfo {
420    /// Create line information.
421    #[must_use]
422    pub fn new(number: u32, y: impl Into<String>, height: impl Into<String>) -> Self {
423        Self {
424            number,
425            y: y.into(),
426            height: height.into(),
427        }
428    }
429}
430
431/// Font metrics for exact text reproduction.
432#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
433#[serde(rename_all = "camelCase")]
434pub struct FontMetrics {
435    /// Font family name.
436    pub family: String,
437
438    /// Font style (normal, italic).
439    #[serde(default = "default_font_style")]
440    pub style: String,
441
442    /// Font weight (100-900).
443    #[serde(default = "default_font_weight")]
444    pub weight: u16,
445
446    /// Units per em.
447    #[serde(default, skip_serializing_if = "Option::is_none")]
448    pub units_per_em: Option<u16>,
449
450    /// Ascender height in font units.
451    #[serde(default, skip_serializing_if = "Option::is_none")]
452    pub ascender: Option<i32>,
453
454    /// Descender depth in font units (typically negative).
455    #[serde(default, skip_serializing_if = "Option::is_none")]
456    pub descender: Option<i32>,
457}
458
459fn default_font_style() -> String {
460    "normal".to_string()
461}
462
463fn default_font_weight() -> u16 {
464    400
465}
466
467impl FontMetrics {
468    /// Create font metrics for a font family.
469    #[must_use]
470    pub fn new(family: impl Into<String>) -> Self {
471        Self {
472            family: family.into(),
473            style: default_font_style(),
474            weight: default_font_weight(),
475            units_per_em: None,
476            ascender: None,
477            descender: None,
478        }
479    }
480
481    /// Set font style.
482    #[must_use]
483    pub fn with_style(mut self, style: impl Into<String>) -> Self {
484        self.style = style.into();
485        self
486    }
487
488    /// Set font weight.
489    #[must_use]
490    pub fn with_weight(mut self, weight: u16) -> Self {
491        self.weight = weight;
492        self
493    }
494
495    /// Set detailed font metrics.
496    #[must_use]
497    pub fn with_metrics(mut self, units_per_em: u16, ascender: i32, descender: i32) -> Self {
498        self.units_per_em = Some(units_per_em);
499        self.ascender = Some(ascender);
500        self.descender = Some(descender);
501        self
502    }
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    #[test]
510    fn test_precise_layout_new() {
511        let layout = PreciseLayout::new_letter("sha256:abc123");
512        assert_eq!(layout.presentation_type, "precise");
513        assert_eq!(layout.target_format, "letter");
514        assert_eq!(layout.page_size.width, "8.5in");
515        assert_eq!(layout.page_size.height, "11in");
516        assert_eq!(layout.content_hash, "sha256:abc123");
517    }
518
519    #[test]
520    fn test_staleness_detection() {
521        let layout = PreciseLayout::new_letter("sha256:abc123");
522        assert!(!layout.is_stale("sha256:abc123"));
523        assert!(layout.is_stale("sha256:xyz789"));
524    }
525
526    #[test]
527    fn test_page_element_continuation() {
528        let elem = PrecisePageElement::new("block-1", "1in", "2in", "6in", "3in").continues();
529        assert!(elem.continues);
530        assert!(!elem.continuation);
531
532        let next = PrecisePageElement::new("block-1", "1in", "1in", "6in", "1in").continuation();
533        assert!(!next.continues);
534        assert!(next.continuation);
535    }
536
537    #[test]
538    fn test_line_level_precision() {
539        let lines = vec![
540            LineInfo::new(1, "3in", "0.2in"),
541            LineInfo::new(2, "3.25in", "0.2in"),
542            LineInfo::new(3, "3.5in", "0.2in"),
543        ];
544        let elem =
545            PrecisePageElement::new("block-5", "1in", "3in", "6.5in", "1.5in").with_lines(lines);
546        assert_eq!(elem.lines.len(), 3);
547        assert_eq!(elem.lines[0].number, 1);
548    }
549
550    #[test]
551    fn test_serialization() {
552        let mut layout = PreciseLayout::new_letter("sha256:abc123");
553        layout.add_page(PrecisePage::new(1).with_element(PrecisePageElement::new(
554            "block-1", "1in", "1in", "6.5in", "0.5in",
555        )));
556
557        let json = serde_json::to_string_pretty(&layout).unwrap();
558        assert!(json.contains("\"presentationType\": \"precise\""));
559        assert!(json.contains("\"targetFormat\": \"letter\""));
560        assert!(json.contains("\"blockId\": \"block-1\""));
561    }
562
563    #[test]
564    fn test_page_template() {
565        let template = PageTemplate::new()
566            .with_margins(Margins::all("1.5in"))
567            .with_footer(PageRegion::page_number_footer("10.5in"));
568
569        assert_eq!(template.margins.top, "1.5in");
570        assert!(template.footer.is_some());
571        assert!(template.header.is_none());
572    }
573
574    #[test]
575    fn test_font_metrics() {
576        let metrics = FontMetrics::new("Times New Roman")
577            .with_weight(700)
578            .with_metrics(2048, 1825, -443);
579
580        assert_eq!(metrics.family, "Times New Roman");
581        assert_eq!(metrics.weight, 700);
582        assert_eq!(metrics.units_per_em, Some(2048));
583    }
584}