xfa-layout-engine 1.0.0-beta.5

Box-model and pagination layout engine for XFA forms. Experimental — part of the PDFluent XFA stack, under active development.
Documentation
//! Canonical, deterministic JSON output for [`crate::ir::LayoutTreeIR`].
//!
//! ## Determinism contract
//!
//! - **Object keys are alphabetically sorted.** The serializer emits a
//!   fixed set of keys per node in lexicographic order.
//! - **Floats use fixed precision.** All `f64` values are formatted with
//!   `{:.4}` (four decimal places). `-0.0` is normalised to `0.0`. NaN and
//!   the infinities are illegal in IR; we emit them as `0` with a comment
//!   in the round-trip test rather than `null`/non-JSON values, but tests
//!   never construct such values.
//! - **No trailing whitespace, no platform-specific line endings.**
//!   Output uses `\n` line endings unconditionally.
//! - **Indent is two spaces.** Pretty-printed output is the canonical
//!   form; there is no compact mode in v1. Pretty output is the diffable
//!   form humans review.
//! - **Strings are JSON-escaped per RFC 8259.** Non-ASCII characters pass
//!   through; control characters (< 0x20), `"`, and `\` are escaped.
//!
//! ## What this module is not
//!
//! - Not a generic JSON serializer. It only knows the IR's shape.
//! - Not a parser. The diff CLI in `xfa-test-runner` uses `serde_json` to
//!   parse output back; this module's `write_tree` plus the diff CLI's
//!   `serde_json::Value` parse cover the full round-trip.

use super::{LayoutNode, LayoutTreeIR};
use crate::types::Rect;
use std::fmt::Write;

const INDENT: &str = "  ";

/// Render the entire IR tree into `out` as canonical JSON.
pub fn write_tree(out: &mut String, tree: &LayoutTreeIR) {
    out.push_str("{\n");
    write_indented(out, 1, "\"root\": ");
    write_node(out, &tree.root, 1);
    out.push_str(",\n");
    write_indented(out, 1, "\"schema_version\": ");
    let _ = writeln!(out, "{}", tree.schema_version);
    out.push_str("}\n");
}

fn write_node(out: &mut String, node: &LayoutNode, depth: usize) {
    out.push_str("{\n");

    // Emit keys in alphabetical order. The fixed set per node is:
    //   children, field_kind, form_node_id, id, kind, overflow,
    //   page_index, presence, rect, som, value_hash
    write_kv_array(out, depth + 1, "children", &node.children, |o, n, d| {
        write_node(o, n, d)
    });
    write_kv_optional_str(
        out,
        depth + 1,
        "field_kind",
        node.field_kind.map(|f| f.tag()),
    );
    write_kv_optional_u64(out, depth + 1, "form_node_id", node.form_node_id);
    write_kv_str(out, depth + 1, "id", node.id.as_str());
    write_kv_str(out, depth + 1, "kind", node.kind.tag());
    write_kv_str(out, depth + 1, "overflow", node.overflow.tag());
    write_kv_optional_u32(out, depth + 1, "page_index", node.page_index);
    write_kv_str(out, depth + 1, "presence", node.presence.tag());
    write_kv_optional_rect(out, depth + 1, "rect", node.rect);
    write_kv_optional_str(out, depth + 1, "som", node.som.as_deref());
    write_kv_optional_str_last(out, depth + 1, "value_hash", node.value_hash.as_deref());

    write_indent(out, depth);
    out.push('}');
}

// --- helpers -----------------------------------------------------------------

fn write_indent(out: &mut String, depth: usize) {
    for _ in 0..depth {
        out.push_str(INDENT);
    }
}

fn write_indented(out: &mut String, depth: usize, s: &str) {
    write_indent(out, depth);
    out.push_str(s);
}

fn write_kv_str(out: &mut String, depth: usize, key: &str, value: &str) {
    write_indent(out, depth);
    out.push('"');
    out.push_str(key);
    out.push_str("\": ");
    write_json_string(out, value);
    out.push_str(",\n");
}

fn write_kv_optional_str(out: &mut String, depth: usize, key: &str, value: Option<&str>) {
    write_indent(out, depth);
    out.push('"');
    out.push_str(key);
    out.push_str("\": ");
    match value {
        Some(s) => write_json_string(out, s),
        None => out.push_str("null"),
    }
    out.push_str(",\n");
}

