mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-27 11:46:51 -06:00
QA1: preserve explicit nullable text defaults in native files
Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user