Merge #330: Compose: the CJK leading model and character alignment apply only under the Japanese composers

This commit is contained in:
aiongg
2026-10-10 13:18:52 +00:00
9 changed files with 120 additions and 30 deletions
+11 -3
View File
@@ -31,7 +31,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};
@@ -725,7 +725,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
apply_desired_spacing(&mut gl, &pp);
mojikumi::apply(&mut gl, &doc.styles, &pp.mojikumi);
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
@@ -815,6 +815,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,
@@ -978,7 +986,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::*;
@@ -2709,38 +2709,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
@@ -424,6 +424,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
@@ -1405,6 +1405,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
@@ -1450,9 +1450,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");
@@ -2609,6 +2607,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
@@ -1212,6 +1212,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
@@ -2656,12 +2656,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
@@ -1523,12 +1523,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.
@@ -2174,6 +2174,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),