CJK-MOJI4: implement public preset boundary policies and punctuation classes

This commit is contained in:
Shao Wenbin
2026-10-08 20:01:57 +08:00
parent d16a7423b8
commit 14b04cbaf8
3 changed files with 168 additions and 17 deletions
+2
View File
@@ -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.
+133 -17
View File
@@ -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<Self> {
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::<u8>().ok()?;
(1..=16).contains(&n).then_some(Self(n))
})
}
fn boundary(self, left: i16, right: i16) -> Option<Aki> {
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();
+33
View File
@@ -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.