From 4a9ebd858ed16fe79d1e9edcbfd690688d0a0640 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:53:42 +1000 Subject: [PATCH] QA1: preserve explicit nullable text defaults in native files Co-Authored-By: Codex --- ROADMAP.md | 4 ++ crates/doc/src/attrs.rs | 31 +++++++----- crates/format/src/lib.rs | 100 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 821728bf7..b4883710b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,6 +9,10 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. +## Recent improvements (2026-10-08) + +- QA1: Explicit default text overrides survive native ZIP and legacy JSON reopening + ## Status (2026-10-01) **Working today** diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index ce658e907..9e8fe541f 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -4,18 +4,27 @@ use serde::{Deserialize, Serialize}; +/// Preserve a present nullable override; a missing field still uses the sparse default. +fn deserialize_explicit_value<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de>, +{ + T::deserialize(deserializer).map(Some) +} + macro_rules! attr_set { ( $(#[$m:meta])* $attrs:ident / $props:ident { - $( $(#[$fm:meta])* $field:ident : $ty:ty = $default:expr ),* $(,)? + $( $(#[$fm:meta])* $field:ident : $ty:ty = $default:expr $(=> $deserialize:literal)? ),* $(,)? } ) => { $(#[$m])* #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct $attrs { - $( $(#[$fm])* #[serde(skip_serializing_if = "Option::is_none")] pub $field: Option<$ty>, )* + $( $(#[$fm])* $(#[serde(deserialize_with = $deserialize)])? #[serde(skip_serializing_if = "Option::is_none")] pub $field: Option<$ty>, )* } /// Fully resolved values. @@ -644,8 +653,8 @@ attr_set! { /// Tracking in 1/1000 em. tracking: f64 = 0.0, /// Explicit CJK aki, in em; None restores automatic spacing. - leading_aki: Option = None, - trailing_aki: Option = None, + leading_aki: Option = None => "deserialize_explicit_value", + trailing_aki: Option = None => "deserialize_explicit_value", /// Proportional character compression, 0..1. tsume: f64 = 0.0, /// Target em cells for the contiguous formatted group (0 disables jidori). @@ -671,13 +680,13 @@ attr_set! { strikethrough: bool = false, /// Underline Options: weight and offset below the baseline (`None` = from the size), /// colour (empty = the text colour) and tint. - underline_weight: Option = None, - underline_offset: Option = None, + underline_weight: Option = None => "deserialize_explicit_value", + underline_offset: Option = None => "deserialize_explicit_value", underline_color: String = String::new(), underline_tint: f32 = 1.0, /// Strikethrough Options: offset above the baseline. - strikethrough_weight: Option = None, - strikethrough_offset: Option = None, + strikethrough_weight: Option = None => "deserialize_explicit_value", + strikethrough_offset: Option = None => "deserialize_explicit_value", strikethrough_color: String = String::new(), strikethrough_tint: f32 = 1.0, glyph_form: String = String::new(), @@ -766,7 +775,7 @@ attr_set! { kashidas: bool = true, /// Preserved vendor policy; non-default policies require a dedicated composer. arabic_justification: String = String::new(), - paragraph_kashida_width: Option = None, + paragraph_kashida_width: Option = None => "deserialize_explicit_value", /// Korean text breaks between any two syllables (character-based breaking); otherwise /// it breaks at spaces, word by word. korean_char_breaks: bool = false, @@ -785,7 +794,7 @@ attr_set! { /// Named list (Define Lists) the numbers belong to ("" = the story's own list). list_name: String = String::new(), /// Start At: restart the numbering at this number. - start_at: Option = None, + start_at: Option = None => "deserialize_explicit_value", bullet_char: String = "\u{2022}".into(), number_style: NumberStyle = NumberStyle::Arabic, /// Text after the number/bullet (`^t` = tab). @@ -793,7 +802,7 @@ attr_set! { balance_ragged: bool = false, optical_margin: bool = false, /// None uses the legacy built-in rules; an empty set explicitly disables kinsoku. - kinsoku: Option = None, + kinsoku: Option = None => "deserialize_explicit_value", /// Preserved spacing-table reference; compatibility audit reports unsupported composition. mojikumi: String = String::new(), /// Preserved priority policy; the current breaker does not implement these priorities. diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index f80ad0a65..c34fee1f4 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -299,4 +299,104 @@ mod tests { } assert!(matches!(load(&buf.into_inner()), Err(FormatError::TooNew(99)))); } + + fn r7a_nullable_document(local_clear: bool, style_clear: bool) -> Document { + use designcraft_doc::{CharAttrs, ParaAttrs}; + let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() }); + let base = d.styles.default_paragraph.clone(); + let chars = CharAttrs { + leading_aki: Some(Some(4.0)), + trailing_aki: Some(Some(3.0)), + underline_weight: Some(Some(2.0)), + underline_offset: Some(Some(1.0)), + strikethrough_weight: Some(Some(2.0)), + strikethrough_offset: Some(Some(1.0)), + ..Default::default() + }; + let para = ParaAttrs { + start_at: Some(Some(7)), + paragraph_kashida_width: Some(Some(0.5)), + kinsoku: Some(Some(Default::default())), + ..Default::default() + }; + let style = std::sync::Arc::make_mut(&mut d.styles).paragraph.iter_mut().find(|s| s.name == base).unwrap(); + style.chars = chars; + style.para = para; + let clear_chars = CharAttrs { + leading_aki: Some(None), + trailing_aki: Some(None), + underline_weight: Some(None), + underline_offset: Some(None), + strikethrough_weight: Some(None), + strikethrough_offset: Some(None), + ..Default::default() + }; + let clear_para = ParaAttrs { start_at: Some(None), paragraph_kashida_width: Some(None), kinsoku: Some(None), ..Default::default() }; + let selected = if style_clear { + std::sync::Arc::make_mut(&mut d.styles).paragraph.push(designcraft_doc::styles::ParagraphStyle { + name: "Defaults".into(), + based_on: Some(base.clone()), + next_style: None, + para: clear_para.clone(), + chars: clear_chars.clone(), + shortcut: String::new(), + }); + "Defaults".to_string() + } else { + base + }; + let format = ParaFormat { + style: selected, + para: if local_clear { clear_para } else { ParaAttrs::default() }, + chars: if local_clear { clear_chars } else { CharAttrs::default() }, + table: None, + }; + let layer = d.default_layer(); + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 300.0, 400.0), layer, "clear", format).unwrap(); + d + } + + fn r7a_nullable_resolution(d: &Document) -> (designcraft_doc::ParaProps, designcraft_doc::CharProps) { + let s = d.stories.values().find(|s| s.text == "clear").unwrap(); + d.styles.resolve_para(s.paras.first().unwrap()) + } + + fn r7a_assert_nullable_defaults_survive(d: &Document) { + let expected = r7a_nullable_resolution(d); + assert_eq!(expected.0.start_at, None); + assert_eq!(expected.0.paragraph_kashida_width, None); + assert_eq!(expected.0.kinsoku, None); + assert_eq!(expected.1.leading_aki, None); + for bytes in [save(d).unwrap(), serde_json::to_vec(d).unwrap()] { + let back = load(&bytes).unwrap(); + assert_eq!(r7a_nullable_resolution(&back), expected, "saved explicit defaults must not become inherited values"); + assert_eq!(back, *d, "native ZIP and legacy JSON preserve the complete document"); + } + } + + #[test] + fn r7a_native_local_default_text_overrides_survive_zip_and_legacy_json() { + r7a_assert_nullable_defaults_survive(&r7a_nullable_document(true, false)); + } + + #[test] + fn r7a_native_named_style_default_overrides_survive_zip_and_legacy_json() { + r7a_assert_nullable_defaults_survive(&r7a_nullable_document(false, true)); + } + + #[test] + fn r7a_native_nullable_missing_and_explicit_value_controls() { + let d = r7a_nullable_document(false, false); + assert_eq!(r7a_nullable_resolution(&d).0.start_at, Some(7)); + assert_eq!(r7a_nullable_resolution(&d).1.leading_aki, Some(4.0)); + for bytes in [save(&d).unwrap(), serde_json::to_vec(&d).unwrap()] { + let back = load(&bytes).unwrap(); + assert_eq!(r7a_nullable_resolution(&back), r7a_nullable_resolution(&d)); + assert_eq!(back, d); + } + let attrs: designcraft_doc::CharAttrs = serde_json::from_value(json!({"size":null})).unwrap(); + assert!(attrs.size.is_none(), "nonnullable fields retain their existing null contract"); + let props: designcraft_doc::CharProps = serde_json::from_value(json!({"leadingAki":null})).unwrap(); + assert_eq!(props.leading_aki, None); + } }