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.
This commit is contained in:
aiongg committed 2026-10-10 11:56:45 +00:00
1 parent 92649ffdb0
commit 5dc8383a50
9 files changed
+120 -30

No files matched your search

+11 -3
View File
@@ -29,7 +29,7 @@ use std::rc::Rc;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use designcraft_doc::{ 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, VerticalJustification, WrapMode, story,
}; };
use designcraft_fonts::{FontDb, ScopedFonts}; 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(); let mut gl = sp.glyphs.clone();
apply_desired_spacing(&mut gl, &pp); 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 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 tab = |j: usize, x: f64, i: usize| {
let ind = pp.left_indent + if j == 0 { pp.first_line_indent } else { 0.0 }; 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 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); prepend_label(db, &mut sp.glyphs, label, prange.start, &base_chars, label_env, &mut table);
} }
let mut glyphs = sp.glyphs; 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_text = bidi::paragraph_text(&mut glyphs, &story.text);
let bidi_info = unicode_bidi::BidiInfo::new( let bidi_info = unicode_bidi::BidiInfo::new(
&bidi_text, &bidi_text,
@@ -896,7 +904,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
}; };
let rest = &glyphs[g0..]; let rest = &glyphs[g0..];
let rest_h = &hyph_after[g0..]; let rest_h = &hyph_after[g0..];
let breaks: Vec<Break> = if pp.composer == Composer::SingleLine || has_tabs || rest.len() > 4000 { let breaks: Vec<Break> = if pp.composer.single_line() || has_tabs || rest.len() > 4000 {
// Where each line starts relative to the tab origin, as `layout_line` places it. // Where each line starts relative to the tab origin, as `layout_line` places it.
let tab = |j: usize, x: f64, i: usize| { let tab = |j: usize, x: f64, i: usize| {
let x0 = slots.get(j).map_or(col.x0, |s| s.0); let x0 = slots.get(j).map_or(col.x0, |s| s.0);
+30 -15
View File
@@ -1,5 +1,5 @@
use designcraft_doc::build::NewDocument; 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 designcraft_geom::Rect;
use super::*; use super::*;
@@ -1469,38 +1469,53 @@ fn cjk_custom_kinsoku_disables_selected_breaks() {
assert_eq!(all_lines(&cs).len(), 1); assert_eq!(all_lines(&cs).len(), 1);
} }
fn japanese() -> ParaAttrs {
ParaAttrs { composer: Some(Composer::Japanese), ..Default::default() }
}
#[test] #[test]
fn cjk_center_leading_measures_em_centers_across_different_sizes() { 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()); let lines_with = |para: ParaAttrs| {
d.story_mut(sid).unwrap().format_chars(0..3, |f| { let (mut d, sid, _) = doc_with("A\nB", Rect::new(0.0, 0.0, 300.0, 200.0), para);
f.over.leading = Some(designcraft_doc::Leading::Points(30.0)); d.story_mut(sid).unwrap().format_chars(0..3, |f| {
f.over.leading_model = Some(designcraft_doc::cjk::LeadingModel::Center); 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()); d.story_mut(sid).unwrap().format_chars(0..1, |f| f.over.size = Some(24.0));
let lines = all_lines(&cs); let cs = compose_story(&d, sid, &ComposeOptions::default());
all_lines(&cs).into_iter().cloned().collect::<Vec<_>>()
};
let lines = lines_with(japanese());
assert_eq!(lines.len(), 2); assert_eq!(lines.len(), 2);
let center = |l: &Line| { let center = |l: &Line| {
let g = &l.glyphs[0]; let g = &l.glyphs[0];
let (a, b) = g.face.vertical_metrics(); let (a, b) = g.face.vertical_metrics();
l.baseline + (b - a) / (2.0 * (a + b)) * g.face.units_per_em() * g.sy 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); 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] #[test]
fn cjk_em_center_alignment_moves_small_characters() { 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()); let line_with = |para: ParaAttrs| {
d.story_mut(sid).unwrap().format_chars(0..1, |f| f.over.size = Some(24.0)); 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(1..2, |f| f.over.character_alignment = Some(designcraft_doc::cjk::CharacterAlignment::EmCenter)); d.story_mut(sid).unwrap().format_chars(0..1, |f| f.over.size = Some(24.0));
let cs = compose_story(&d, sid, &ComposeOptions::default()); d.story_mut(sid).unwrap().format_chars(0..2, |f| f.over.character_alignment = Some(designcraft_doc::cjk::CharacterAlignment::EmCenter));
let line = &cs.frames[0].lines[0]; compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].clone()
};
let line = line_with(japanese());
let center = |g: &PlacedGlyph| { let center = |g: &PlacedGlyph| {
let (a, b) = g.face.vertical_metrics(); let (a, b) = g.face.vertical_metrics();
g.y + (b - a) / (2.0 * (a + b)) * g.face.units_per_em() * g.sy 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); 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::<Vec<_>>());
} }
#[test] #[test]
+19
View File
@@ -415,6 +415,25 @@ pub enum Composer {
Paragraph, Paragraph,
/// Greedy, line by line. /// Greedy, line by line.
SingleLine, 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). /// What a nested style runs to (Drop Caps and Nested Styles).
+2
View File
@@ -1389,6 +1389,8 @@ impl<'a> Ex<'a> {
match c { match c {
designcraft_doc::Composer::Paragraph => "HL Composer", designcraft_doc::Composer::Paragraph => "HL Composer",
designcraft_doc::Composer::SingleLine => "HL Single", designcraft_doc::Composer::SingleLine => "HL Single",
designcraft_doc::Composer::Japanese => "HL Composer J",
designcraft_doc::Composer::JapaneseSingleLine => "HL Single J",
}, },
); );
} }
+13 -3
View File
@@ -1289,9 +1289,7 @@ impl<'r> Importer<'r> {
GridAlign::AllLines GridAlign::AllLines
}); });
} }
a.composer = e a.composer = e.prop("Composer").map(|c| composer_in(&c));
.prop("Composer")
.map(|c| if c.contains("Single") { designcraft_doc::Composer::SingleLine } else { designcraft_doc::Composer::Paragraph });
a.hyphenate = e.boolean("Hyphenation"); a.hyphenate = e.boolean("Hyphenation");
a.hyph_min_word = u("HyphenateWordsLongerThan"); a.hyph_min_word = u("HyphenateWordsLongerThan");
a.hyph_after_first = u("HyphenateAfterFirst"); a.hyph_after_first = u("HyphenateAfterFirst");
@@ -2421,6 +2419,18 @@ fn ace_char(v: &str) -> Option<char> {
} }
} }
/// `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) { fn margins_of(m: &El) -> (Margins, Columns) {
let g = |k: &str, d: f64| m.num(k).unwrap_or(d); let g = |k: &str, d: f64| m.num(k).unwrap_or(d);
( (
+27
View File
@@ -787,6 +787,33 @@ fn cjk_character_attributes_import_from_independent_xml_and_round_trip() {
assert_eq!(a.leading_model, b.leading_model); 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#"<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/[No paragraph style]" Composer="{composer}"><CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>{text}</Content><Br /></CharacterStyleRange></ParagraphStyleRange>"#
)
};
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#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Story Self="s1">{body}</Story></idPkg:Story>"#);
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::<Vec<_>>());
}
}
#[test] #[test]
fn warichu_imports_from_idml_and_round_trips() { fn warichu_imports_from_idml_and_round_trips() {
let story = r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Story Self="s1"> let story = r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Story Self="s1">
+4 -3
View File
@@ -2576,12 +2576,13 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
ui.end_row(); ui.end_row();
} }
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Composer:")); 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() { 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() { 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(); ui.end_row();
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Insert Kashidas:")); crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Insert Kashidas:"));
+11 -3
View File
@@ -1480,12 +1480,12 @@ pub fn paragraph_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
}); });
ui.horizontal(|ui| { ui.horizontal(|ui| {
caption(ui, crate::i18n::tr(&app.ui.language, "Composer")); 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() { 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() { 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. // 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)] #[cfg(test)]
mod tests { mod tests {
use egui_kittest::{Harness, kittest::Queryable}; use egui_kittest::{Harness, kittest::Queryable};
+3 -3
View File
@@ -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 | | 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) | | Mojikumi | tables and paragraph references kept through IDML; not applied (Preflight says so) |
| Aki, tsume, jidori | done ([cjk-typography.md](cjk-typography.md)) | | 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 | | 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 | | 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 | | 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 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 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. adjustment amount), which needs the Japanese composer's push-in and push-out candidates.
- A `Composer::Japanese` (paragraph) and `Composer::JapaneseSingleLine` beside the Western - The Japanese composers (`Composer::Japanese`, `Composer::JapaneseSingleLine`) break lines as
composers. In `breaker::items` the Japanese composer emits: the Western ones do. In `breaker::items` they are to emit:
- boxes per glyph, - boxes per glyph,
- mojikumi glue between glyphs with priority tiers (compress punctuation first, then - mojikumi glue between glyphs with priority tiers (compress punctuation first, then
expand inter-ideograph space, then Western/CJK spacing, then letter-spacing), expand inter-ideograph space, then Western/CJK spacing, then letter-spacing),