fn write_kv_optional_str_last(out: &mut String, depth: usize, key: &str, value: Option<&str>) {
    write_indent(out, depth);
    out.push('"');
    out.push_str(key);
    out.push_str("\": ");
    match value {
        Some(s) => write_json_string(out, s),
        None => out.push_str("null"),
    }
    out.push('\n');
}

fn write_kv_optional_u32(out: &mut String, depth: usize, key: &str, value: Option<u32>) {
    write_indent(out, depth);
    out.push('"');
    out.push_str(key);
    out.push_str("\": ");
    match value {
        Some(v) => {
            let _ = write!(out, "{}", v);
        }
        None => out.push_str("null"),
    }
    out.push_str(",\n");
}

fn write_kv_optional_u64(out: &mut String, depth: usize, key: &str, value: Option<u64>) {
    write_indent(out, depth);
    out.push('"');
    out.push_str(key);
    out.push_str("\": ");
    match value {
        Some(v) => {
            let _ = write!(out, "{}", v);
        }
        None => out.push_str("null"),
    }
    out.push_str(",\n");
}

fn write_kv_optional_rect(out: &mut String, depth: usize, key: &str, value: Option<Rect>) {
    write_indent(out, depth);
    out.push('"');
    out.push_str(key);
    out.push_str("\": ");
    match value {
        Some(r) => write_rect(out, r, depth),
        None => out.push_str("null"),
    }
    out.push_str(",\n");
}

fn write_rect(out: &mut String, r: Rect, depth: usize) {
    out.push_str("{\n");
    // Alphabetical: height, width, x, y.
    write_indent(out, depth + 1);
    out.push_str("\"height\": ");
    write_float(out, r.height);
    out.push_str(",\n");
    write_indent(out, depth + 1);
    out.push_str("\"width\": ");
    write_float(out, r.width);
    out.push_str(",\n");
    write_indent(out, depth + 1);
    out.push_str("\"x\": ");
    write_float(out, r.x);
    out.push_str(",\n");
    write_indent(out, depth + 1);
    out.push_str("\"y\": ");
    write_float(out, r.y);
    out.push('\n');
    write_indent(out, depth);
    out.push('}');
}

fn write_kv_array<T>(
    out: &mut String,
    depth: usize,
    key: &str,
    items: &[T],
    write_item: impl Fn(&mut String, &T, usize),
) {
    write_indent(out, depth);
    out.push('"');
    out.push_str(key);
    out.push_str("\": ");
    if items.is_empty() {
        out.push_str("[],\n");
        return;
    }
    out.push_str("[\n");
    let last = items.len() - 1;
    for (i, item) in items.iter().enumerate() {
        write_indent(out, depth + 1);
        write_item(out, item, depth + 1);
        if i != last {
            out.push(',');
        }
        out.push('\n');
    }
    write_indent(out, depth);
    out.push_str("],\n");
}

/// Format an `f64` with fixed precision `{:.4}`, normalising `-0.0` to `0.0`.
///
/// The serializer is not expected to encounter NaN or the infinities for
/// well-formed IRs. If it does, we emit `0.0000` to keep the JSON valid;
/// the round-trip test does not exercise that path.
fn write_float(out: &mut String, v: f64) {
    let n = if v == 0.0 { 0.0 } else { v };
    let n = if n.is_finite() { n } else { 0.0 };
    let _ = write!(out, "{:.4}", n);
}

/// Append a JSON-escaped string literal (including surrounding quotes).
fn write_json_string(out: &mut String, s: &str) {
    out.push('"');
    for c in s.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            '\u{08}' => out.push_str("\\b"),
            '\u{0C}' => out.push_str("\\f"),
            c if (c as u32) < 0x20 => {
                let _ = write!(out, "\\u{:04x}", c as u32);
            }
            c => out.push(c),
        }
    }
    out.push('"');
}

#[cfg(test)]
mod tests {
    use super::super::{
        FieldKindIR, LayoutNode, LayoutNodeId, LayoutTreeIR, NodeKind, OverflowState, PresenceIR,
        SCHEMA_VERSION,
    };
    use crate::types::Rect;

