1use std::collections::HashMap;
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12use super::paginated::Margins;
13use super::style::Transform;
14
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct PreciseLayout {
23 pub version: String,
25
26 pub presentation_type: String,
28
29 pub target_format: String,
31
32 pub page_size: PrecisePageSize,
34
35 pub content_hash: String,
40
41 pub generated_at: DateTime<Utc>,
43
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub page_template: Option<PageTemplate>,
47
48 pub pages: Vec<PrecisePage>,
50
51 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
53 pub fonts: HashMap<String, FontMetrics>,
54}
55
56impl PreciseLayout {
57 #[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 #[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 #[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 #[must_use]
107 pub fn is_stale(&self, current_content_hash: &str) -> bool {
108 self.content_hash != current_content_hash
109 }
110
111 pub fn add_page(&mut self, page: PrecisePage) {
113 self.pages.push(page);
114 }
115
116 #[must_use]
118 pub fn with_template(mut self, template: PageTemplate) -> Self {
119 self.page_template = Some(template);
120 self
121 }
122
123 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133pub struct PrecisePageSize {
134 pub width: String,
136 pub height: String,
138}
139
140impl PrecisePageSize {
141 #[must_use]
143 pub fn letter() -> Self {
144 Self {
145 width: "8.5in".to_string(),
146 height: "11in".to_string(),
147 }
148 }
149
150 #[must_use]
152 pub fn legal() -> Self {
153 Self {
154 width: "8.5in".to_string(),
155 height: "14in".to_string(),
156 }
157 }
158
159 #[must_use]
161 pub fn a4() -> Self {
162 Self {
163 width: "210mm".to_string(),
164 height: "297mm".to_string(),
165 }
166 }
167
168 #[must_use]
170 pub fn a5() -> Self {
171 Self {
172 width: "148mm".to_string(),
173 height: "210mm".to_string(),
174 }
175 }
176
177 #[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#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
189pub struct PageTemplate {
190 pub margins: Margins,
192
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub header: Option<PageRegion>,
196
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub footer: Option<PageRegion>,
200}
201
202impl PageTemplate {
203 #[must_use]
205 pub fn new() -> Self {
206 Self::default()
207 }
208
209 #[must_use]
211 pub fn with_margins(mut self, margins: Margins) -> Self {
212 self.margins = margins;
213 self
214 }
215
216 #[must_use]
218 pub fn with_header(mut self, header: PageRegion) -> Self {
219 self.header = Some(header);
220 self
221 }
222
223 #[must_use]
225 pub fn with_footer(mut self, footer: PageRegion) -> Self {
226 self.footer = Some(footer);
227 self
228 }
229}
230
231#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
233pub struct PageRegion {
234 pub content: String,
238
239 pub y: String,
241
242 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub style: Option<String>,
245}
246
247impl PageRegion {
248 #[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 #[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 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
278pub struct PrecisePage {
279 pub number: u32,
281
282 #[serde(default)]
284 pub elements: Vec<PrecisePageElement>,
285}
286
287impl PrecisePage {
288 #[must_use]
290 pub fn new(number: u32) -> Self {
291 Self {
292 number,
293 elements: Vec::new(),
294 }
295 }
296
297 pub fn add_element(&mut self, element: PrecisePageElement) {
299 self.elements.push(element);
300 }
301
302 #[must_use]
304 pub fn with_element(mut self, element: PrecisePageElement) -> Self {
305 self.elements.push(element);
306 self
307 }
308}
309
310#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
312#[serde(rename_all = "camelCase")]
313pub struct PrecisePageElement {
314 pub block_id: String,
316
317 pub x: String,
319
320 pub y: String,
322
323 pub width: String,
325
326 pub height: String,
328
329 #[serde(default, skip_serializing_if = "is_false")]
331 pub continues: bool,
332
333 #[serde(default, skip_serializing_if = "is_false")]
335 pub continuation: bool,
336
337 #[serde(default, skip_serializing_if = "Vec::is_empty")]
339 pub lines: Vec<LineInfo>,
340
341 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub transform: Option<Transform>,
344}
345
346#[allow(clippy::trivially_copy_pass_by_ref)] fn is_false(b: &bool) -> bool {
348 !*b
349}
350
351impl PrecisePageElement {
352 #[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 #[must_use]
376 pub fn with_transform(mut self, transform: Transform) -> Self {
377 self.transform = Some(transform);
378 self
379 }
380
381 #[must_use]
383 pub fn continues(mut self) -> Self {
384 self.continues = true;
385 self
386 }
387
388 #[must_use]
390 pub fn continuation(mut self) -> Self {
391 self.continuation = true;
392 self
393 }
394
395 #[must_use]
397 pub fn with_lines(mut self, lines: Vec<LineInfo>) -> Self {
398 self.lines = lines;
399 self
400 }
401}
402
403#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
408pub struct LineInfo {
409 pub number: u32,
411
412 pub y: String,
414
415 pub height: String,
417}
418
419impl LineInfo {
420 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
433#[serde(rename_all = "camelCase")]
434pub struct FontMetrics {
435 pub family: String,
437
438 #[serde(default = "default_font_style")]
440 pub style: String,
441
442 #[serde(default = "default_font_weight")]
444 pub weight: u16,
445
446 #[serde(default, skip_serializing_if = "Option::is_none")]
448 pub units_per_em: Option<u16>,
449
450 #[serde(default, skip_serializing_if = "Option::is_none")]
452 pub ascender: Option<i32>,
453
454 #[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 #[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 #[must_use]
483 pub fn with_style(mut self, style: impl Into<String>) -> Self {
484 self.style = style.into();
485 self
486 }
487
488 #[must_use]
490 pub fn with_weight(mut self, weight: u16) -> Self {
491 self.weight = weight;
492 self
493 }
494
495 #[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}