pdf_engine/json.rs
1//! The block model as JSON, for callers that chunk it themselves (#572).
2//!
3//! `crate::markdown` next door writes the document a RAG pipeline READS. This
4//! writes the document a pipeline TAKES APART: every block with the kind it
5//! was classified as, the page and the box it was drawn in, its text, the
6//! spans it is made of and its place in the reading order. A caller that
7//! chunks by heading, cites a paragraph by page, or draws a highlight over
8//! what it retrieved needs the geometry, and markdown has none.
9//!
10//! WRITTEN BY HAND AND NOT BY A DERIVE. `serde` is an optional dependency of
11//! this crate and off by default, deliberately -- the core engine stays
12//! serialization-agnostic and the bindings that emit JSON turn it on. A
13//! serializer that only existed under a feature flag would be a serializer
14//! most callers of the library do not have, so this one writes its own bytes
15//! and is always there.
16//!
17//! WHAT MAKES IT DETERMINISTIC, which is the whole of what a golden test over
18//! it rests on: the keys are written in a fixed order rather than iterated out
19//! of a map, and every number goes through [`number`], which has one rule for
20//! every value a `f64` can hold.
21//!
22//! A NON-FINITE NUMBER IS `null`. JSON has no NaN and no infinity, and the two
23//! ways out of that are both worse than saying nothing: printing `NaN` makes a
24//! file no parser accepts, and printing `0` states a coordinate the document
25//! does not have. `Block::bbox` is already finite or `[0, 0, 0, 0]` by the
26//! time it leaves `crate::blocks`, so this is about a span from a pathological
27//! transform and about a block a caller built by hand.
28//!
29//! THE KIND'S OWN FIELDS ARE FLAT, next to `kind` rather than nested under it:
30//! `level` on a heading, `row` and `column` on a cell. Each object has exactly
31//! one kind, so reading `level` after reading `kind` is unambiguous -- and it
32//! is one step instead of two for every filter a caller writes.
33//!
34//! AND THERE IS NO FALLBACK ARM FOR A KIND THIS DOES NOT KNOW. `BlockKind` is
35//! `#[non_exhaustive]` for the callers outside this crate, and inside it the
36//! compiler still counts the variants -- so a kind added later stops the build
37//! here instead of leaving the block to a `_` arm that would write it out as
38//! something it is not. The name of a kind is a wire contract, and the day one
39//! is added is the day somebody has to choose its name.
40
41// Copyright (c) 2026 Innovation Trigger B.V.
42//
43// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
44// the PDFluent Commercial Licence. See the LICENSE file in this repository --
45// that file travels with the copy you received, which a URL does not.
46
47//! WHY THE SPANS ARE THE SIX NUMBERS AND NOT THE WHOLE `TextSpan`. The per-
48//! glyph boxes on a span outweigh everything else in this file by an order of
49//! magnitude -- one box of four numbers per character -- and nothing that
50//! chunks a document reads them. The caller that does want them asks
51//! `extract_text_blocks`, whose `TextSpanInfo` is the canonical wire form of a
52//! span and carries all of it. This carries what a chunk needs to be placed:
53//! where the run is, how big it is, and what it was set in.
54
55use crate::blocks::{Block, BlockKind};
56#[cfg(test)]
57use crate::table_trace::TableOrigin;
58use crate::text::TextSpan;
59
60/// Two spaces per level, the indentation of the whole file.
61pub(crate) const INDENT: &str = " ";
62
63/// One number, or `null` where a `f64` holds something JSON cannot.
64///
65/// The finite case is Rust's shortest representation that reads back as the
66/// same `f64`, which is both exact and stable across platforms -- an integral
67/// value is written without a fractional part, as JSON allows.
68pub(crate) fn number(value: f64) -> String {
69 if value.is_finite() {
70 format!("{value}")
71 } else {
72 "null".to_string()
73 }
74}
75
76/// One string as a JSON string, quotes included.
77///
78/// The two escapes JSON requires, the five it names, and `\u00xx` for every
79/// other control character. Nothing else is touched: a Rust `String` is valid
80/// UTF-8 and JSON is a UTF-8 format, so escaping the rest would only make the
81/// file bigger and the text harder to read.
82pub(crate) fn string(text: &str) -> String {
83 let mut out = String::with_capacity(text.len() + 2);
84 out.push('"');
85 for c in text.chars() {
86 match c {
87 '"' => out.push_str("\\\""),
88 '\\' => out.push_str("\\\\"),
89 '\n' => out.push_str("\\n"),
90 '\r' => out.push_str("\\r"),
91 '\t' => out.push_str("\\t"),
92 '\u{08}' => out.push_str("\\b"),
93 '\u{0c}' => out.push_str("\\f"),
94 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
95 c => out.push(c),
96 }
97 }
98 out.push('"');
99 out
100}
101
102/// The name of a block's kind.
103fn kind_name(kind: &BlockKind) -> &'static str {
104 match kind {
105 BlockKind::Paragraph => "paragraph",
106 BlockKind::Heading { .. } => "heading",
107 BlockKind::ListItem { .. } => "listItem",
108 BlockKind::TableCell { .. } => "tableCell",
109 BlockKind::Header => "header",
110 BlockKind::Footer => "footer",
111 BlockKind::Caption => "caption",
112 BlockKind::Figure => "figure",
113 }
114}
115
116/// The fields that belong to the kind alone, in the order they are written.
117fn kind_fields(kind: &BlockKind) -> Vec<(&'static str, String)> {
118 match *kind {
119 BlockKind::Heading { level } => vec![("level", level.to_string())],
120 BlockKind::ListItem { level, ordinal } => vec![
121 ("level", level.to_string()),
122 (
123 "ordinal",
124 ordinal.map_or_else(|| "null".to_string(), |n| n.to_string()),
125 ),
126 ],
127 BlockKind::TableCell {
128 row,
129 column,
130 row_span,
131 column_span,
132 header,
133 } => vec![
134 ("row", row.to_string()),
135 ("column", column.to_string()),
136 ("rowSpan", row_span.to_string()),
137 ("columnSpan", column_span.to_string()),
138 ("header", header.to_string()),
139 ],
140 _ => Vec::new(),
141 }
142}
143
144/// One span, on one line: it is read as a record and not as a shape, and a
145/// block of forty spans over nine lines each is a file nobody scrolls through.
146fn span(span: &TextSpan, depth: usize) -> String {
147 let pad = INDENT.repeat(depth);
148 let mut out = format!(
149 "{pad}{{ \"text\": {}, \"x\": {}, \"y\": {}, \"width\": {}, \"height\": {}, \"fontSize\": {}",
150 string(&span.text),
151 number(span.x),
152 number(span.y),
153 number(span.width),
154 number(span.height),
155 number(span.font_size),
156 );
157 // The face, where the document said what it was. Omitted rather than
158 // written as `null`: a standard-14 font has no name to report, and a key
159 // that is absent says that more plainly than a key that is empty -- the
160 // same rule `TextSpanInfo` follows on the same field.
161 if let Some(name) = &span.font_name {
162 out.push_str(&format!(", \"fontName\": {}", string(name)));
163 }
164 out.push_str(&format!(
165 ", \"isBold\": {}, \"isItalic\": {} }}",
166 span.is_bold, span.is_italic
167 ));
168 out
169}
170
171/// One block as a JSON object.
172pub(crate) fn block(block: &Block, depth: usize) -> String {
173 let pad = INDENT.repeat(depth);
174 let inner = INDENT.repeat(depth + 1);
175 let mut lines: Vec<String> = Vec::new();
176 lines.push(format!("{inner}\"index\": {}", block.index));
177 lines.push(format!(
178 "{inner}\"kind\": {}",
179 string(kind_name(&block.kind))
180 ));
181 for (key, value) in kind_fields(&block.kind) {
182 lines.push(format!("{inner}\"{key}\": {value}"));
183 }
184 lines.push(format!("{inner}\"page\": {}", block.page));
185 let bbox: Vec<String> = block.bbox.iter().map(|v| number(*v)).collect();
186 lines.push(format!("{inner}\"bbox\": [{}]", bbox.join(", ")));
187 lines.push(format!("{inner}\"text\": {}", string(&block.text)));
188 lines.push(format!(
189 "{inner}\"continuesPrevious\": {}",
190 block.continues_previous
191 ));
192 // OMITTED RATHER THAN NULL WHERE NO MODEL SPOKE (#624, codex on #1977). The C
193 // ABI and the .NET binding read structured blocks through this serializer and
194 // nothing else, so a field missing here is a field those callers cannot have.
195 // Absent and `null` would both parse, and absent is the one that cannot be
196 // mistaken for a confidence of zero by a reader that coerces.
197 if let Some(confidence) = block.confidence {
198 lines.push(format!("{inner}\"confidence\": {}", number(confidence)));
199 }
200 // The table builder's own evidence, on every cell of the table it describes
201 // (#574). Absent on a block that is not a cell, for the reason `confidence` is
202 // absent where no model spoke: a reader that coerces must not be handed zeros.
203 if let Some(grid) = block.grid {
204 let facts = [
205 format!("\"thinRows\": {}", grid.thin_rows),
206 format!("\"fill\": {}", number(grid.fill)),
207 format!("\"retriedFrom\": {}", grid.retried_from),
208 format!("\"fullRows\": {}", grid.full_rows),
209 format!("\"columns\": {}", grid.columns),
210 ];
211 lines.push(format!("{inner}\"grid\": {{{}}}", facts.join(", ")));
212 }
213 let spans: Vec<String> = block.spans.iter().map(|s| span(s, depth + 2)).collect();
214 if spans.is_empty() {
215 lines.push(format!("{inner}\"spans\": []"));
216 } else {
217 lines.push(format!(
218 "{inner}\"spans\": [\n{}\n{inner}]",
219 spans.join(",\n")
220 ));
221 }
222 format!("{pad}{{\n{}\n{pad}}}", lines.join(",\n"))
223}
224
225/// Every block as JSON: kind, page, box, text, spans and reading-order index.
226///
227/// Preview: output quality is still improving and the API may change in a minor release.
228///
229/// An OBJECT with a `blocks` array in it, not a bare array. A document is a
230/// thing with blocks in it and other things later -- the page sizes, the
231/// document's own metadata -- and a format that opened as an array could never
232/// say one without breaking every reader of it.
233///
234/// THE FURNITURE IS IN IT, which is the difference from `crate::to_markdown`.
235/// A running head standing between two paragraphs is what makes it a defect
236/// for anything that chunks the text, so the markdown does not write it; a
237/// caller reading the blocks is not reading a flow, and the line the document
238/// repeats is something it may well want. It carries `"header"` or `"footer"`
239/// and stands after the body, where the reading order puts it (#571).
240///
241/// Deterministic: the same blocks produce the same bytes. Indented with two
242/// spaces per level, because the golden test over this is a diff somebody has
243/// to read, and any consumer that wants it smaller minifies it.
244///
245/// ```
246/// use pdf_engine::{to_json, Block, BlockKind};
247///
248/// let blocks = vec![Block::new(
249/// BlockKind::Heading { level: 1 },
250/// 0,
251/// [72.0, 700.0, 200.0, 712.0],
252/// "Scope".to_string(),
253/// 0,
254/// )];
255/// assert!(to_json(&blocks).contains("\"kind\": \"heading\""));
256/// ```
257pub fn to_json(blocks: &[Block]) -> String {
258 if blocks.is_empty() {
259 return "{\n \"blocks\": []\n}\n".to_string();
260 }
261 let written: Vec<String> = blocks.iter().map(|b| block(b, 2)).collect();
262 format!(
263 "{{\n{INDENT}\"blocks\": [\n{}\n{INDENT}]\n}}\n",
264 written.join(",\n")
265 )
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 fn block_of(kind: BlockKind, text: &str) -> Block {
273 Block {
274 kind,
275 page: 0,
276 bbox: [1.0, 2.0, 3.0, 4.0],
277 text: text.to_string(),
278 spans: Vec::new(),
279 index: 0,
280 continues_previous: false,
281 confidence: None,
282 grid: None,
283 table_origin: TableOrigin::Other,
284 trace_learned: None,
285 }
286 }
287
288 #[test]
289 fn nothing_in_is_an_empty_document_out() {
290 assert_eq!(to_json(&[]), "{\n \"blocks\": []\n}\n");
291 }
292
293 /// A caption is its own kind in the JSON, not a paragraph (#636).
294 #[test]
295 fn a_caption_is_named_a_caption() {
296 let json = to_json(&[block_of(BlockKind::Caption, "Figure 1. The plant.")]);
297 assert!(json.contains("\"kind\": \"caption\""), "{json}");
298 }
299
300 #[test]
301 fn a_heading_carries_its_level_beside_its_kind() {
302 let json = to_json(&[block_of(BlockKind::Heading { level: 2 }, "Terms")]);
303 assert!(json.contains("\"kind\": \"heading\""), "{json}");
304 assert!(json.contains("\"level\": 2"), "{json}");
305 assert!(json.contains("\"text\": \"Terms\""), "{json}");
306 assert!(json.contains("\"bbox\": [1, 2, 3, 4]"), "{json}");
307 }
308
309 #[test]
310 fn a_bulleted_item_says_it_has_no_number() {
311 let json = to_json(&[block_of(
312 BlockKind::ListItem {
313 level: 0,
314 ordinal: None,
315 },
316 "first",
317 )]);
318 assert!(json.contains("\"ordinal\": null"), "{json}");
319 }
320
321 #[test]
322 fn a_non_finite_coordinate_is_null_and_not_a_number_no_parser_reads() {
323 let mut block = block_of(BlockKind::Paragraph, "x");
324 block.bbox = [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1.5];
325 let json = to_json(&[block]);
326 assert!(json.contains("\"bbox\": [null, null, null, 1.5]"), "{json}");
327 assert!(!json.contains("NaN") && !json.contains("inf"), "{json}");
328 }
329
330 #[test]
331 fn a_quote_and_a_control_character_leave_as_escapes() {
332 let json = to_json(&[block_of(
333 BlockKind::Paragraph,
334 "he said \"go\"\tand\u{1}stopped",
335 )]);
336 let want = concat!(
337 "\"text\": \"he said ",
338 "\\\"go\\\"",
339 "\\tand",
340 "\\u0001",
341 "stopped\""
342 );
343 assert!(json.contains(want), "{json}");
344 }
345
346 #[test]
347 fn the_same_blocks_write_the_same_bytes() {
348 let blocks = vec![
349 block_of(BlockKind::Heading { level: 1 }, "Scope"),
350 block_of(BlockKind::Paragraph, "Body."),
351 ];
352 assert_eq!(to_json(&blocks), to_json(&blocks));
353 }
354}