Skip to main content

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}