use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::paginated::Margins;
use super::style::Transform;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PreciseLayout {
pub version: String,
pub presentation_type: String,
pub target_format: String,
pub page_size: PrecisePageSize,
pub content_hash: String,
pub generated_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page_template: Option<PageTemplate>,
pub pages: Vec<PrecisePage>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub fonts: HashMap<String, FontMetrics>,
}
impl PreciseLayout {
#[must_use]
pub fn new_letter(content_hash: impl Into<String>) -> Self {
Self {
version: crate::SPEC_VERSION.to_string(),
presentation_type: "precise".to_string(),
target_format: "letter".to_string(),
page_size: PrecisePageSize::letter(),
content_hash: content_hash.into(),
generated_at: Utc::now(),
page_template: None,
pages: Vec::new(),
fonts: HashMap::new(),
}
}
#[must_use]
pub fn new_a4(content_hash: impl Into<String>) -> Self {
Self {
version: crate::SPEC_VERSION.to_string(),
presentation_type: "precise".to_string(),
target_format: "a4".to_string(),
page_size: PrecisePageSize::a4(),
content_hash: content_hash.into(),
generated_at: Utc::now(),
page_template: None,
pages: Vec::new(),
fonts: HashMap::new(),
}
}
#[must_use]
pub fn new_legal(content_hash: impl Into<String>) -> Self {
Self {
version: crate::SPEC_VERSION.to_string(),
presentation_type: "precise".to_string(),
target_format: "legal".to_string(),
page_size: PrecisePageSize::legal(),
content_hash: content_hash.into(),
generated_at: Utc::now(),
page_template: None,
pages: Vec::new(),
fonts: HashMap::new(),
}
}
#[must_use]
pub fn is_stale(&self, current_content_hash: &str) -> bool {
self.content_hash != current_content_hash
}
pub fn add_page(&mut self, page: PrecisePage) {
self.pages.push(page);
}
#[must_use]
pub fn with_template(mut self, template: PageTemplate) -> Self {
self.page_template = Some(template);
self
}
#[must_use]
pub fn with_font(mut self, name: impl Into<String>, metrics: FontMetrics) -> Self {
self.fonts.insert(name.into(), metrics);
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PrecisePageSize {
pub width: String,
pub height: String,
}
impl PrecisePageSize {
#[must_use]
pub fn letter() -> Self {
Self {
width: "8.5in".to_string(),
height: "11in".to_string(),
}
}
#[must_use]
pub fn legal() -> Self {
Self {
width: "8.5in".to_string(),
height: "14in".to_string(),
}
}
#[must_use]
pub fn a4() -> Self {
Self {
width: "210mm".to_string(),
height: "297mm".to_string(),
}
}
#[must_use]
pub fn a5() -> Self {
Self {
width: "148mm".to_string(),
height: "210mm".to_string(),
}
}
#[must_use]
pub fn custom(width: impl Into<String>, height: impl Into<String>) -> Self {
Self {
width: width.into(),
height: height.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct PageTemplate {
pub margins: Margins,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub header: Option<PageRegion>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub footer: Option<PageRegion>,
}
impl PageTemplate {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_margins(mut self, margins: Margins) -> Self {
self.margins = margins;
self
}
#[must_use]
pub fn with_header(mut self, header: PageRegion) -> Self {
self.header = Some(header);
self
}
#[must_use]
pub fn with_footer(mut self, footer: PageRegion) -> Self {
self.footer = Some(footer);
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PageRegion {
pub content: String,
pub y: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub style: Option<String>,
}
impl PageRegion {
#[must_use]
pub fn new(content: impl Into<String>, y: impl Into<String>) -> Self {
Self {
content: content.into(),
y: y.into(),
style: None,
}
}
#[must_use]
pub fn page_number_footer(y: impl Into<String>) -> Self {
Self {
content: "Page {pageNumber} of {totalPages}".to_string(),
y: y.into(),
style: Some("footer".to_string()),
}
}
#[must_use]
pub fn with_style(mut self, style: impl Into<String>) -> Self {
self.style = Some(style.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PrecisePage {
pub number: u32,
#[serde(default)]
pub elements: Vec<PrecisePageElement>,
}
impl PrecisePage {
#[must_use]
pub fn new(number: u32) -> Self {
Self {
number,
elements: Vec::new(),
}
}
pub fn add_element(&mut self, element: PrecisePageElement) {
self.elements.push(element);
}
#[must_use]
pub fn with_element(mut self, element: PrecisePageElement) -> Self {
self.elements.push(element);
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PrecisePageElement {
pub block_id: String,
pub x: String,
pub y: String,
pub width: String,
pub height: String,
#[serde(default, skip_serializing_if = "is_false")]
pub continues: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub continuation: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lines: Vec<LineInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transform: Option<Transform>,
}
#[allow(clippy::trivially_copy_pass_by_ref)] fn is_false(b: &bool) -> bool {
!*b
}
impl PrecisePageElement {
#[must_use]
pub fn new(
block_id: impl Into<String>,
x: impl Into<String>,
y: impl Into<String>,
width: impl Into<String>,
height: impl Into<String>,
) -> Self {
Self {
block_id: block_id.into(),
x: x.into(),
y: y.into(),
width: width.into(),
height: height.into(),
continues: false,
continuation: false,
lines: Vec::new(),
transform: None,
}
}
#[must_use]
pub fn with_transform(mut self, transform: Transform) -> Self {
self.transform = Some(transform);
self
}
#[must_use]
pub fn continues(mut self) -> Self {
self.continues = true;
self
}
#[must_use]
pub fn continuation(mut self) -> Self {
self.continuation = true;
self
}
#[must_use]
pub fn with_lines(mut self, lines: Vec<LineInfo>) -> Self {
self.lines = lines;
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LineInfo {
pub number: u32,
pub y: String,
pub height: String,
}
impl LineInfo {
#[must_use]
pub fn new(number: u32, y: impl Into<String>, height: impl Into<String>) -> Self {
Self {
number,
y: y.into(),
height: height.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FontMetrics {
pub family: String,
#[serde(default = "default_font_style")]
pub style: String,
#[serde(default = "default_font_weight")]
pub weight: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub units_per_em: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ascender: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub descender: Option<i32>,
}
fn default_font_style() -> String {
"normal".to_string()
}
fn default_font_weight() -> u16 {
400
}
impl FontMetrics {
#[must_use]
pub fn new(family: impl Into<String>) -> Self {
Self {
family: family.into(),
style: default_font_style(),
weight: default_font_weight(),
units_per_em: None,
ascender: None,
descender: None,
}
}
#[must_use]
pub fn with_style(mut self, style: impl Into<String>) -> Self {
self.style = style.into();
self
}
#[must_use]
pub fn with_weight(mut self, weight: u16) -> Self {
self.weight = weight;
self
}
#[must_use]
pub fn with_metrics(mut self, units_per_em: u16, ascender: i32, descender: i32) -> Self {
self.units_per_em = Some(units_per_em);
self.ascender = Some(ascender);
self.descender = Some(descender);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_precise_layout_new() {
let layout = PreciseLayout::new_letter("sha256:abc123");
assert_eq!(layout.presentation_type, "precise");
assert_eq!(layout.target_format, "letter");
assert_eq!(layout.page_size.width, "8.5in");
assert_eq!(layout.page_size.height, "11in");
assert_eq!(layout.content_hash, "sha256:abc123");
}
#[test]
fn test_staleness_detection() {
let layout = PreciseLayout::new_letter("sha256:abc123");
assert!(!layout.is_stale("sha256:abc123"));
assert!(layout.is_stale("sha256:xyz789"));
}
#[test]
fn test_page_element_continuation() {
let elem = PrecisePageElement::new("block-1", "1in", "2in", "6in", "3in").continues();
assert!(elem.continues);
assert!(!elem.continuation);
let next = PrecisePageElement::new("block-1", "1in", "1in", "6in", "1in").continuation();
assert!(!next.continues);
assert!(next.continuation);
}
#[test]
fn test_line_level_precision() {
let lines = vec![
LineInfo::new(1, "3in", "0.2in"),
LineInfo::new(2, "3.25in", "0.2in"),
LineInfo::new(3, "3.5in", "0.2in"),
];
let elem =
PrecisePageElement::new("block-5", "1in", "3in", "6.5in", "1.5in").with_lines(lines);
assert_eq!(elem.lines.len(), 3);
assert_eq!(elem.lines[0].number, 1);
}
#[test]
fn test_serialization() {
let mut layout = PreciseLayout::new_letter("sha256:abc123");
layout.add_page(PrecisePage::new(1).with_element(PrecisePageElement::new(
"block-1", "1in", "1in", "6.5in", "0.5in",
)));
let json = serde_json::to_string_pretty(&layout).unwrap();
assert!(json.contains("\"presentationType\": \"precise\""));
assert!(json.contains("\"targetFormat\": \"letter\""));
assert!(json.contains("\"blockId\": \"block-1\""));
}
#[test]
fn test_page_template() {
let template = PageTemplate::new()
.with_margins(Margins::all("1.5in"))
.with_footer(PageRegion::page_number_footer("10.5in"));
assert_eq!(template.margins.top, "1.5in");
assert!(template.footer.is_some());
assert!(template.header.is_none());
}
#[test]
fn test_font_metrics() {
let metrics = FontMetrics::new("Times New Roman")
.with_weight(700)
.with_metrics(2048, 1825, -443);
assert_eq!(metrics.family, "Times New Roman");
assert_eq!(metrics.weight, 700);
assert_eq!(metrics.units_per_em, Some(2048));
}
}