Skip to main content

xfa_layout_engine/ir/
canonical_json.rs

1//! Canonical, deterministic JSON output for [`crate::ir::LayoutTreeIR`].
2//!
3//! ## Determinism contract
4//!
5//! - **Object keys are alphabetically sorted.** The serializer emits a
6//!   fixed set of keys per node in lexicographic order.
7//! - **Floats use fixed precision.** All `f64` values are formatted with
8//!   `{:.4}` (four decimal places). `-0.0` is normalised to `0.0`. NaN and
9//!   the infinities are illegal in IR; we emit them as `0` with a comment
10//!   in the round-trip test rather than `null`/non-JSON values, but tests
11//!   never construct such values.
12//! - **No trailing whitespace, no platform-specific line endings.**
13//!   Output uses `\n` line endings unconditionally.
14//! - **Indent is two spaces.** Pretty-printed output is the canonical
15//!   form; there is no compact mode in v1. Pretty output is the diffable
16//!   form humans review.
17//! - **Strings are JSON-escaped per RFC 8259.** Non-ASCII characters pass
18//!   through; control characters (< 0x20), `"`, and `\` are escaped.
19//!
20//! ## What this module is not
21//!
22//! - Not a generic JSON serializer. It only knows the IR's shape.
23//! - Not a parser. The diff CLI in `xfa-test-runner` uses `serde_json` to
24//!   parse output back; this module's `write_tree` plus the diff CLI's
25//!   `serde_json::Value` parse cover the full round-trip.
26
27use super::{LayoutNode, LayoutTreeIR};
28use crate::types::Rect;
29use std::fmt::Write;
30
31const INDENT: &str = "  ";
32
33/// Render the entire IR tree into `out` as canonical JSON.
34pub fn write_tree(out: &mut String, tree: &LayoutTreeIR) {
35    out.push_str("{\n");
36    write_indented(out, 1, "\"root\": ");
37    write_node(out, &tree.root, 1);
38    out.push_str(",\n");
39    write_indented(out, 1, "\"schema_version\": ");
40    let _ = writeln!(out, "{}", tree.schema_version);
41    out.push_str("}\n");
42}
43
44fn write_node(out: &mut String, node: &LayoutNode, depth: usize) {
45    out.push_str("{\n");
46
47    // Emit keys in alphabetical order. The fixed set per node is:
48    //   children, field_kind, form_node_id, id, kind, overflow,
49    //   page_index, presence, rect, som, value_hash
50    write_kv_array(out, depth + 1, "children", &node.children, |o, n, d| {
51        write_node(o, n, d)
52    });
53    write_kv_optional_str(
54        out,
55        depth + 1,
56        "field_kind",
57        node.field_kind.map(|f| f.tag()),
58    );
59    write_kv_optional_u64(out, depth + 1, "form_node_id", node.form_node_id);
60    write_kv_str(out, depth + 1, "id", node.id.as_str());
61    write_kv_str(out, depth + 1, "kind", node.kind.tag());
62    write_kv_str(out, depth + 1, "overflow", node.overflow.tag());
63    write_kv_optional_u32(out, depth + 1, "page_index", node.page_index);
64    write_kv_str(out, depth + 1, "presence", node.presence.tag());
65    write_kv_optional_rect(out, depth + 1, "rect", node.rect);
66    write_kv_optional_str(out, depth + 1, "som", node.som.as_deref());
67    write_kv_optional_str_last(out, depth + 1, "value_hash", node.value_hash.as_deref());
68
69    write_indent(out, depth);
70    out.push('}');
71}
72
73// --- helpers -----------------------------------------------------------------
74
75fn write_indent(out: &mut String, depth: usize) {
76    for _ in 0..depth {
77        out.push_str(INDENT);
78    }
79}
80
81fn write_indented(out: &mut String, depth: usize, s: &str) {
82    write_indent(out, depth);
83    out.push_str(s);
84}
85
86fn write_kv_str(out: &mut String, depth: usize, key: &str, value: &str) {
87    write_indent(out, depth);
88    out.push('"');
89    out.push_str(key);
90    out.push_str("\": ");
91    write_json_string(out, value);
92    out.push_str(",\n");
93}
94
95fn write_kv_optional_str(out: &mut String, depth: usize, key: &str, value: Option<&str>) {
96    write_indent(out, depth);
97    out.push('"');
98    out.push_str(key);
99    out.push_str("\": ");
100    match value {
101        Some(s) => write_json_string(out, s),
102        None => out.push_str("null"),
103    }
104    out.push_str(",\n");
105}
106
107fn write_kv_optional_str_last(out: &mut String, depth: usize, key: &str, value: Option<&str>) {
108    write_indent(out, depth);
109    out.push('"');
110    out.push_str(key);
111    out.push_str("\": ");
112    match value {
113        Some(s) => write_json_string(out, s),
114        None => out.push_str("null"),
115    }
116    out.push('\n');
117}
118
119fn write_kv_optional_u32(out: &mut String, depth: usize, key: &str, value: Option<u32>) {
120    write_indent(out, depth);
121    out.push('"');
122    out.push_str(key);
123    out.push_str("\": ");
124    match value {
125        Some(v) => {
126            let _ = write!(out, "{}", v);
127        }
128        None => out.push_str("null"),
129    }
130    out.push_str(",\n");
131}
132
133fn write_kv_optional_u64(out: &mut String, depth: usize, key: &str, value: Option<u64>) {
134    write_indent(out, depth);
135    out.push('"');
136    out.push_str(key);
137    out.push_str("\": ");
138    match value {
139        Some(v) => {
140            let _ = write!(out, "{}", v);
141        }
142        None => out.push_str("null"),
143    }
144    out.push_str(",\n");
145}
146
147fn write_kv_optional_rect(out: &mut String, depth: usize, key: &str, value: Option<Rect>) {
148    write_indent(out, depth);
149    out.push('"');
150    out.push_str(key);
151    out.push_str("\": ");
152    match value {
153        Some(r) => write_rect(out, r, depth),
154        None => out.push_str("null"),
155    }
156    out.push_str(",\n");
157}
158
159fn write_rect(out: &mut String, r: Rect, depth: usize) {
160    out.push_str("{\n");
161    // Alphabetical: height, width, x, y.
162    write_indent(out, depth + 1);
163    out.push_str("\"height\": ");
164    write_float(out, r.height);
165    out.push_str(",\n");
166    write_indent(out, depth + 1);
167    out.push_str("\"width\": ");
168    write_float(out, r.width);
169    out.push_str(",\n");
170    write_indent(out, depth + 1);
171    out.push_str("\"x\": ");
172    write_float(out, r.x);
173    out.push_str(",\n");
174    write_indent(out, depth + 1);
175    out.push_str("\"y\": ");
176    write_float(out, r.y);
177    out.push('\n');
178    write_indent(out, depth);
179    out.push('}');
180}
181
182fn write_kv_array<T>(
183    out: &mut String,
184    depth: usize,
185    key: &str,
186    items: &[T],
187    write_item: impl Fn(&mut String, &T, usize),
188) {
189    write_indent(out, depth);
190    out.push('"');
191    out.push_str(key);
192    out.push_str("\": ");
193    if items.is_empty() {
194        out.push_str("[],\n");
195        return;
196    }
197    out.push_str("[\n");
198    let last = items.len() - 1;
199    for (i, item) in items.iter().enumerate() {
200        write_indent(out, depth + 1);
201        write_item(out, item, depth + 1);
202        if i != last {
203            out.push(',');
204        }
205        out.push('\n');
206    }
207    write_indent(out, depth);
208    out.push_str("],\n");
209}
210
211/// Format an `f64` with fixed precision `{:.4}`, normalising `-0.0` to `0.0`.
212///
213/// The serializer is not expected to encounter NaN or the infinities for
214/// well-formed IRs. If it does, we emit `0.0000` to keep the JSON valid;
215/// the round-trip test does not exercise that path.
216fn write_float(out: &mut String, v: f64) {
217    let n = if v == 0.0 { 0.0 } else { v };
218    let n = if n.is_finite() { n } else { 0.0 };
219    let _ = write!(out, "{:.4}", n);
220}
221
222/// Append a JSON-escaped string literal (including surrounding quotes).
223fn write_json_string(out: &mut String, s: &str) {
224    out.push('"');
225    for c in s.chars() {
226        match c {
227            '"' => out.push_str("\\\""),
228            '\\' => out.push_str("\\\\"),
229            '\n' => out.push_str("\\n"),
230            '\r' => out.push_str("\\r"),
231            '\t' => out.push_str("\\t"),
232            '\u{08}' => out.push_str("\\b"),
233            '\u{0C}' => out.push_str("\\f"),
234            c if (c as u32) < 0x20 => {
235                let _ = write!(out, "\\u{:04x}", c as u32);
236            }
237            c => out.push(c),
238        }
239    }
240    out.push('"');
241}
242
243#[cfg(test)]
244mod tests {
245    use super::super::{
246        FieldKindIR, LayoutNode, LayoutNodeId, LayoutTreeIR, NodeKind, OverflowState, PresenceIR,
247        SCHEMA_VERSION,
248    };
249    use crate::types::Rect;
250
251    fn fixture() -> LayoutTreeIR {
252        let mut root = LayoutNode::new(LayoutNodeId::root(), NodeKind::Root);
253        let mut page = LayoutNode::new(root.id.child(0), NodeKind::PageArea);
254        page.page_index = Some(0);
255        page.rect = Some(Rect::new(0.0, 0.0, 612.0, 792.0));
256        let mut field = LayoutNode::new(page.id.child(0), NodeKind::Field);
257        field.field_kind = Some(FieldKindIR::Text);
258        field.presence = PresenceIR::Visible;
259        field.rect = Some(Rect::new(72.0, 100.0, 200.0, 14.4));
260        field.som = Some("form1.subform.field1".into());
261        field.value_hash = Some("abcd1234".into());
262        field.overflow = OverflowState::None;
263        field.form_node_id = Some(42);
264        page.push_child(field);
265        root.push_child(page);
266        LayoutTreeIR {
267            schema_version: SCHEMA_VERSION,
268            root,
269        }
270    }
271
272    #[test]
273    fn output_is_byte_stable() {
274        let tree = fixture();
275        let a = tree.to_canonical_json();
276        let b = tree.to_canonical_json();
277        assert_eq!(a, b);
278    }
279
280    #[test]
281    fn output_starts_and_ends_predictably() {
282        let tree = fixture();
283        let s = tree.to_canonical_json();
284        assert!(s.starts_with("{\n"));
285        assert!(s.ends_with("}\n"));
286        assert!(s.contains("\"schema_version\": 1"));
287    }
288
289    #[test]
290    fn keys_are_alphabetical_at_each_level() {
291        let tree = fixture();
292        let s = tree.to_canonical_json();
293        // Top-level: root before schema_version.
294        let ri = s.find("\"root\":").unwrap();
295        let si = s.find("\"schema_version\":").unwrap();
296        assert!(ri < si);
297        // Node-level: children, field_kind, form_node_id, id, kind, overflow,
298        // page_index, presence, rect, som, value_hash.
299        for (a, b) in [
300            ("\"children\":", "\"field_kind\":"),
301            ("\"field_kind\":", "\"form_node_id\":"),
302            ("\"form_node_id\":", "\"id\":"),
303            ("\"id\":", "\"kind\":"),
304            ("\"kind\":", "\"overflow\":"),
305            ("\"overflow\":", "\"page_index\":"),
306            ("\"page_index\":", "\"presence\":"),
307            ("\"presence\":", "\"rect\":"),
308            ("\"rect\":", "\"som\":"),
309            ("\"som\":", "\"value_hash\":"),
310        ] {
311            let ai = s.find(a).expect(a);
312            let bi = s.find(b).expect(b);
313            assert!(ai < bi, "expected {a} before {b}");
314        }
315    }
316
317    #[test]
318    fn floats_have_four_decimal_places() {
319        let tree = fixture();
320        let s = tree.to_canonical_json();
321        assert!(s.contains("\"height\": 792.0000"));
322        assert!(s.contains("\"width\": 612.0000"));
323        assert!(s.contains("\"x\": 0.0000"));
324        assert!(s.contains("\"y\": 0.0000"));
325        assert!(s.contains("\"height\": 14.4000"));
326        assert!(s.contains("\"x\": 72.0000"));
327    }
328
329    #[test]
330    fn empty_children_render_as_empty_array() {
331        let tree = LayoutTreeIR::new();
332        let s = tree.to_canonical_json();
333        assert!(s.contains("\"children\": [],"));
334    }
335
336    #[test]
337    fn missing_optional_fields_render_as_null() {
338        let tree = LayoutTreeIR::new();
339        let s = tree.to_canonical_json();
340        assert!(s.contains("\"rect\": null,"));
341        assert!(s.contains("\"som\": null,"));
342        assert!(s.contains("\"value_hash\": null"));
343        assert!(s.contains("\"page_index\": null,"));
344        assert!(s.contains("\"form_node_id\": null,"));
345        assert!(s.contains("\"field_kind\": null,"));
346    }
347
348    #[test]
349    fn negative_zero_is_normalised() {
350        let mut tree = LayoutTreeIR::new();
351        tree.root.rect = Some(Rect::new(-0.0, -0.0, 1.0, 2.0));
352        let s = tree.to_canonical_json();
353        assert!(s.contains("\"x\": 0.0000"));
354        assert!(s.contains("\"y\": 0.0000"));
355        assert!(!s.contains("-0.0000"));
356    }
357
358    #[test]
359    fn strings_are_json_escaped() {
360        let mut tree = LayoutTreeIR::new();
361        tree.root.som = Some("a\"b\\c\nd".into());
362        let s = tree.to_canonical_json();
363        assert!(s.contains("\"som\": \"a\\\"b\\\\c\\nd\""));
364    }
365}