    fn fixture() -> LayoutTreeIR {
        let mut root = LayoutNode::new(LayoutNodeId::root(), NodeKind::Root);
        let mut page = LayoutNode::new(root.id.child(0), NodeKind::PageArea);
        page.page_index = Some(0);
        page.rect = Some(Rect::new(0.0, 0.0, 612.0, 792.0));
        let mut field = LayoutNode::new(page.id.child(0), NodeKind::Field);
        field.field_kind = Some(FieldKindIR::Text);
        field.presence = PresenceIR::Visible;
        field.rect = Some(Rect::new(72.0, 100.0, 200.0, 14.4));
        field.som = Some("form1.subform.field1".into());
        field.value_hash = Some("abcd1234".into());
        field.overflow = OverflowState::None;
        field.form_node_id = Some(42);
        page.push_child(field);
        root.push_child(page);
        LayoutTreeIR {
            schema_version: SCHEMA_VERSION,
            root,
        }
    }

    #[test]
    fn output_is_byte_stable() {
        let tree = fixture();
        let a = tree.to_canonical_json();
        let b = tree.to_canonical_json();
        assert_eq!(a, b);
    }

    #[test]
    fn output_starts_and_ends_predictably() {
        let tree = fixture();
        let s = tree.to_canonical_json();
        assert!(s.starts_with("{\n"));
        assert!(s.ends_with("}\n"));
        assert!(s.contains("\"schema_version\": 1"));
    }

    #[test]
    fn keys_are_alphabetical_at_each_level() {
        let tree = fixture();
        let s = tree.to_canonical_json();
        // Top-level: root before schema_version.
        let ri = s.find("\"root\":").unwrap();
        let si = s.find("\"schema_version\":").unwrap();
        assert!(ri < si);
        // Node-level: children, field_kind, form_node_id, id, kind, overflow,
        // page_index, presence, rect, som, value_hash.
        for (a, b) in [
            ("\"children\":", "\"field_kind\":"),
            ("\"field_kind\":", "\"form_node_id\":"),
            ("\"form_node_id\":", "\"id\":"),
            ("\"id\":", "\"kind\":"),
            ("\"kind\":", "\"overflow\":"),
            ("\"overflow\":", "\"page_index\":"),
            ("\"page_index\":", "\"presence\":"),
            ("\"presence\":", "\"rect\":"),
            ("\"rect\":", "\"som\":"),
            ("\"som\":", "\"value_hash\":"),
        ] {
            let ai = s.find(a).expect(a);
            let bi = s.find(b).expect(b);
            assert!(ai < bi, "expected {a} before {b}");
        }
    }

    #[test]
    fn floats_have_four_decimal_places() {
        let tree = fixture();
        let s = tree.to_canonical_json();
        assert!(s.contains("\"height\": 792.0000"));
        assert!(s.contains("\"width\": 612.0000"));
        assert!(s.contains("\"x\": 0.0000"));
        assert!(s.contains("\"y\": 0.0000"));
        assert!(s.contains("\"height\": 14.4000"));
        assert!(s.contains("\"x\": 72.0000"));
    }

    #[test]
    fn empty_children_render_as_empty_array() {
        let tree = LayoutTreeIR::new();
        let s = tree.to_canonical_json();
        assert!(s.contains("\"children\": [],"));
    }

    #[test]
    fn missing_optional_fields_render_as_null() {
        let tree = LayoutTreeIR::new();
        let s = tree.to_canonical_json();
        assert!(s.contains("\"rect\": null,"));
        assert!(s.contains("\"som\": null,"));
        assert!(s.contains("\"value_hash\": null"));
        assert!(s.contains("\"page_index\": null,"));
        assert!(s.contains("\"form_node_id\": null,"));
        assert!(s.contains("\"field_kind\": null,"));
    }

    #[test]
    fn negative_zero_is_normalised() {
        let mut tree = LayoutTreeIR::new();
        tree.root.rect = Some(Rect::new(-0.0, -0.0, 1.0, 2.0));
        let s = tree.to_canonical_json();
        assert!(s.contains("\"x\": 0.0000"));
        assert!(s.contains("\"y\": 0.0000"));
        assert!(!s.contains("-0.0000"));
    }

    #[test]
    fn strings_are_json_escaped() {
        let mut tree = LayoutTreeIR::new();
        tree.root.som = Some("a\"b\\c\nd".into());
        let s = tree.to_canonical_json();
        assert!(s.contains("\"som\": \"a\\\"b\\\\c\\nd\""));
    }
}