Merge mojikumi-base: CJK: mojikumi tables based on Nothing and the one-em line-end set apply their rules

Conflict: crates/compose/src/tests.rs: next's tests, plus the branch's one-em line-end test after its neighbour
This commit is contained in:
aiongg
2026-10-11 05:18:58 +00:00
4 changed files with 159 additions and 13 deletions
+23
View File
@@ -4712,6 +4712,29 @@ fn mojikumi_does_not_restore_fullwidth_blanks_to_narrow_tracked_punctuation() {
}
}
#[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}");
}
fn ruled(text: &str, rect: Rect, columns: u32, edit: impl FnOnce(&mut Document, StoryId)) -> (Document, ItemId, Vec<Rect>) {
let (mut d, sid, fid) = doc_with(text, rect, ParaAttrs::default());
{
+64 -12
View File
@@ -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}");
}
}
}
+7 -1
View File
@@ -832,7 +832,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();
+65
View File
@@ -1467,6 +1467,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#"<OverrideMojikumiAkiType TargetMojikumiClass="12" SideMojikumiClass="18" SideIsAfterTarget="true" Minimum="0.25" Desired="0.75" Maximum="1" CompressionPriority="2" AkiDoesNotFloat="false"/>"#;
let map = DESIGNMAP.replace("</Document>", &format!(r#"<MojikumiTable Self="uMojikumi2" Name="Baseless"><Properties><OverrideMojikumiAkiList>{row}</OverrideMojikumiAkiList></Properties></MojikumiTable>
<MojikumiTable Self="uMojikumi3" Name="NoBase" BasedOnMojikumiSet="Nothing"><Properties><OverrideMojikumiAkiList>{row}</OverrideMojikumiAkiList></Properties></MojikumiTable></Document>"#));
let story = STORY.replace("<ParagraphStyleRange ", "<ParagraphStyleRange Mojikumi=\"uMojikumi3\" ");
let bytes = zip_files(&[
("designmap.xml", &map),
("Resources/Graphic.xml", GRAPHIC),
("Resources/Styles.xml", STYLES),
("Resources/Preferences.xml", PREFS),
("MasterSpreads/MasterSpread_m1.xml", MASTER),
("Spreads/Spread_sp1.xml", SPREAD),
("Stories/Story_s1.xml", &story),
]);
let d = import_idml(&bytes).unwrap();
let name = d.stories.values().flat_map(|s| &s.paras).find_map(|p| p.para.mojikumi.clone()).unwrap();
assert_eq!(name, "MojikumiTable/NoBase");
let nothing = designcraft_doc::mojikumi::Rules::resolve(&d.styles, &name).unwrap().unwrap();
let missing = designcraft_doc::mojikumi::Rules::resolve(&d.styles, "MojikumiTable/Baseless").unwrap().unwrap();
assert!(nothing.assumed_base());
let close = designcraft_doc::mojikumi::class('。');
for (l, r) in [(12, 18), (22, designcraft_doc::mojikumi::class('(')), (close, 22), (close, 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}");
}
let back = import_idml(&export_idml(&d)).unwrap();
assert_eq!(back.styles.mojikumi_tables, d.styles.mojikumi_tables);
assert_eq!(back.styles.mojikumi_tables[1].based_on, "Nothing");
}
/// The built-in `LineEndAllOneEmEnum` set applies directly and as a custom
/// table's base: closing punctuation at line end keeps its full em.
#[test]
fn cjk_mojikumi_line_end_all_one_em_set_applies_and_round_trips() {
let map = DESIGNMAP.replace("</Document>", r#"<MojikumiTable Self="uMojikumi4" Name="OneEm" BasedOnMojikumiSet="LineEndAllOneEmEnum"><Properties><OverrideMojikumiAkiList>
<OverrideMojikumiAkiType TargetMojikumiClass="12" SideMojikumiClass="18" SideIsAfterTarget="true" Minimum="0.25" Desired="0.75" Maximum="1" CompressionPriority="2" AkiDoesNotFloat="false"/>
</OverrideMojikumiAkiList></Properties></MojikumiTable></Document>"#);
let story = STORY.replacen("<ParagraphStyleRange ", "<ParagraphStyleRange Mojikumi=\"$ID/LineEndAllOneEmEnum\" ", 1);
let bytes = zip_files(&[
("designmap.xml", &map),
("Resources/Graphic.xml", GRAPHIC),
("Resources/Styles.xml", STYLES),
("Resources/Preferences.xml", PREFS),
("MasterSpreads/MasterSpread_m1.xml", MASTER),
("Spreads/Spread_sp1.xml", SPREAD),
("Stories/Story_s1.xml", &story),
]);
let d = import_idml(&bytes).unwrap();
let name = d.stories.values().flat_map(|s| &s.paras).find_map(|p| p.para.mojikumi.clone()).unwrap();
let close = designcraft_doc::mojikumi::class('。');
for name in [name.as_str(), "MojikumiTable/OneEm"] {
let rules = designcraft_doc::mojikumi::Rules::resolve(&d.styles, name).unwrap().unwrap();
assert!(!rules.assumed_base(), "{name}");
assert_eq!(rules.pair(close, 22).desired, 0.5, "{name}");
assert_eq!(rules.pair(22, designcraft_doc::mojikumi::class('(')).desired, 0.0, "{name}");
}
let back = import_idml(&export_idml(&d)).unwrap();
assert_eq!(back.styles.mojikumi_tables, d.styles.mojikumi_tables);
let back_name = back.stories.values().flat_map(|s| &s.paras).find_map(|p| p.para.mojikumi.clone()).unwrap();
assert_eq!(back_name, name);
}
#[test]
fn arabic_controls_import_independent_xml_and_round_trip() {
let story = r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Story Self="s1">