Skip to main content

mos_layout/
debug.rs

1//! Optional geometry and source tracing for layout inspection.
2//!
3//! Coordinates are PDF points measured rightward/downward from the page top
4//! left. Text rectangles use shaped advances and font ascent/descent, rather
5//! than glyph ink outlines. Container rectangles enclose emitted content on
6//! each page; they do not include trailing paragraph space or blank raw lines.
7
8use std::collections::BTreeMap;
9
10use mos_core::{AttrValue, Node, NodeKind, display_path};
11use serde::Serialize;
12
13use crate::{ImagePlacement, Page as LayoutPage, PageStyle, TextRun, ascent, descent};
14
15/// Versioned, deterministic geometry report from an explicitly traced layout.
16#[derive(Clone, Debug, Serialize)]
17pub struct Report {
18    pub schema_version: u32,
19    pub units: &'static str,
20    pub origin: &'static str,
21    pub pages: Vec<Page>,
22}
23
24/// Final page geometry, source blocks, text lines, and image placements.
25#[derive(Clone, Debug, Serialize)]
26pub struct Page {
27    pub number: u32,
28    pub bounds: Rect,
29    pub content_bounds: Rect,
30    pub blocks: Vec<Block>,
31    pub lines: Vec<Line>,
32    pub images: Vec<Image>,
33}
34
35/// A rectangle measured in points from the page's top-left corner.
36#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
37pub struct Rect {
38    pub x_pt: f32,
39    pub y_pt: f32,
40    pub width_pt: f32,
41    pub height_pt: f32,
42}
43
44impl Rect {
45    fn union(self, other: Self) -> Self {
46        let x = self.x_pt.min(other.x_pt);
47        let y = self.y_pt.min(other.y_pt);
48        Self {
49            x_pt: x,
50            y_pt: y,
51            width_pt: (self.x_pt + self.width_pt).max(other.x_pt + other.width_pt) - x,
52            height_pt: (self.y_pt + self.height_pt).max(other.y_pt + other.height_pt) - y,
53        }
54    }
55}
56
57/// The semantic block responsible for a placement. Offsets are UTF-8 bytes.
58/// File names are display strings; non-UTF-8 paths use replacement characters.
59#[derive(Clone, Debug, Serialize)]
60pub struct Source {
61    pub node_id: u64,
62    pub kind: &'static str,
63    pub file: String,
64    pub byte_start: usize,
65    pub byte_end: usize,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub label: Option<String>,
68}
69
70impl Source {
71    fn from_node(node: &Node) -> Self {
72        Self {
73            node_id: node.id.0,
74            kind: match node.kind {
75                NodeKind::Section => "section",
76                NodeKind::Paragraph => "paragraph",
77                NodeKind::Image => "image",
78                NodeKind::Figure => "figure",
79                NodeKind::List => "list",
80                NodeKind::ListItem => "list_item",
81                NodeKind::Bibliography => "bibliography",
82                NodeKind::Raw => "raw",
83                _ => "other",
84            },
85            file: display_path(&node.span.file),
86            byte_start: node.span.start(),
87            byte_end: node.span.end(),
88            label: match node.attributes.get("label") {
89                Some(AttrValue::Str(label)) => Some(label.clone()),
90                _ => None,
91            },
92        }
93    }
94}
95
96/// Per-page union of the content emitted by a source block and its children.
97#[derive(Clone, Debug, Serialize)]
98pub struct Block {
99    pub source: Source,
100    pub bounds: Rect,
101}
102
103/// One committed line, including list/bibliography markers and font fallback.
104#[derive(Clone, Debug, Serialize)]
105pub struct Line {
106    pub source: Option<Source>,
107    pub bounds: Rect,
108    pub baseline_from_top_pt: f32,
109    pub runs: Vec<Run>,
110}
111
112/// One final text run, indexed into its page's run array.
113#[derive(Clone, Debug, Serialize)]
114pub struct Run {
115    pub run_index: usize,
116    pub text: String,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub actual_text: Option<String>,
119    pub font: String,
120    pub size_pt: f32,
121    pub bounds: Rect,
122}
123
124/// One final image placement, without decoded pixel bytes or asset paths.
125#[derive(Clone, Debug, Serialize)]
126pub struct Image {
127    pub source: Option<Source>,
128    pub image_index: usize,
129    pub image_id: u32,
130    pub pixel_width: u32,
131    pub pixel_height: u32,
132    pub bounds: Rect,
133}
134
135#[derive(Default)]
136struct PageTrace {
137    blocks: BTreeMap<u64, Block>,
138    lines: Vec<Line>,
139    images: Vec<Image>,
140}
141
142#[derive(Default)]
143pub(super) struct Recorder {
144    active: Vec<Source>,
145    pages: BTreeMap<u32, PageTrace>,
146    pending_runs: Vec<Run>,
147}
148
149impl Recorder {
150    pub(super) fn begin_block(&mut self, node: &Node) {
151        self.active.push(Source::from_node(node));
152    }
153
154    pub(super) fn end_block(&mut self) {
155        self.active.pop();
156    }
157
158    pub(super) fn run(&mut self, run_index: usize, run: &TextRun, advance_pt: f32) {
159        let ascender = ascent(run.font, run.size_pt);
160        self.pending_runs.push(Run {
161            run_index,
162            text: run.text.clone(),
163            actual_text: run.actual_text.clone(),
164            font: run.font.pdf_base_name().to_owned(),
165            size_pt: run.size_pt,
166            bounds: Rect {
167                x_pt: run.x_pt,
168                y_pt: run.baseline_from_top_pt - ascender,
169                width_pt: advance_pt,
170                height_pt: ascender + descent(run.font, run.size_pt),
171            },
172        });
173    }
174
175    pub(super) fn line(&mut self, page: u32, baseline_from_top_pt: f32) {
176        let runs = std::mem::take(&mut self.pending_runs);
177        let Some(bounds) = runs.iter().map(|run| run.bounds).reduce(Rect::union) else {
178            return;
179        };
180        self.extend_blocks(page, bounds);
181        self.pages.entry(page).or_default().lines.push(Line {
182            source: self.active.last().cloned(),
183            bounds,
184            baseline_from_top_pt,
185            runs,
186        });
187    }
188
189    pub(super) fn image(&mut self, page: u32, image_index: usize, image: &ImagePlacement) {
190        let bounds = Rect {
191            x_pt: image.x_pt,
192            y_pt: image.top_from_top_pt,
193            width_pt: image.width_pt,
194            height_pt: image.height_pt,
195        };
196        self.extend_blocks(page, bounds);
197        self.pages.entry(page).or_default().images.push(Image {
198            source: self.active.last().cloned(),
199            image_index,
200            image_id: image.handle.id,
201            pixel_width: image.handle.pixel_width,
202            pixel_height: image.handle.pixel_height,
203            bounds,
204        });
205    }
206
207    fn extend_blocks(&mut self, page: u32, bounds: Rect) {
208        let blocks = &mut self.pages.entry(page).or_default().blocks;
209        for source in &self.active {
210            blocks
211                .entry(source.node_id)
212                .and_modify(|block| {
213                    block.bounds = block.bounds.union(bounds);
214                })
215                .or_insert_with(|| Block {
216                    source: source.clone(),
217                    bounds,
218                });
219        }
220    }
221
222    pub(super) fn finish(mut self, pages: &[LayoutPage], style: PageStyle) -> Report {
223        Report {
224            schema_version: 1,
225            units: "pt",
226            origin: "top-left",
227            pages: pages
228                .iter()
229                .map(|page| {
230                    let trace = self.pages.remove(&page.number).unwrap_or_default();
231                    Page {
232                        number: page.number,
233                        bounds: Rect {
234                            x_pt: 0.0,
235                            y_pt: 0.0,
236                            width_pt: page.width_pt,
237                            height_pt: page.height_pt,
238                        },
239                        content_bounds: Rect {
240                            x_pt: style.margin,
241                            y_pt: style.margin,
242                            width_pt: page.width_pt - 2.0 * style.margin,
243                            height_pt: page.height_pt - 2.0 * style.margin,
244                        },
245                        blocks: trace.blocks.into_values().collect(),
246                        lines: trace.lines,
247                        images: trace.images,
248                    }
249                })
250                .collect(),
251        }
252    }
253}
254
255#[cfg(test)]
256mod tests;