diff --git a/ROADMAP.md b/ROADMAP.md index 27d2e892..eef038e6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,10 +1,9 @@ # DesignCraft roadmap -- CJK-MOJI8: preserve shaped narrow punctuation and manual Tsume instead of restoring nonexistent full-width blank space. -- 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-MOJI8: preserve shaped narrow punctuation when tracking changes its advance. +- CJK-MOJI7: prevent numeric coercion in Mojikumi import, preserve finite unsupported rules, and check nested note/cell rules in preflight. +- CJK-MOJI6: apply spacing at shaped-cluster boundaries without moving attached marks. - 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. - CJK-MOJI3: use the composition rule resolver in preflight, report unknown Mojikumi presets and retain regression coverage for supported and unsupported settings. diff --git a/crates/compose/src/mojikumi.rs b/crates/compose/src/mojikumi.rs index 0c44e546..a1ca09c9 100644 --- a/crates/compose/src/mojikumi.rs +++ b/crates/compose/src/mojikumi.rs @@ -10,8 +10,6 @@ pub struct Metrics { pub active: bool, /// Shaped body width before tracking and manual character spacing. pub body: f64, - pub tsume_before: f64, - pub tsume_after: f64, pub blank_before: f64, pub blank_after: f64, /// Desired internal gap attached to this glyph's advance. @@ -71,8 +69,8 @@ pub fn apply(glyphs: &mut [Glyph], styles: &Styles, name: &str) { // Tracking must not make a proportional or half-width glyph appear to // contain a removable half-em body. Shaping features already affect body. if body >= unit * 0.8 { - let before = if cluster[0].moji.explicit_before { 0.0 } else { (table.leading(class) * unit - cluster[0].moji.tsume_before).max(0.0) }; - let after = if cluster[0].moji.explicit_after { 0.0 } else { (table.trailing(class) * unit - cluster[0].moji.tsume_after).max(0.0) }; + let before = if cluster[0].moji.explicit_before { 0.0 } else { rules::leading(class) * unit }; + let after = if cluster[0].moji.explicit_after { 0.0 } else { rules::trailing(class) * unit }; let trim = before + after; if let Some(owner) = cluster.iter().position(|g| g.adv >= trim) { cluster[0].moji.blank_before = before / unit.max(1e-9); diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index bbf91cbb..e1d46aa2 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -477,8 +477,6 @@ fn shape_run( let tsume = p.tsume.clamp(0.0, 1.0); let before = p.leading_aki.unwrap_or(0.0).clamp(0.0, 100.0) * em; let after = p.trailing_aki.unwrap_or(0.0).clamp(0.0, 100.0) * em; - g.moji.tsume_before = left * tsume; - g.moji.tsume_after = right * tsume; g.dx += before - left * tsume; g.adv += before + after - (left + right) * tsume; } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 33e0c7a9..32e0e90c 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -706,8 +706,8 @@ impl Document { } } for t in &self.styles.mojikumi_tables { - if t.overrides.iter().any(|r| !mojikumi::valid_range(r)) { - return Err(DocError::Invalid(format!("invalid mojikumi spacing: {}", t.name))); + 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))); } } let mut ids = std::collections::HashSet::new(); diff --git a/crates/doc/src/mojikumi.rs b/crates/doc/src/mojikumi.rs index aaedd355..fbb8a13d 100644 --- a/crates/doc/src/mojikumi.rs +++ b/crates/doc/src/mojikumi.rs @@ -19,92 +19,9 @@ impl Aki { } } -/// Adobe's public 16-preset enumeration. Japanese preset geometry is described -/// in the Adobe Mojikumi guide, sections 3.1–3.14 (see docs/mojikumi.md). -#[derive(Clone, Copy, Debug)] -struct Preset(u8); -impl Preset { - fn parse(name: &str) -> Option { - let name = name.trim_start_matches("$ID/"); - let names = [ - "LineEndAllOneHalfEmEnum", - "OneEmIndentLineEndUkeOneHalfEmEnum", - "OneOrOneHalfEmIndentLineEndUkeOneHalfEmEnum", - "OneOrOneHalfEmIndentLineEndAllOneEmEnum", - "OneEmIndentLineEndAllOneEmEnum", - "OneEmIndentLineEndAllNoFloatEnum", - "OneEmIndentLineEndUkeNoFloatEnum", - "OneOrOneHalfEmIndentLineEndUkeNoFloatEnum", - "OneEmIndentLineEndAllOneHalfEmEnum", - "LineEndAllOneEmEnum", - "LineEndUkeNoFloatEnum", - "OneOrOneHalfEmIndentLineEndPeriodOneEmEnum", - "OneEmIndentLineEndPeriodOneEmEnum", - "LineEndPeriodOneEmEnum", - "TradChineseDefault", - "SimpChineseDefault", - ]; - names.iter().position(|n| *n == name).map(|i| Self(i as u8 + 1)).or_else(|| { - let n = name.strip_prefix("kMojikumiDefaultName")?.parse::().ok()?; - (1..=16).contains(&n).then_some(Self(n)) - }) - } - fn leading(self, c: i16) -> f64 { - if self.0 == 15 && matches!(c, 6 | 21 | 30 | 31) { - 0.25 - } else if self.0 == 16 && c == 32 { - 0.0 - } else { - leading(c) - } - } - fn trailing(self, c: i16) -> f64 { - if self.0 == 15 && matches!(c, 6 | 21 | 30 | 31) { - 0.25 - } else if self.0 == 16 && c == 32 { - 0.5 - } else { - trailing(c) - } - } - fn boundary(self, left: i16, right: i16) -> Option { - let n = self.0; - let open = |c| matches!(c, 1 | 26 | 27); - if left == 23 { - if n >= 15 { - return Some(Aki::fixed(self.leading(right))); - } - let indent = if matches!(n, 1 | 10 | 11 | 14) { 0.0 } else { 1.0 }; - let opening = match n { - 2 | 5 | 7 | 13 => 1.5, - 3 | 8 | 10 => 0.5, - 4 | 6 | 9 | 12 => 1.0, - _ => 0.0, - }; - return Some(Aki::fixed(if open(right) { opening } else { indent + self.leading(right) })); - } - if left == 22 { - return Some(Aki::fixed(if n >= 15 || matches!(n, 4 | 5 | 10) { self.leading(right) } else { 0.0 })); - } - if right == 22 { - let width = self.trailing(left); - let full = n >= 15 || matches!(n, 4 | 5 | 10) || (matches!(n, 12..=14) && matches!(left, 6 | 31)); - let discrete = n == 6 || (matches!(n, 7 | 8 | 11) && matches!(left, 2 | 6 | 21 | 28..=31)); - return Some(if full { - Aki::fixed(width) - } else if discrete { - Aki { min: 0.0, desired: width, max: width, priority: 1, discrete: true } - } else { - Aki::fixed(0.0) - }); - } - None - } -} - /// Resolved table; custom rows override the base preset in document order. pub struct Rules<'a> { - preset: Preset, + chinese: bool, rows: &'a [MojikumiAki], } impl<'a> Rules<'a> { @@ -115,25 +32,25 @@ impl<'a> Rules<'a> { let key = name.strip_prefix("MojikumiTable/").unwrap_or(name); let table = styles.mojikumi_tables.iter().find(|t| t.name == key); let base = table.map_or(key, |t| if t.based_on.is_empty() { t.name.as_str() } else { t.based_on.as_str() }); - let preset = Preset::parse(base).ok_or_else(|| format!("unknown base preset `{base}`"))?; + let chinese = match base.trim_start_matches("$ID/") { + "SimpChineseDefault" | "TradChineseDefault" => true, + "LineEndAllOneHalfEmEnum" | "kMojikumiDefaultName1" => false, + _ => return Err(format!("unknown base preset `{base}`")), + }; let rows = table.map_or(&[][..], |t| t.overrides.as_slice()); for r in rows { if !valid_class(r.target_class) || !valid_class(r.side_class) { return Err(format!("unsupported character classes {}/{}", r.target_class, r.side_class)); } - if !valid_range(r) { + 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) + { return Err("invalid spacing range or priority".into()); } } - Ok(Some(Self { preset, rows })) - } - - /// Blank body portions for the preset's regional punctuation convention. - pub fn leading(&self, class: i16) -> f64 { - self.preset.leading(class) - } - pub fn trailing(&self, class: i16) -> f64 { - self.preset.trailing(class) + Ok(Some(Self { chinese, rows })) } fn override_pair(&self, left: i16, right: i16) -> Option<&MojikumiAki> { @@ -149,11 +66,11 @@ impl<'a> Rules<'a> { let mut aki = self.pair(left, right); if self.override_pair(left, right).is_none() { let missing = if matches!(left, 22 | 23) { - self.leading(right) - before_right + leading(right) - before_right } else if right == 22 { - self.trailing(left) - after_left + trailing(left) - after_left } else { - self.trailing(left).max(self.leading(right)) - after_left.max(before_right) + trailing(left).max(leading(right)) - after_left.max(before_right) } .max(0.0); aki.min = (aki.min - missing).max(0.0); @@ -173,17 +90,15 @@ impl<'a> Rules<'a> { discrete: row.does_not_float, }; } - if let Some(aki) = self.preset.boundary(left, right) { - return aki; + // Boundaries: Chinese keeps full punctuation; the half-em Japanese set + // trims opening punctuation at line start and closing punctuation at end. + if matches!(left, 22 | 23) { + return Aki::fixed(if self.chinese { leading(right) } else { 0.0 }); } - // Adjacent opening brackets and adjacent closing punctuation share a - // half-body, rather than restoring both glyphs to their full widths. - if (matches!(left, 1 | 26 | 27) && matches!(right, 1 | 26 | 27)) - || (matches!(left, 2 | 6 | 21 | 28..=31) && matches!(right, 2 | 6 | 21 | 28..=31)) - { - return Aki::fixed(0.0); + if right == 22 { + return Aki::fixed(if self.chinese { trailing(left) } else { 0.0 }); } - let punctuation = self.trailing(left).max(self.leading(right)); + let punctuation = trailing(left).max(leading(right)); if punctuation > 0.0 { return Aki { min: 0.0, desired: punctuation, max: punctuation, priority: 1, discrete: false }; } @@ -199,15 +114,6 @@ 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) } @@ -247,42 +153,13 @@ pub fn class(c: char) -> i16 { '¥' | '$' | '£' | '¥' => 8, '%' | '‰' | '℃' | '°' => 9, '\u{3000}' => 10, - 'ゕ' - | 'ゖ' - | 'ヵ' - | 'ヶ' - | '\u{31f0}'..='\u{31ff}' - | 'ぁ' - | 'ぃ' - | 'ぅ' - | 'ぇ' - | 'ぉ' - | 'っ' - | 'ゃ' - | 'ゅ' - | 'ょ' - | 'ゎ' - | 'ァ' - | 'ィ' - | 'ゥ' - | 'ェ' - | 'ォ' - | 'ッ' - | 'ャ' - | 'ュ' - | 'ョ' - | 'ヮ' - | 'ー' - | '々' - | 'ゝ' - | 'ゞ' - | 'ヽ' - | 'ヾ' => 3, + 'ぁ' | 'ぃ' | 'ぅ' | 'ぇ' | 'ぉ' | 'っ' | 'ゃ' | 'ゅ' | 'ょ' | 'ゎ' | 'ァ' | 'ィ' | 'ゥ' | 'ェ' | 'ォ' | 'ッ' | 'ャ' | 'ュ' | 'ョ' | 'ヮ' + | 'ー' | '々' | 'ゝ' | 'ゞ' | 'ヽ' | 'ヾ' => 3, '\u{3040}'..='\u{309f}' => 11, - '\u{30a0}'..='\u{30ff}' | '\u{ff66}'..='\u{ff9d}' => 33, + '\u{30a0}'..='\u{30ff}' | '\u{31f0}'..='\u{31ff}' => 33, '0'..='9' => 24, '0'..='9' => 25, - '\u{2e80}'..='\u{a4cf}' | '\u{ac00}'..='\u{d7af}' | '\u{f900}'..='\u{faff}' | '\u{ff01}'..='\u{ff60}' | '\u{20000}'..='\u{3347f}' => 12, + '\u{2e80}'..='\u{a4cf}' | '\u{ac00}'..='\u{d7af}' | '\u{f900}'..='\u{faff}' | '\u{ff01}'..='\u{ff60}' | '\u{20000}'..='\u{323af}' => 12, _ => 18, } } @@ -292,47 +169,6 @@ mod tests { use super::*; use crate::cjk::MojikumiTable; - #[test] - fn regional_punctuation_retains_the_correct_side_bearings() { - let styles = Styles::default(); - let traditional = Rules::resolve(&styles, "TradChineseDefault").unwrap().unwrap(); - let simplified = Rules::resolve(&styles, "SimpChineseDefault").unwrap().unwrap(); - assert_eq!(traditional.leading(class('。')), 0.25); - assert_eq!(traditional.trailing(class('。')), 0.25); - assert_eq!(simplified.leading(class('。')), 0.0); - assert_eq!(simplified.trailing(class('。')), 0.5); - assert_eq!(traditional.pair(12, class('。')).desired, 0.25); - assert_eq!(simplified.pair(12, class('。')).desired, 0.0); - assert_eq!(simplified.leading(class(':')), 0.0); - assert_eq!(simplified.trailing(class(':')), 0.5); - assert!((simplified.pair_with_bodies(6, 12, 0.1, 0.0).desired - 0.1).abs() < 1e-9); - assert_eq!(simplified.pair_with_bodies(22, 27, 0.0, 0.0).desired, 0.0); - } - - #[test] - fn all_public_presets_have_distinct_documented_edges() { - let styles = Styles::default(); - let openings = [0.0, 1.5, 0.5, 1.0, 1.5, 1.0, 1.5, 0.5, 1.0, 0.5, 0.0, 1.0, 1.5, 0.0]; - for (i, expected) in openings.into_iter().enumerate() { - let rules = Rules::resolve(&styles, &format!("$ID/kMojikumiDefaultName{}", i + 1)).unwrap().unwrap(); - assert_eq!(rules.pair(23, 26).desired, expected, "preset {}", i + 1); - assert_eq!(rules.pair(23, 12).desired, if matches!(i + 1, 1 | 10 | 11 | 14) { 0.0 } else { 1.0 }); - } - let discrete = Rules::resolve(&styles, "OneEmIndentLineEndUkeNoFloatEnum").unwrap().unwrap(); - assert!(discrete.pair(6, 22).discrete); - assert!(!discrete.pair(5, 22).discrete); - let period = Rules::resolve(&styles, "LineEndPeriodOneEmEnum").unwrap().unwrap(); - assert_eq!(period.pair(6, 22).desired, 0.5); - assert_eq!(period.pair(21, 22).desired, 0.0); - for name in ["$ID/kMojikumiDefaultName15", "$ID/kMojikumiDefaultName16"] { - assert!(Rules::resolve(&styles, name).unwrap().is_some()); - } - assert_eq!(period.pair(26, 26).desired, 0.0); - assert_eq!(period.pair(28, 6).desired, 0.0); - assert_eq!(class('ㇰ'), 3); - assert_eq!(class('カ'), 33); - } - #[test] fn overrides_are_directional_and_unknown_tables_are_not_silently_applied() { let mut styles = Styles::default(); @@ -352,7 +188,6 @@ mod tests { }); let rule = Rules::resolve(&styles, "MojikumiTable/Pair").unwrap().unwrap(); assert_eq!(rule.pair(18, 12).desired, 0.2); - assert_eq!(rule.pair_with_bodies(18, 12, 0.0, 0.0).desired, 0.2); assert_eq!(rule.pair(12, 18).desired, 0.25); assert!(Rules::resolve(&styles, "Unknown").is_err()); assert!(Rules::resolve(&styles, "Nothing").unwrap().is_none()); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 93206272..fba7ae66 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -144,14 +144,14 @@ fn lab_to_color(l: f32, a: f32, b: f32) -> Color { } fn mojikumi_row(e: &El) -> Result { - let number = |key: &str, required: bool| -> Result { + let number = |key: &str| -> Result { let Some(raw) = e.prop(key) else { - return if required { Err(IdmlError::Invalid(format!("Mojikumi row is missing {key}"))) } else { Ok(0.0) }; + return Ok(0.0); }; raw.trim().parse::().ok().filter(|v| v.is_finite()).ok_or_else(|| IdmlError::Invalid(format!("Mojikumi row has invalid {key}"))) }; - let integer = |key: &str, required: bool| -> Result { - let v = number(key, required)?; + let integer = |key: &str| -> Result { + let v = number(key)?; 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"))); } @@ -165,18 +165,15 @@ fn mojikumi_row(e: &El) -> Result { } }; let row = designcraft_doc::cjk::MojikumiAki { - target_class: integer("TargetMojikumiClass", true)?, - side_class: integer("SideMojikumiClass", true)?, + target_class: integer("TargetMojikumiClass")?, + side_class: integer("SideMojikumiClass")?, after: boolean("SideIsAfterTarget")?, - minimum: number("Minimum", false)?, - desired: number("Desired", false)?, - maximum: number("Maximum", false)?, - priority: integer("CompressionPriority", false)?, + minimum: number("Minimum")?, + desired: number("Desired")?, + maximum: number("Maximum")?, + priority: integer("CompressionPriority")?, 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) } diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 79f24248..11fb11b8 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -947,6 +947,7 @@ fn malformed_mojikumi_rows_are_not_silently_coerced() { ("Desired", "oops"), ("Minimum", "0.4"), ("Maximum", "0.1"), + ("Maximum", "101"), ("CompressionPriority", "10"), ("AkiDoesNotFloat", "maybe"), ] { @@ -964,7 +965,17 @@ fn malformed_mojikumi_rows_are_not_silently_coerced() { ("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}"); + if matches!((key, value), ("Minimum", "0.4") | ("Maximum", "0.1" | "101") | ("CompressionPriority", "10")) { + // Finite, representable rules survive import even if composition + // cannot apply them. The resolver supplies a diagnostic instead. + let document = import_idml(&bytes).unwrap(); + document.check().unwrap(); + assert!(designcraft_doc::mojikumi::Rules::resolve(&document.styles, "MojikumiTable/Bad").is_err()); + let restored = import_idml(&export_idml(&document)).unwrap(); + assert_eq!(restored.styles.mojikumi_tables, document.styles.mojikumi_tables); + } else { + let err = import_idml(&bytes).unwrap_err().to_string(); + assert!(err.contains("Mojikumi"), "{key}={value}: {err}"); + } } } diff --git a/docs/mojikumi.md b/docs/mojikumi.md index c9390d58..fcdf2807 100644 --- a/docs/mojikumi.md +++ b/docs/mojikumi.md @@ -1,33 +1,18 @@ -# Mojikumi implementation and public references +# Mojikumi repair scope -This is a clean-room implementation of public spacing behavior. Mojikumi is an -Adobe feature, not a single conformance standard. W3C JLReq and CLReq describe -Japanese and Chinese layout requirements; they do not specify Adobe's private -composer or its complete preset pair matrices. +The retained changes address reproducible implementation defects: equal-priority +spacing allocation, discrete compression endpoints, leading-only compression, +spacing after complete shaped clusters, and tracking that made narrow punctuation +look full-width. Preflight also checks nested note and table-cell paragraph rules. -References: +IDML import rejects malformed/non-finite numbers and integer coercion. Finite, +representable rules outside the composer's supported range remain preserved; +the rule resolver reports unsupported settings instead of rejecting the document. -- [Adobe preset enumeration](https://developer.adobe.com/indesign/uxp/dom/api/m/mojikumi-table-defaults/) -- [Adobe Mojikumi guide, sections 3–5](https://wwwimages.adobe.com/www.adobe.com/jp/joc/design/guides/pdf/mojikumi.pdf) -- [Adobe spacing priorities](https://helpx.adobe.com/indesign/desktop/language-and-proofing/chinese-japanese-and-korean/set-spacing-priorities-in-mojikumi-character-classes.html) -- [W3C JLReq](https://www.w3.org/TR/jlreq/), especially character classes and line adjustment -- [W3C CLReq](https://www.w3.org/TR/clreq/), especially regional punctuation placement +The broad preset expansion, regional side-bearing changes, Tsume combination +policy, and CID preference/diagnostic extension were withdrawn after review. +The original supported preset set and Unicode classification remain in place. +No claim of full Adobe Mojikumi conformance or exact output parity is made. -## Presets and interchange - -The resolver recognizes the 14 Japanese presets and two Chinese presets from the -public enumeration, including their IDML resource aliases. Paragraph indentation, -opening-bracket indentation, ordinary line starts, full/half/discrete line ends, -and period-only line-end policies are represented separately. Custom IDML rows -replace the corresponding directional pair. Adjacent opening and closing marks -must not regain two full blank half-bodies. - -Regression tests cover preset boundary differences, custom override precedence, -IDML reference round trips, and unsupported names. The implementation must not be -represented as a byte-for-byte reproduction of Adobe's full preset matrices. - -## Audit work - -Further audit areas are regional punctuation metrics, shaped cluster boundaries, -equal-priority allocation, discrete line fitting, input validation, and diagnostics -for compatibility modes whose glyph classification cannot yet be reproduced. +Reference for priority semantics: +[Adobe spacing priorities](https://helpx.adobe.com/indesign/desktop/language-and-proofing/chinese-japanese-and-korean/set-spacing-priorities-in-mojikumi-character-classes.html).