From 14b04cbaf8954aa9f2a28442dce4d2dc5c19be6b Mon Sep 17 00:00:00 2001 From: Shao Wenbin Date: Thu, 8 Oct 2026 20:01:57 +0800 Subject: [PATCH] CJK-MOJI4: implement public preset boundary policies and punctuation classes --- ROADMAP.md | 2 + crates/doc/src/mojikumi.rs | 150 ++++++++++++++++++++++++++++++++----- docs/mojikumi.md | 33 ++++++++ 3 files changed, 168 insertions(+), 17 deletions(-) create mode 100644 docs/mojikumi.md diff --git a/ROADMAP.md b/ROADMAP.md index 961368d44..9c452b9ad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # DesignCraft roadmap +- 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. - CJK-MOJI2: share Mojikumi pair and line-edge metrics between breaking and placement; honor spacing bounds, compression priorities, explicit aki and kinsoku policies. diff --git a/crates/doc/src/mojikumi.rs b/crates/doc/src/mojikumi.rs index 72056e7ca..bff676c9f 100644 --- a/crates/doc/src/mojikumi.rs +++ b/crates/doc/src/mojikumi.rs @@ -19,9 +19,74 @@ 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 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(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 + leading(right) })); + } + if left == 22 { + return Some(Aki::fixed(if n >= 15 || matches!(n, 4 | 5 | 10) { leading(right) } else { 0.0 })); + } + if right == 22 { + let width = 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> { - chinese: bool, + preset: Preset, rows: &'a [MojikumiAki], } impl<'a> Rules<'a> { @@ -32,11 +97,7 @@ 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 chinese = match base.trim_start_matches("$ID/") { - "SimpChineseDefault" | "TradChineseDefault" => true, - "LineEndAllOneHalfEmEnum" | "kMojikumiDefaultName1" => false, - _ => return Err(format!("unknown base preset `{base}`")), - }; + let preset = Preset::parse(base).ok_or_else(|| 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) { @@ -50,7 +111,7 @@ impl<'a> Rules<'a> { return Err("invalid spacing range or priority".into()); } } - Ok(Some(Self { chinese, rows })) + Ok(Some(Self { preset, rows })) } pub fn pair(&self, left: i16, right: i16) -> Aki { @@ -68,13 +129,15 @@ impl<'a> Rules<'a> { discrete: row.does_not_float, }; } - // 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 }); + if let Some(aki) = self.preset.boundary(left, right) { + return aki; } - if right == 22 { - return Aki::fixed(if self.chinese { trailing(left) } 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); } let punctuation = trailing(left).max(leading(right)); if punctuation > 0.0 { @@ -131,13 +194,42 @@ pub fn class(c: char) -> i16 { '¥' | '$' | '£' | '¥' => 8, '%' | '‰' | '℃' | '°' => 9, '\u{3000}' => 10, - 'ぁ' | 'ぃ' | 'ぅ' | 'ぇ' | 'ぉ' | 'っ' | 'ゃ' | 'ゅ' | 'ょ' | 'ゎ' | 'ァ' | 'ィ' | 'ゥ' | 'ェ' | 'ォ' | 'ッ' | 'ャ' | 'ュ' | 'ョ' | 'ヮ' - | 'ー' | '々' | 'ゝ' | 'ゞ' | 'ヽ' | 'ヾ' => 3, + 'ゕ' + | 'ゖ' + | 'ヵ' + | 'ヶ' + | '\u{31f0}'..='\u{31ff}' + | 'ぁ' + | 'ぃ' + | 'ぅ' + | 'ぇ' + | 'ぉ' + | 'っ' + | 'ゃ' + | 'ゅ' + | 'ょ' + | 'ゎ' + | 'ァ' + | 'ィ' + | 'ゥ' + | 'ェ' + | 'ォ' + | 'ッ' + | 'ャ' + | 'ュ' + | 'ョ' + | 'ヮ' + | 'ー' + | '々' + | 'ゝ' + | 'ゞ' + | 'ヽ' + | 'ヾ' => 3, '\u{3040}'..='\u{309f}' => 11, - '\u{30a0}'..='\u{30ff}' | '\u{31f0}'..='\u{31ff}' => 33, + '\u{30a0}'..='\u{30ff}' | '\u{ff66}'..='\u{ff9d}' => 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{323af}' => 12, + '\u{2e80}'..='\u{a4cf}' | '\u{ac00}'..='\u{d7af}' | '\u{f900}'..='\u{faff}' | '\u{ff01}'..='\u{ff60}' | '\u{20000}'..='\u{3347f}' => 12, _ => 18, } } @@ -147,6 +239,30 @@ mod tests { use super::*; use crate::cjk::MojikumiTable; + #[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(); diff --git a/docs/mojikumi.md b/docs/mojikumi.md new file mode 100644 index 000000000..c9390d58d --- /dev/null +++ b/docs/mojikumi.md @@ -0,0 +1,33 @@ +# Mojikumi implementation and public references + +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. + +References: + +- [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 + +## 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.