From 5dc8383a50897d437021262b7ed83ad69a3aa85e Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 11:42:02 +0000 Subject: [PATCH] Compose: apply the CJK leading model and character alignment only under the Japanese composers Every IDML root style carries LeadingModelAkiBelow and AlignEmCenter, but only the Japanese composers (HL Composer J, HL Single J) use them; the Adobe and World-Ready composers set lines baseline to baseline with every character on the Roman baseline. Applying them everywhere moved lines down after a size change and lowered smaller-em glyphs, pushing the last lines of tight frames (and whole keep-together paragraphs) into overset. Composer gains Japanese and JapaneseSingleLine, read from and written to IDML; the composer choice in the UI keeps the kind. --- crates/compose/src/lib.rs | 14 ++++++-- crates/compose/src/tests.rs | 45 ++++++++++++++++--------- crates/doc/src/attrs.rs | 19 +++++++++++ crates/idml/src/export.rs | 2 ++ crates/idml/src/import.rs | 16 +++++++-- crates/idml/src/tests.rs | 27 +++++++++++++++ crates/ui-egui/src/dialogs.rs | 7 ++-- crates/ui-egui/src/panels/properties.rs | 14 ++++++-- docs/cjk.md | 6 ++-- 9 files changed, 120 insertions(+), 30 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3dd7c25b3..d7e564af1 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -29,7 +29,7 @@ use std::rc::Rc; use std::sync::{Arc, Mutex}; use designcraft_doc::{ - Align, Composer, Document, FirstBaseline, GridAlign, ItemId, ParaProps, SpanColumns, StartParagraph, Story, StoryId, TabAlign, TextFrameOptions, + Align, Document, FirstBaseline, GridAlign, ItemId, ParaProps, SpanColumns, StartParagraph, Story, StoryId, TabAlign, TextFrameOptions, VerticalJustification, WrapMode, story, }; use designcraft_fonts::{FontDb, ScopedFonts}; @@ -681,7 +681,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let mut gl = sp.glyphs.clone(); apply_desired_spacing(&mut gl, &pp); let hy = hyphenation_points(&story.text, &gl, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars)); - let breaks = if pp.composer == Composer::SingleLine || gl.iter().any(|g| g.ch == '\t') || gl.len() > 4000 { + let breaks = if pp.composer.single_line() || gl.iter().any(|g| g.ch == '\t') || gl.len() > 4000 { let tab = |j: usize, x: f64, i: usize| { let ind = pp.left_indent + if j == 0 { pp.first_line_indent } else { 0.0 }; tab_advance(&pp.tabs, pp.left_indent, ind + x, gl.get(i + 1..).unwrap_or_default()).0 @@ -755,6 +755,14 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C prepend_label(db, &mut sp.glyphs, label, prange.start, &base_chars, label_env, &mut table); } let mut glyphs = sp.glyphs; + if !pp.composer.japanese() { + // Every IDML's root style carries a leading model and character alignment, which only + // the Japanese composers use. + for g in &mut glyphs { + g.leading_model = designcraft_doc::cjk::LeadingModel::Roman; + g.character_alignment = designcraft_doc::cjk::CharacterAlignment::Baseline; + } + } let bidi_text = bidi::paragraph_text(&mut glyphs, &story.text); let bidi_info = unicode_bidi::BidiInfo::new( &bidi_text, @@ -896,7 +904,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C }; let rest = &glyphs[g0..]; let rest_h = &hyph_after[g0..]; - let breaks: Vec = if pp.composer == Composer::SingleLine || has_tabs || rest.len() > 4000 { + let breaks: Vec = if pp.composer.single_line() || has_tabs || rest.len() > 4000 { // Where each line starts relative to the tab origin, as `layout_line` places it. let tab = |j: usize, x: f64, i: usize| { let x0 = slots.get(j).map_or(col.x0, |s| s.0); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 0d1fe2d27..914c1c8fc 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1,5 +1,5 @@ use designcraft_doc::build::NewDocument; -use designcraft_doc::{Align, Document, ParaAttrs, ParaFormat, SpreadRef}; +use designcraft_doc::{Align, Composer, Document, ParaAttrs, ParaFormat, SpreadRef}; use designcraft_geom::Rect; use super::*; @@ -1469,38 +1469,53 @@ fn cjk_custom_kinsoku_disables_selected_breaks() { assert_eq!(all_lines(&cs).len(), 1); } +fn japanese() -> ParaAttrs { + ParaAttrs { composer: Some(Composer::Japanese), ..Default::default() } +} + #[test] fn cjk_center_leading_measures_em_centers_across_different_sizes() { - let (mut d, sid, _) = doc_with("A\nB", Rect::new(0.0, 0.0, 300.0, 200.0), ParaAttrs::default()); - d.story_mut(sid).unwrap().format_chars(0..3, |f| { - f.over.leading = Some(designcraft_doc::Leading::Points(30.0)); - f.over.leading_model = Some(designcraft_doc::cjk::LeadingModel::Center); - }); - d.story_mut(sid).unwrap().format_chars(0..1, |f| f.over.size = Some(24.0)); - let cs = compose_story(&d, sid, &ComposeOptions::default()); - let lines = all_lines(&cs); + let lines_with = |para: ParaAttrs| { + let (mut d, sid, _) = doc_with("A\nB", Rect::new(0.0, 0.0, 300.0, 200.0), para); + d.story_mut(sid).unwrap().format_chars(0..3, |f| { + f.over.leading = Some(designcraft_doc::Leading::Points(30.0)); + f.over.leading_model = Some(designcraft_doc::cjk::LeadingModel::Center); + }); + d.story_mut(sid).unwrap().format_chars(0..1, |f| f.over.size = Some(24.0)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + all_lines(&cs).into_iter().cloned().collect::>() + }; + let lines = lines_with(japanese()); assert_eq!(lines.len(), 2); let center = |l: &Line| { let g = &l.glyphs[0]; let (a, b) = g.face.vertical_metrics(); l.baseline + (b - a) / (2.0 * (a + b)) * g.face.units_per_em() * g.sy }; - assert!((center(lines[1]) - center(lines[0]) - 30.0).abs() < 1e-6); + assert!((center(&lines[1]) - center(&lines[0]) - 30.0).abs() < 1e-6); assert!((lines[1].baseline - lines[0].baseline - 30.0).abs() > 0.1); + // The other composers set lines baseline to baseline. + let lines = lines_with(ParaAttrs::default()); + assert!((lines[1].baseline - lines[0].baseline - 30.0).abs() < 1e-6); } #[test] fn cjk_em_center_alignment_moves_small_characters() { - let (mut d, sid, _) = doc_with("AB", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default()); - d.story_mut(sid).unwrap().format_chars(0..1, |f| f.over.size = Some(24.0)); - d.story_mut(sid).unwrap().format_chars(1..2, |f| f.over.character_alignment = Some(designcraft_doc::cjk::CharacterAlignment::EmCenter)); - let cs = compose_story(&d, sid, &ComposeOptions::default()); - let line = &cs.frames[0].lines[0]; + let line_with = |para: ParaAttrs| { + let (mut d, sid, _) = doc_with("AB", Rect::new(0.0, 0.0, 300.0, 100.0), para); + d.story_mut(sid).unwrap().format_chars(0..1, |f| f.over.size = Some(24.0)); + d.story_mut(sid).unwrap().format_chars(0..2, |f| f.over.character_alignment = Some(designcraft_doc::cjk::CharacterAlignment::EmCenter)); + compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].clone() + }; + let line = line_with(japanese()); let center = |g: &PlacedGlyph| { let (a, b) = g.face.vertical_metrics(); g.y + (b - a) / (2.0 * (a + b)) * g.face.units_per_em() * g.sy }; assert!((center(&line.glyphs[0]) - center(&line.glyphs[1])).abs() < 1e-6); + // The other composers keep every character on the baseline. + let line = line_with(ParaAttrs::default()); + assert!(line.glyphs.iter().all(|g| g.y.abs() < 1e-9), "{:?}", line.glyphs.iter().map(|g| g.y).collect::>()); } #[test] diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index b99740e2c..2588fb500 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -415,6 +415,25 @@ pub enum Composer { Paragraph, /// Greedy, line by line. SingleLine, + /// The Japanese paragraph composer: total fit, with the CJK leading model and character + /// alignment. + Japanese, + /// The Japanese single-line composer. + JapaneseSingleLine, +} + +impl Composer { + /// Breaks lines greedily, one at a time. + pub fn single_line(self) -> bool { + matches!(self, Composer::SingleLine | Composer::JapaneseSingleLine) + } + + /// A Japanese composer: lines follow the leading model ([`crate::cjk::LeadingModel`]) and + /// characters their [`crate::cjk::CharacterAlignment`]. The others set lines baseline to + /// baseline with every character on the Roman baseline. + pub fn japanese(self) -> bool { + matches!(self, Composer::Japanese | Composer::JapaneseSingleLine) + } } /// What a nested style runs to (Drop Caps and Nested Styles). diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 9d73562b9..e95daa03b 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1389,6 +1389,8 @@ impl<'a> Ex<'a> { match c { designcraft_doc::Composer::Paragraph => "HL Composer", designcraft_doc::Composer::SingleLine => "HL Single", + designcraft_doc::Composer::Japanese => "HL Composer J", + designcraft_doc::Composer::JapaneseSingleLine => "HL Single J", }, ); } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index d85d9ee02..8a8bdbdc9 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1289,9 +1289,7 @@ impl<'r> Importer<'r> { GridAlign::AllLines }); } - a.composer = e - .prop("Composer") - .map(|c| if c.contains("Single") { designcraft_doc::Composer::SingleLine } else { designcraft_doc::Composer::Paragraph }); + a.composer = e.prop("Composer").map(|c| composer_in(&c)); a.hyphenate = e.boolean("Hyphenation"); a.hyph_min_word = u("HyphenateWordsLongerThan"); a.hyph_after_first = u("HyphenateAfterFirst"); @@ -2421,6 +2419,18 @@ fn ace_char(v: &str) -> Option { } } +/// `HL Composer` and `HL Single`, their World-Ready (`… Optyca`) and Japanese (`… J`) forms. +fn composer_in(name: &str) -> designcraft_doc::Composer { + use designcraft_doc::Composer as C; + let name = name.trim().trim_start_matches("$ID/"); + match (name.contains("Single"), name.ends_with(" J") || name.contains("Japanese")) { + (false, false) => C::Paragraph, + (true, false) => C::SingleLine, + (false, true) => C::Japanese, + (true, true) => C::JapaneseSingleLine, + } +} + fn margins_of(m: &El) -> (Margins, Columns) { let g = |k: &str, d: f64| m.num(k).unwrap_or(d); ( diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 7b747587a..3dd074f96 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -787,6 +787,33 @@ fn cjk_character_attributes_import_from_independent_xml_and_round_trip() { assert_eq!(a.leading_model, b.leading_model); } +#[test] +fn composers_import_by_kind_and_round_trip() { + use designcraft_doc::Composer as C; + let para = |composer: &str, text: &str| { + format!( + r#"{text}
"# + ) + }; + let cases = [ + ("HL Composer", C::Paragraph), + ("HL Single Optyca", C::SingleLine), + ("HL Composer Optyca", C::Paragraph), + ("HL Composer J", C::Japanese), + ("$ID/HL Single J", C::JapaneseSingleLine), + ]; + let body: String = cases.iter().enumerate().map(|(i, (name, _))| para(name, &format!("p{i}"))).collect(); + let story = + format!(r#"{body}"#); + let doc = import_idml(&fixture_with_story(&story)).unwrap(); + let back = import_idml(&export_idml(&doc)).unwrap(); + for d in [&doc, &back] { + let st = d.stories.values().find(|s| s.text.contains("p0")).unwrap(); + let got: Vec<_> = st.paras.iter().take(cases.len()).map(|p| p.para.composer).collect(); + assert_eq!(got, cases.iter().map(|(_, c)| Some(*c)).collect::>()); + } +} + #[test] fn warichu_imports_from_idml_and_round_trips() { let story = r#" diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2ab7a3bad..e86390a39 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2576,12 +2576,13 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo ui.end_row(); } crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Composer:")); - let single = cur(d, "p.composer", &pv["composer"]).as_str() == Some("singleLine"); + let (single, [paragraph, single_line]) = + crate::panels::properties::composer_choice(cur(d, "p.composer", &pv["composer"]).as_str()); if ui.selectable_label(!single, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Paragraph Composer"))).clicked() { - d.fields.insert("p.composer".into(), json!("paragraph")); + d.fields.insert("p.composer".into(), json!(paragraph)); } if ui.selectable_label(single, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Single-line Composer"))).clicked() { - d.fields.insert("p.composer".into(), json!("singleLine")); + d.fields.insert("p.composer".into(), json!(single_line)); } ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Insert Kashidas:")); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 49cb920f2..e13b909d4 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1480,12 +1480,12 @@ pub fn paragraph_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }); ui.horizontal(|ui| { caption(ui, crate::i18n::tr(&app.ui.language, "Composer")); - let single = p["composer"].as_str() == Some("singleLine"); + let (single, [paragraph, single_line]) = composer_choice(p["composer"].as_str()); if ui.selectable_label(!single, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Paragraph"))).clicked() { - let _ = app.run("type.para", json!({"attrs": {"composer": "paragraph"}})); + let _ = app.run("type.para", json!({"attrs": {"composer": paragraph}})); } if ui.selectable_label(single, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Single-line"))).clicked() { - let _ = app.run("type.para", json!({"attrs": {"composer": "singleLine"}})); + let _ = app.run("type.para", json!({"attrs": {"composer": single_line}})); } }); // Paragraph Border and Shading. @@ -2131,6 +2131,14 @@ fn variable_font_axes(app: &mut DesignApp, ui: &mut egui::Ui, family: &str, styl } } +/// Whether `composer` (a paragraph's `composer` attribute) is a single-line composer, and the +/// paragraph and single-line composers of its kind (Japanese or not), for the composer choice. +pub(crate) fn composer_choice(composer: Option<&str>) -> (bool, [&'static str; 2]) { + let single = matches!(composer, Some("singleLine" | "japaneseSingleLine")); + let kind = if composer.is_some_and(|c| c.starts_with("japanese")) { ["japanese", "japaneseSingleLine"] } else { ["paragraph", "singleLine"] }; + (single, kind) +} + #[cfg(test)] mod tests { use egui_kittest::{Harness, kittest::Queryable}; diff --git a/docs/cjk.md b/docs/cjk.md index 5da0a74a8..72b9f7ab7 100644 --- a/docs/cjk.md +++ b/docs/cjk.md @@ -37,7 +37,7 @@ presets or tables are copied. Our default mojikumi and kinsoku tables are built | Korean line breaking | at spaces (Hangul everywhere, hanja in Korean text); per-paragraph character-based breaking (`koreanCharBreaks`); DesignCraft-only, not in IDML | | Mojikumi | tables and paragraph references kept through IDML; not applied (Preflight says so) | | Aki, tsume, jidori | done ([cjk-typography.md](cjk-typography.md)) | -| Character alignment, leading model | em box top / centre / bottom, ICF from ascender and descender, roman baseline; aki above / below, centre (centre down as centre) | +| Character alignment, leading model | em box top / centre / bottom, ICF from ascender and descender, roman baseline; aki above / below, centre (centre down as centre); with the Japanese composers (`Composer::Japanese`, `JapaneseSingleLine`, IDML `HL Composer J` / `HL Single J`), the others set lines baseline to baseline on the Roman baseline | | Tate-chu-yoko | manual, with offsets; no auto, 3+ digits overflow the em | | Ruby | group ruby only, fixed 50 % size, no options, doesn't affect leading | | Kenten | the IDML kinds and a custom character, drawn as characters; no position, size, alignment, colour or font | @@ -86,8 +86,8 @@ working (tsume removes the side bearings before mojikumi runs). built-in hard and soft sets from JLREQ's classes (today's named sets are common punctuation lists), and the kinsoku type (push in first / push out first / push out only / prioritise the adjustment amount), which needs the Japanese composer's push-in and push-out candidates. -- A `Composer::Japanese` (paragraph) and `Composer::JapaneseSingleLine` beside the Western - composers. In `breaker::items` the Japanese composer emits: +- The Japanese composers (`Composer::Japanese`, `Composer::JapaneseSingleLine`) break lines as + the Western ones do. In `breaker::items` they are to emit: - boxes per glyph, - mojikumi glue between glyphs with priority tiers (compress punctuation first, then expand inter-ideograph space, then Western/CJK spacing, then letter-spacing),