Skip to main content

pdf_vdiff/
layout.rs

1//! Canvas layout geometry, coordinate mapping, and pane origin transformations.
2
3use crate::model::{HighlightSpan, Rect};
4use crate::theme::{DEFAULT_GUTTER_WIDTH, DEFAULT_HEADER_HEIGHT};
5
6/// Width and height of a PDF page in points.
7#[derive(Debug, Clone, Copy, PartialEq)]
8pub struct PageDimensions {
9    pub width: f32,
10    pub height: f32,
11}
12
13impl PageDimensions {
14    /// Creates a new `PageDimensions` instance.
15    pub fn new(width: f32, height: f32) -> Self {
16        Self { width, height }
17    }
18
19    /// Standard US Letter dimensions (612 x 792 pt).
20    pub fn us_letter() -> Self {
21        Self::new(612.0, 792.0)
22    }
23
24    /// Standard A4 dimensions (595.28 x 841.89 pt).
25    pub fn a4() -> Self {
26        Self::new(595.28, 841.89)
27    }
28}
29
30/// Calculated composite landscape canvas layout for a side-by-side page pair.
31#[derive(Debug, Clone, PartialEq)]
32pub struct CanvasLayout {
33    /// Total width of the composite canvas in PDF points.
34    pub total_width: f32,
35    /// Total height of the composite canvas in PDF points.
36    pub total_height: f32,
37    /// Height allocated to the top header banner.
38    pub header_height: f32,
39    /// Gutter width in points between left and right viewports.
40    pub gutter_width: f32,
41    /// Origin (X0, Y0) of the Left (Base) viewport in canvas coordinates.
42    pub base_pane_origin: (f32, f32),
43    /// Origin (X0, Y0) of the Right (Tailored) viewport in canvas coordinates.
44    pub tailored_pane_origin: (f32, f32),
45    /// Bounding box of the Left (Base) page on the canvas.
46    pub base_viewport: Rect,
47    /// Bounding box of the Right (Tailored) page on the canvas.
48    pub tailored_viewport: Rect,
49    /// Bounding box of the top header banner.
50    pub header_bounds: Rect,
51    /// X coordinate of the vertical gutter dividing line.
52    pub gutter_line_x: f32,
53}
54
55impl CanvasLayout {
56    /// Computes the complete composite canvas layout for a page pair.
57    ///
58    /// Invariants:
59    /// - Dimensions: $W_{out} = W_b + W_t + \text{Gutter}$, $H_{out} = \max(H_b, H_t) + H_{header}$
60    /// - Top-Alignment: Source pages are top-aligned directly below the header banner ($Y = \max(H_b, H_t)$)
61    /// - Bottom-Left Origin: $(X_0, Y_0) = (0, \max(H_b, H_t) - H_b)$ for Left, $(W_b + \text{Gutter}, \max(H_b, H_t) - H_t)$ for Right
62    pub fn compute(
63        base_dim: Option<PageDimensions>,
64        tailored_dim: Option<PageDimensions>,
65        gutter_width: f32,
66        header_height: f32,
67    ) -> Self {
68        // Fallback to the other document's size if one side is missing, or US Letter if both missing
69        let default_dim = base_dim
70            .or(tailored_dim)
71            .unwrap_or_else(PageDimensions::us_letter);
72        let b = base_dim.unwrap_or(default_dim);
73        let t = tailored_dim.unwrap_or(default_dim);
74
75        let max_content_height = b.height.max(t.height);
76        let total_width = b.width + t.width + gutter_width;
77        let total_height = max_content_height + header_height;
78
79        // In PDF coordinates with bottom-left origin, top alignment directly below the header means:
80        // Page Top Y = max_content_height
81        // Origin Y = max_content_height - Page Height
82        let base_y0 = max_content_height - b.height;
83        let tailored_y0 = max_content_height - t.height;
84
85        let base_x0 = 0.0;
86        let tailored_x0 = b.width + gutter_width;
87
88        let base_viewport = Rect::new(base_x0, base_y0, base_x0 + b.width, base_y0 + b.height);
89        let tailored_viewport = Rect::new(
90            tailored_x0,
91            tailored_y0,
92            tailored_x0 + t.width,
93            tailored_y0 + t.height,
94        );
95
96        let header_bounds = Rect::new(0.0, max_content_height, total_width, total_height);
97
98        // Position the vertical gutter divider line at the exact midpoint (gutter_width / 2.0)
99        // between the base (left) and tailored (right) page viewports.
100        let gutter_line_x = b.width + (gutter_width / 2.0);
101
102        Self {
103            total_width,
104            total_height,
105            header_height,
106            gutter_width,
107            base_pane_origin: (base_x0, base_y0),
108            tailored_pane_origin: (tailored_x0, tailored_y0),
109            base_viewport,
110            tailored_viewport,
111            header_bounds,
112            gutter_line_x,
113        }
114    }
115
116    /// Computes layout with default gutter and header settings.
117    pub fn compute_default(
118        base_dim: Option<PageDimensions>,
119        tailored_dim: Option<PageDimensions>,
120    ) -> Self {
121        Self::compute(
122            base_dim,
123            tailored_dim,
124            DEFAULT_GUTTER_WIDTH,
125            DEFAULT_HEADER_HEIGHT,
126        )
127    }
128
129    /// Projects a rectangle from Left (Base) source page space into composite canvas space.
130    pub fn project_base_rect(&self, r: Rect) -> Rect {
131        Rect::new(
132            r.x0 + self.base_pane_origin.0,
133            r.y0 + self.base_pane_origin.1,
134            r.x1 + self.base_pane_origin.0,
135            r.y1 + self.base_pane_origin.1,
136        )
137    }
138
139    /// Projects a rectangle from Right (Tailored) source page space into composite canvas space.
140    pub fn project_tailored_rect(&self, r: Rect) -> Rect {
141        Rect::new(
142            r.x0 + self.tailored_pane_origin.0,
143            r.y0 + self.tailored_pane_origin.1,
144            r.x1 + self.tailored_pane_origin.0,
145            r.y1 + self.tailored_pane_origin.1,
146        )
147    }
148
149    /// Projects a Left (Base) highlight span into canvas space.
150    pub fn project_base_highlight(&self, span: &HighlightSpan) -> HighlightSpan {
151        HighlightSpan::new(
152            self.project_base_rect(span.bounds),
153            span.op,
154            span.is_modified_token,
155        )
156    }
157
158    /// Projects a Right (Tailored) highlight span into canvas space.
159    pub fn project_tailored_highlight(&self, span: &HighlightSpan) -> HighlightSpan {
160        HighlightSpan::new(
161            self.project_tailored_rect(span.bounds),
162            span.op,
163            span.is_modified_token,
164        )
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::model::DiffOpKind;
172
173    #[test]
174    fn test_equal_page_dimensions() {
175        let dim = PageDimensions::new(600.0, 800.0);
176        let layout = CanvasLayout::compute(Some(dim), Some(dim), 20.0, 40.0);
177
178        assert_eq!(layout.total_width, 1220.0); // 600 + 600 + 20
179        assert_eq!(layout.total_height, 840.0); // 800 + 40
180        assert_eq!(layout.base_pane_origin, (0.0, 0.0));
181        assert_eq!(layout.tailored_pane_origin, (620.0, 0.0));
182        assert_eq!(layout.gutter_line_x, 610.0);
183        assert_eq!(layout.header_bounds, Rect::new(0.0, 800.0, 1220.0, 840.0));
184    }
185
186    #[test]
187    fn test_unequal_page_dimensions_top_alignment() {
188        let base = PageDimensions::new(600.0, 700.0);
189        let tailored = PageDimensions::new(600.0, 800.0); // Taller page
190        let layout = CanvasLayout::compute(Some(base), Some(tailored), 20.0, 30.0);
191
192        assert_eq!(layout.total_height, 830.0); // 800 + 30
193                                                // Top alignment invariant:
194                                                // Base top must be at Y = 800.0 (below header) -> Base Y0 = 800 - 700 = 100.0
195        assert_eq!(layout.base_pane_origin, (0.0, 100.0));
196        assert_eq!(layout.tailored_pane_origin, (620.0, 0.0));
197
198        let local_rect = Rect::new(50.0, 600.0, 150.0, 650.0);
199        let projected = layout.project_base_rect(local_rect);
200        assert_eq!(projected, Rect::new(50.0, 700.0, 150.0, 750.0));
201    }
202
203    #[test]
204    fn test_highlight_projection() {
205        let dim = PageDimensions::us_letter();
206        let layout = CanvasLayout::compute_default(Some(dim), Some(dim));
207
208        let span = HighlightSpan::new(Rect::new(10.0, 20.0, 30.0, 40.0), DiffOpKind::Insert, false);
209
210        let proj = layout.project_tailored_highlight(&span);
211        assert_eq!(proj.bounds.x0, 10.0 + 612.0 + DEFAULT_GUTTER_WIDTH);
212        assert_eq!(proj.op, DiffOpKind::Insert);
213    }
214}