use super::{LayoutNode, LayoutTreeIR};
use crate::types::Rect;
use std::fmt::Write;
const INDENT: &str = " ";
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");
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('}');
}
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");
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");
}
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);
}
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();
let ri = s.find("\"root\":").unwrap();
let si = s.find("\"schema_version\":").unwrap();
assert!(ri < si);
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\""));
}
}