mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-30 13:16:56 -06:00
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:
@@ -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
@@ -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]
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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:"));
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user