CJK-REVERT2: withdraw speculative spacing rules and narrow import rejection

Restore the previous preset and character-class coverage. Remove regional punctuation and Tsume policy changes while retaining cluster, tracking, and spacing-allocation repairs. Preserve finite unsupported rows for diagnostics instead of rejecting whole documents.
This commit is contained in:
Shao Wenbin
2026-10-08 20:36:21 +08:00
parent 56febef6ab
commit 9f655739fa
8 changed files with 70 additions and 247 deletions
+3 -4
View File
@@ -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.
+2 -4
View File
@@ -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);
-2
View File
@@ -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;
}
+2 -2
View File
@@ -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();
+26 -191
View File
@@ -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<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 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<Aki> {
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());
+10 -13
View File
@@ -144,14 +144,14 @@ fn lab_to_color(l: f32, a: f32, b: f32) -> Color {
}
fn mojikumi_row(e: &El) -> Result<designcraft_doc::cjk::MojikumiAki> {
let number = |key: &str, required: bool| -> Result<f64> {
let number = |key: &str| -> 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) };
return 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)?;
let integer = |key: &str| -> Result<i16> {
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<designcraft_doc::cjk::MojikumiAki> {
}
};
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)
}
+13 -2
View File
@@ -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}");
}
}
}
+14 -29
View File
@@ -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).