mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-25 02:37:00 -06:00
CJK-MOJI4: implement public preset boundary policies and punctuation classes
This commit is contained in:
@@ -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
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user