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
2026-10-10 11:56:45 +00:00
parent 92649ffdb0
commit 5dc8383a50
9 changed files with 120 additions and 30 deletions
+11 -3
View File
@@ -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<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.
let tab = |j: usize, x: f64, i: usize| {
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::{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::<Vec<_>>()
};
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::<Vec<_>>());
}
#[test]
+19
View File
@@ -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).
+2
View File
@@ -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",
},
);
}
+13 -3
View File
@@ -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<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) {
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);
}
#[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]
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">
+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();
}
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:"));
+11 -3
View File
@@ -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};
+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 |
| 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),