QA1: preserve explicit nullable text defaults in native files

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
sanyamsingh001
2026-10-10 18:53:42 +10:00
co-authored by Codex
parent 9e4f69428f
commit 4a9ebd858e
3 changed files with 124 additions and 11 deletions
+4
View File
@@ -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**
+20 -11
View File
@@ -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<Option<T>, 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<f64> = None,
trailing_aki: Option<f64> = None,
leading_aki: Option<f64> = None => "deserialize_explicit_value",
trailing_aki: Option<f64> = 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<f64> = None,
underline_offset: Option<f64> = None,
underline_weight: Option<f64> = None => "deserialize_explicit_value",
underline_offset: Option<f64> = None => "deserialize_explicit_value",
underline_color: String = String::new(),
underline_tint: f32 = 1.0,
/// Strikethrough Options: offset above the baseline.
strikethrough_weight: Option<f64> = None,
strikethrough_offset: Option<f64> = None,
strikethrough_weight: Option<f64> = None => "deserialize_explicit_value",
strikethrough_offset: Option<f64> = 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<f64> = None,
paragraph_kashida_width: Option<f64> = 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<u32> = None,
start_at: Option<u32> = 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<crate::cjk::Kinsoku> = None,
kinsoku: Option<crate::cjk::Kinsoku> = 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.
+100
View File
@@ -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);
}
}