From 45032b527f93f61badeddc79bcabb8f98a27e343 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 11 Oct 2026 04:38:04 +0000 Subject: [PATCH] CJK: mojikumi tables based on Nothing and the one-em line-end set apply their rules --- crates/compose/src/tests.rs | 23 +++++++++++ crates/doc/src/mojikumi.rs | 76 +++++++++++++++++++++++++++++++------ crates/engine/src/tests.rs | 8 +++- crates/idml/src/tests.rs | 65 +++++++++++++++++++++++++++++++ 4 files changed, 159 insertions(+), 13 deletions(-) diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 69e74f1a7..3df0cfeb4 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2342,3 +2342,26 @@ fn mojikumi_does_not_restore_fullwidth_blanks_to_narrow_tracked_punctuation() { assert!((a.adv - b.adv).abs() < 0.001); } } + +#[test] +fn mojikumi_one_em_line_end_set_sets_closing_punctuation_full_width_at_line_end() { + let family = "Mojikumi Full Stop"; + let font = designcraft_fonts::testing::font_mapping(family, &[('(', '‰'), ('一', '‰'), ('。', '‰')]).unwrap(); + designcraft_fonts::FontDb::global().add_font(font); + let text = "(一。"; + let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 200.0, 100.0), ParaAttrs::default()); + d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.font_family = Some(family.into())); + // Advances of the opening bracket at line start and the full stop at line end. + let mut edges = |set: &str| { + d.story_mut(sid).unwrap().paras[0].para.mojikumi = Some(set.into()); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let line = all_lines(&cs)[0]; + let adv = |c| line.glyphs.iter().find(|g| g.rendered_char == c).map(|g| (g.adv, g.face.units_per_em() * g.sx)).unwrap(); + (adv('(').0, adv('。').0, adv('。').1) + }; + let (open, stop, em) = edges("Nothing"); + let half = edges("LineEndAllOneHalfEmEnum"); + let one = edges("LineEndAllOneEmEnum"); + assert!((open - half.0 - em * 0.5).abs() < 0.01 && (stop - half.1 - em * 0.5).abs() < 0.01, "half-em set: {half:?} of {open}/{stop}"); + assert!((open - one.0 - em * 0.5).abs() < 0.01 && (stop - one.1).abs() < 0.01, "one-em set: {one:?} of {open}/{stop}"); +} diff --git a/crates/doc/src/mojikumi.rs b/crates/doc/src/mojikumi.rs index 0b95d2b71..415e26bc3 100644 --- a/crates/doc/src/mojikumi.rs +++ b/crates/doc/src/mojikumi.rs @@ -19,9 +19,20 @@ impl Aki { } } +/// Built-in base sets. They differ only at line boundaries. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Base { + /// Opening punctuation at line start and closing punctuation at line end take ½ em. + LineEndHalfEm, + /// As `LineEndHalfEm`, but closing punctuation at line end keeps its full 1 em. + LineEndOneEm, + /// Punctuation keeps its full em at both line boundaries. + Chinese, +} + /// Resolved table; custom rows override the base preset in document order. pub struct Rules<'a> { - chinese: bool, + base: Base, rows: &'a [MojikumiAki], assumed_base: bool, } @@ -33,16 +44,19 @@ impl<'a> Rules<'a> { let key = name.strip_prefix("MojikumiTable/").unwrap_or(name); let table = styles.mojikumi_tables.iter().find(|t| t.name == key); // A custom table whose base set is missing (IDML leaves `BasedOnMojikumiSet` - // out when it cannot name the base) keeps its rows on the half-em default set. - let assumed_base = table.is_some_and(|t| t.based_on.is_empty()); + // out when it cannot name the base) or is `Nothing` keeps its rows on the + // half-em default set. + let baseless = |t: &crate::cjk::MojikumiTable| matches!(t.based_on.trim_start_matches("$ID/"), "" | "Nothing"); + let assumed_base = table.is_some_and(baseless); let base = match table { - Some(t) if t.based_on.is_empty() => "LineEndAllOneHalfEmEnum", + Some(t) if baseless(t) => "LineEndAllOneHalfEmEnum", Some(t) => t.based_on.as_str(), None => key, }; - let chinese = match base.trim_start_matches("$ID/") { - "SimpChineseDefault" | "TradChineseDefault" => true, - "LineEndAllOneHalfEmEnum" | "kMojikumiDefaultName1" => false, + let base_set = match base.trim_start_matches("$ID/") { + "SimpChineseDefault" | "TradChineseDefault" => Base::Chinese, + "LineEndAllOneHalfEmEnum" | "kMojikumiDefaultName1" => Base::LineEndHalfEm, + "LineEndAllOneEmEnum" | "kMojikumiDefaultName10" => Base::LineEndOneEm, _ => return Err(format!("unknown base preset `{base}`")), }; let rows = table.map_or(&[][..], |t| t.overrides.as_slice()); @@ -58,7 +72,7 @@ impl<'a> Rules<'a> { return Err("invalid spacing range or priority".into()); } } - Ok(Some(Self { chinese, rows, assumed_base })) + Ok(Some(Self { base: base_set, rows, assumed_base })) } /// True when the table names no base set and the default one was used. @@ -103,13 +117,14 @@ 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. + // Boundaries: Chinese keeps full punctuation; the Japanese line-end sets + // trim opening punctuation at line start, and the half-em set also trims + // closing punctuation at line end. if matches!(left, 22 | 23) { - return Aki::fixed(if self.chinese { leading(right) } else { 0.0 }); + return Aki::fixed(if self.base == Base::Chinese { leading(right) } else { 0.0 }); } if right == 22 { - return Aki::fixed(if self.chinese { trailing(left) } else { 0.0 }); + return Aki::fixed(if self.base == Base::LineEndHalfEm { 0.0 } else { trailing(left) }); } let punctuation = trailing(left).max(leading(right)); if punctuation > 0.0 { @@ -221,4 +236,41 @@ mod tests { assert_ne!(class(','), class('、')); assert_ne!(class('「'), class('(')); } + + #[test] + fn one_em_line_end_set_keeps_closing_punctuation_full_at_line_end_only() { + let styles = Styles::default(); + let one = Rules::resolve(&styles, "LineEndAllOneEmEnum").unwrap().unwrap(); + let half = Rules::resolve(&styles, "LineEndAllOneHalfEmEnum").unwrap().unwrap(); + assert_eq!(one.pair(class('。'), 22).desired, 0.5); + assert_eq!(one.pair(class('」'), 22).desired, 0.5); + assert_eq!(one.pair(22, class('(')).desired, 0.0); + assert_eq!(one.pair(class('。'), class('一')).desired, half.pair(class('。'), class('一')).desired); + assert!(Rules::resolve(&styles, "$ID/kMojikumiDefaultName10").unwrap().is_some()); + } + + #[test] + fn nothing_base_set_behaves_like_a_missing_base_set() { + let row = MojikumiAki { + target_class: 12, + side_class: 18, + after: true, + minimum: 0.25, + desired: 0.75, + maximum: 1.0, + priority: 2, + does_not_float: false, + }; + let mut styles = Styles::default(); + for (name, based_on) in [("Missing", ""), ("Nothing", "Nothing")] { + styles.mojikumi_tables.push(MojikumiTable { name: name.into(), based_on: based_on.into(), overrides: vec![row.clone()] }); + } + let missing = Rules::resolve(&styles, "MojikumiTable/Missing").unwrap().unwrap(); + let nothing = Rules::resolve(&styles, "MojikumiTable/Nothing").unwrap().unwrap(); + assert!(nothing.assumed_base()); + for (l, r) in [(12, 18), (22, class('(')), (class('。'), 22), (12, 12)] { + let (a, b) = (missing.pair(l, r), nothing.pair(l, r)); + assert_eq!((a.min, a.desired, a.max, a.priority), (b.min, b.desired, b.max, b.priority), "{l}/{r}"); + } + } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 65a6644d6..814c6fef4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -572,7 +572,13 @@ fn preflight_mojikumi_uses_the_same_supported_rules_as_composition() { s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "中文 A1"})).unwrap(); let story = s.doc().unwrap().doc.stories.keys().next().unwrap().0; s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 1})).unwrap(); - for (name, unsupported) in [("SimpChineseDefault", false), ("LineEndAllOneHalfEmEnum", false), ("UnknownPreset", true), ("Nothing", false)] { + for (name, unsupported) in [ + ("SimpChineseDefault", false), + ("LineEndAllOneHalfEmEnum", false), + ("LineEndAllOneEmEnum", false), + ("UnknownPreset", true), + ("Nothing", false), + ] { s.execute("type.para", &json!({"attrs": {"mojikumi": name, "kinsokuType": "KinsokuPushInFirst"}})).unwrap(); let result = s.execute("preflight.run", &json!({})).unwrap(); let warnings: Vec<_> = result["issues"].as_array().unwrap().iter().filter(|i| i["kind"] == "unsupportedTypography").collect(); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 4ecd9facb..4dbfb72c3 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -845,6 +845,71 @@ fn cjk_mojikumi_table_without_base_set_applies_its_rows() { assert_eq!(back.styles.mojikumi_tables, d.styles.mojikumi_tables); } +/// `BasedOnMojikumiSet="Nothing"` names no base set: the table resolves as one +/// without the attribute, and the value survives export. +#[test] +fn cjk_mojikumi_table_based_on_nothing_matches_a_table_without_base_set() { + let row = r#""#; + let map = DESIGNMAP.replace("", &format!(r#"{row} + {row}"#)); + let story = STORY.replace("", r#" + + "#); + let story = STORY.replacen("