diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 1bb877504..8e8985975 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -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 = 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 104671896..01625e718 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::*; @@ -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::>() + }; + 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 b50bc3c97..93b8706fd 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -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). diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 2ccef3396..b3a9aa870 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -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", }, ); } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index fb35d90c6..9abe36a06 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -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 { } } +/// `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 e22dd54fd..c3fc6eb50 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -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#"{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 c8b379d56..4982086a2 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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:")); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 31ff89373..bf47a8e11 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -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}; 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),