CJK-MOJI7: validate spacing data and inspect nested typography rules

This commit is contained in:
Shao Wenbin
2026-10-08 20:14:16 +08:00
parent d9f24b6ad5
commit bc43965a45
7 changed files with 114 additions and 26 deletions
+1
View File
@@ -1,5 +1,6 @@
# DesignCraft roadmap
- CJK-MOJI7: reject malformed Mojikumi interchange values and check nested note/cell paragraph rules in preflight.
- CJK-MOJI6: apply spacing at shaped-cluster boundaries and distinguish centered Traditional Chinese punctuation from Simplified Chinese side bearings.
- CJK-MOJI5: share equal-priority boundary spacing and honor discrete compression endpoints, including leading-only ragged-line fitting.
- CJK-MOJI4: resolve all public Mojikumi preset names and their distinct paragraph/line-edge policies; correct consecutive punctuation defaults and small-kana classes.
+2 -2
View File
@@ -706,8 +706,8 @@ impl Document {
}
}
for t in &self.styles.mojikumi_tables {
if t.overrides.iter().any(|r| ![r.minimum, r.desired, r.maximum].iter().all(|v| v.is_finite())) {
return Err(DocError::Invalid(format!("non-finite mojikumi spacing: {}", t.name)));
if t.overrides.iter().any(|r| !mojikumi::valid_range(r)) {
return Err(DocError::Invalid(format!("invalid mojikumi spacing: {}", t.name)));
}
}
let mut ids = std::collections::HashSet::new();
+10 -5
View File
@@ -121,11 +121,7 @@ impl<'a> Rules<'a> {
if !valid_class(r.target_class) || !valid_class(r.side_class) {
return Err(format!("unsupported character classes {}/{}", r.target_class, r.side_class));
}
if ![r.minimum, r.desired, r.maximum].iter().all(|v| v.is_finite() && (-1.0..=100.0).contains(v))
|| r.minimum > r.desired
|| r.desired > r.maximum
|| !(0..=9).contains(&r.priority)
{
if !valid_range(r) {
return Err("invalid spacing range or priority".into());
}
}
@@ -181,6 +177,15 @@ impl<'a> Rules<'a> {
}
}
/// Structural validation shared by native document loading and interchange.
/// Unknown integer classes remain preservable and are diagnosed by the resolver.
pub fn valid_range(r: &MojikumiAki) -> bool {
[r.minimum, r.desired, r.maximum].iter().all(|v| v.is_finite() && (-1.0..=100.0).contains(v))
&& r.minimum <= r.desired
&& r.desired <= r.maximum
&& (0..=9).contains(&r.priority)
}
pub fn valid_class(c: i16) -> bool {
matches!(c, 1..=12 | 18 | 21..=33)
}
+1 -1
View File
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
/// Every story's text, including table cells and footnotes.
fn for_each_story(d: &Document, f: &mut dyn FnMut(&Story)) {
pub(super) fn for_each_story(d: &Document, f: &mut dyn FnMut(&Story)) {
let mut pending: Vec<&Story> = d.stories.values().map(AsRef::as_ref).collect();
while let Some(st) = pending.pop() {
f(st);
+26 -1
View File
@@ -138,6 +138,9 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
page: location.and_then(|(_, page)| page),
});
}
}
// Nested note and cell text uses paragraph rules too.
super::fonts::for_each_story(d, &mut |story| {
let ranges = story.para_ranges();
for (pi, pf) in story.paras.iter().enumerate() {
let (para, base) = d.styles.resolve_para(pf);
@@ -183,7 +186,7 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
}
}
}
}
});
// Use the same effective character-to-font mapping as Find Font, including
// anonymous note/cell stories. Unused composite entries are not missing text.
for font in super::fonts::used_fonts(d) {
@@ -403,3 +406,25 @@ mod generated_glyph_tests {
assert_eq!(issue["page"], 0);
}
}
#[cfg(test)]
mod mojikumi_nested_tests {
use super::*;
use std::sync::Arc;
#[test]
fn unknown_table_in_a_note_inside_a_cell_is_reported() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("frame.create", &json!({"rect":[20,20,300,400],"content":"text","text":"Body"})).unwrap();
let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let sid = *d.stories.keys().next().unwrap();
let mut table = designcraft_doc::Table::new(1, 1, 1, 0, 0, 100.0);
let cell = &mut table.cells[0].text;
let mut pf = designcraft_doc::ParaFormat::default();
pf.para.mojikumi = Some("MojikumiTable/Unrecognized".into());
cell.insert_note(0, "Note", pf);
d.story_mut(sid).unwrap().insert_table(0, table);
let issues = check(&s, 150.0);
assert_eq!(issues.iter().filter(|i| i.kind == "unsupportedTypography" && i.message.contains("Unrecognized")).count(), 1);
}
}
+42 -17
View File
@@ -143,6 +143,43 @@ fn lab_to_color(l: f32, a: f32, b: f32) -> Color {
Color::rgb(rgb[0], rgb[1], rgb[2])
}
fn mojikumi_row(e: &El) -> Result<designcraft_doc::cjk::MojikumiAki> {
let number = |key: &str, required: bool| -> Result<f64> {
let Some(raw) = e.prop(key) else {
return if required { Err(IdmlError::Invalid(format!("Mojikumi row is missing {key}"))) } else { Ok(0.0) };
};
raw.trim().parse::<f64>().ok().filter(|v| v.is_finite()).ok_or_else(|| IdmlError::Invalid(format!("Mojikumi row has invalid {key}")))
};
let integer = |key: &str, required: bool| -> Result<i16> {
let v = number(key, required)?;
if v.fract() != 0.0 || v < f64::from(i16::MIN) || v > f64::from(i16::MAX) {
return Err(IdmlError::Invalid(format!("Mojikumi {key} must be an in-range integer")));
}
Ok(v as i16)
};
let boolean = |key: &str| -> Result<bool> {
match e.prop(key).as_deref().map(str::trim) {
None | Some("false") => Ok(false),
Some("true") => Ok(true),
_ => Err(IdmlError::Invalid(format!("Mojikumi row has invalid {key}"))),
}
};
let row = designcraft_doc::cjk::MojikumiAki {
target_class: integer("TargetMojikumiClass", true)?,
side_class: integer("SideMojikumiClass", true)?,
after: boolean("SideIsAfterTarget")?,
minimum: number("Minimum", false)?,
desired: number("Desired", false)?,
maximum: number("Maximum", false)?,
priority: integer("CompressionPriority", false)?,
does_not_float: boolean("AkiDoesNotFloat")?,
};
if !designcraft_doc::mojikumi::valid_range(&row) {
return Err(IdmlError::Invalid("Mojikumi row has an invalid spacing range or priority".into()));
}
Ok(row)
}
fn nums(s: &str) -> Vec<f64> {
s.split_whitespace().filter_map(|v| v.parse().ok()).collect()
}
@@ -316,23 +353,11 @@ impl<'r> Importer<'r> {
if let (Some(id), Some(name)) = (e.get("Self"), e.get("Name")) {
self.mojikumi_names.insert(id.into(), format!("MojikumiTable/{}", unescape_id(name)));
}
let overrides = e
.prop_el("OverrideMojikumiAkiList")
.map(|l| {
l.find_all("OverrideMojikumiAkiType")
.map(|r| designcraft_doc::cjk::MojikumiAki {
target_class: r.num("TargetMojikumiClass").unwrap_or(0.0) as i16,
side_class: r.num("SideMojikumiClass").unwrap_or(0.0) as i16,
after: r.boolean("SideIsAfterTarget").unwrap_or(false),
minimum: r.num("Minimum").unwrap_or(0.0),
desired: r.num("Desired").unwrap_or(0.0),
maximum: r.num("Maximum").unwrap_or(0.0),
priority: r.num("CompressionPriority").unwrap_or(0.0) as i16,
does_not_float: r.boolean("AkiDoesNotFloat").unwrap_or(false),
})
.collect()
})
.unwrap_or_default();
let overrides = if let Some(list) = e.prop_el("OverrideMojikumiAkiList") {
list.find_all("OverrideMojikumiAkiType").map(mojikumi_row).collect::<Result<Vec<_>>>()?
} else {
Vec::new()
};
self.styles.mojikumi_tables.push(designcraft_doc::cjk::MojikumiTable {
name: unescape_id(e.get("Name").unwrap_or("")),
based_on: e.get("BasedOnMojikumiSet").unwrap_or("").into(),
+32
View File
@@ -936,3 +936,35 @@ fn composite_references_resolve_by_identity_and_export_as_objects() {
let back = import_idml(&output).unwrap();
assert_eq!(back.styles.composite_fonts, d.styles.composite_fonts);
}
#[test]
fn malformed_mojikumi_rows_are_not_silently_coerced() {
let good = r#"<OverrideMojikumiAkiType TargetMojikumiClass="12" SideMojikumiClass="18" SideIsAfterTarget="true" Minimum="0" Desired="0.25" Maximum="0.5" CompressionPriority="3" AkiDoesNotFloat="false"/>"#;
for (key, value) in [
("TargetMojikumiClass", "12.9"),
("SideMojikumiClass", "65536"),
("Minimum", "NaN"),
("Desired", "oops"),
("Minimum", "0.4"),
("Maximum", "0.1"),
("CompressionPriority", "10"),
("AkiDoesNotFloat", "maybe"),
] {
let mut row = crate::xml::parse(good.as_bytes()).unwrap();
row.set(key, value);
let mut xml = String::new();
row.write(&mut xml, 0);
let map = DESIGNMAP.replace("</Document>", &format!("<MojikumiTable Name=\"Bad\" BasedOnMojikumiSet=\"SimpChineseDefault\"><Properties><OverrideMojikumiAkiList>{xml}</OverrideMojikumiAkiList></Properties></MojikumiTable></Document>"));
let bytes = zip_files(&[
("designmap.xml", &map),
("Resources/Graphic.xml", GRAPHIC),
("Resources/Styles.xml", STYLES),
("Resources/Preferences.xml", PREFS),
("MasterSpreads/MasterSpread_m1.xml", MASTER),
("Spreads/Spread_sp1.xml", SPREAD),
("Stories/Story_s1.xml", STORY),
]);
let err = import_idml(&bytes).unwrap_err().to_string();
assert!(err.contains("Mojikumi"), "{key}={value}: {err}");
}
}