diff --git a/ROADMAP.md b/ROADMAP.md index ac8472d57..fe15cfc14 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,17 @@ - FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage. +- CJK-MOJI8: preserve shaped narrow punctuation when tracking changes its advance. +- CJK-MOJI7: prevent numeric coercion in Mojikumi import, preserve finite unsupported rules, and check nested note/cell rules in preflight. +- CJK-MOJI6: apply spacing at shaped-cluster boundaries without moving attached marks. +- CJK-MOJI5: share equal-priority boundary spacing and honor discrete compression endpoints, including leading-only ragged-line fitting. + +- CJK-MOJI3: use the composition rule resolver in preflight, report unknown Mojikumi presets and retain regression coverage for supported and unsupported settings. + +- CJK-MOJI2: share Mojikumi pair and line-edge metrics between breaking and placement; honor spacing bounds, compression priorities, explicit aki and kinsoku policies. + +- CJK-MOJI1: resolve Chinese and half-em Mojikumi presets and directional custom overrides; preserve IDML table references independently of display names. + - FONT7: replacement recommendations check the characters actually used; placeholder LastResort fonts and glyph-zero mappings cannot masquerade as glyph coverage or suppress missing-glyph diagnostics. - FONT6: localized and legacy family/style names share canonical resolution across shaping, inventory and replacement. Known compatible and script-based substitutes retain missing status; Find Font shows the resolved face and offers it as the replacement, while preflight reports unavailable styles. diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs index 3b08e1050..8aec5df04 100644 --- a/crates/compose/src/breaker.rs +++ b/crates/compose/src/breaker.rs @@ -50,6 +50,8 @@ pub struct Spacing { pub optical: bool, /// Korean text breaks between syllables instead of at spaces. pub korean_char_breaks: bool, + /// 0: minimum adjustment; 1: push in; 2: push out first; 3: push out only. + pub kinsoku_priority: u8, } impl Default for Spacing { @@ -71,6 +73,7 @@ impl Default for Spacing { hyph_zone: 0.0, optical: false, korean_char_breaks: false, + kinsoku_priority: 0, } } } @@ -78,13 +81,18 @@ impl Default for Spacing { impl Spacing { /// Per-tier (word, letter, glyph) stretch and shrink of glyph `g` as a box. fn box_elastic(&self, g: &Glyph) -> ([f64; 3], [f64; 3]) { - if !self.justify || g.locked_advance { + if g.locked_advance { return ([0.0; 3], [0.0; 3]); } + let moji_stretch = if self.justify { g.moji.stretch } else { 0.0 }; + let moji_shrink = if self.kinsoku_priority == 3 { 0.0 } else { g.moji.shrink }; + if !self.justify { + return ([0.0; 3], [moji_shrink, 0.0, 0.0]); + } let natural = g.adv / self.glyph_desired.max(0.01); ( - [0.0, g.space * (self.letter_max - self.letter_desired).max(0.0), natural * (self.glyph_max - self.glyph_desired).max(0.0)], - [0.0, g.space * (self.letter_desired - self.letter_min).max(0.0), natural * (self.glyph_desired - self.glyph_min).max(0.0)], + [moji_stretch, g.space * (self.letter_max - self.letter_desired).max(0.0), natural * (self.glyph_max - self.glyph_desired).max(0.0)], + [moji_shrink, g.space * (self.letter_desired - self.letter_min).max(0.0), natural * (self.glyph_desired - self.glyph_min).max(0.0)], ) } /// Word-space stretch and shrink of space glyph `g` (only U+0020 is elastic). @@ -146,6 +154,8 @@ struct Item { auto: bool, /// Breaks: optical hang of the glyph ending the line. Boxes: hang of the glyph starting one. hang: f64, + edge_elastic: [f64; 2], + start_elastic: [f64; 2], /// Auto hyphenation points: index of the break opportunity before the word (or `NONE`). zone_from: usize, } @@ -154,13 +164,49 @@ const NONE: usize = usize::MAX; impl Item { fn boxed(w: f64, st: [f64; 3], sh: [f64; 3]) -> Item { - Item { kind: Kind::Box, w, st, sh, p: 0.0, flagged: false, auto: false, hang: 0.0, zone_from: NONE } + Item { + kind: Kind::Box, + w, + st, + sh, + p: 0.0, + flagged: false, + auto: false, + hang: 0.0, + edge_elastic: [0.0; 2], + start_elastic: [0.0; 2], + zone_from: NONE, + } } fn glue(w: f64, st: f64, sh: f64) -> Item { - Item { kind: Kind::Glue, w, st: [st, 0.0, 0.0], sh: [sh, 0.0, 0.0], p: 0.0, flagged: false, auto: false, hang: 0.0, zone_from: NONE } + Item { + kind: Kind::Glue, + w, + st: [st, 0.0, 0.0], + sh: [sh, 0.0, 0.0], + p: 0.0, + flagged: false, + auto: false, + hang: 0.0, + edge_elastic: [0.0; 2], + start_elastic: [0.0; 2], + zone_from: NONE, + } } fn penalty(w: f64, p: f64, flagged: bool) -> Item { - Item { kind: Kind::Penalty, w, st: [0.0; 3], sh: [0.0; 3], p, flagged, auto: false, hang: 0.0, zone_from: NONE } + Item { + kind: Kind::Penalty, + w, + st: [0.0; 3], + sh: [0.0; 3], + p, + flagged, + auto: false, + hang: 0.0, + edge_elastic: [0.0; 2], + start_elastic: [0.0; 2], + zone_from: NONE, + } } } @@ -235,7 +281,7 @@ fn hang_left(g: &Glyph) -> f64 { } fn right_hang(g: &Glyph, sp: &Spacing) -> f64 { - g.cjk_hang.max(if sp.optical { hang_right(g) } else { 0.0 }) + g.cjk_hang.max(if sp.optical { hang_right(g) } else { 0.0 }) - g.moji.end } fn hang_right(g: &Glyph) -> f64 { @@ -287,6 +333,9 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec, Vec ig.push(i); let mut p = Item::penalty(0.0, -INF, false); p.hang = prev_hang; + if let Some(prev) = i.checked_sub(1).and_then(|j| glyphs.get(j)) { + p.edge_elastic = crate::mojikumi::end_elastic(prev, sp.justify, sp.kinsoku_priority != 3); + } it.push(p); ig.push(i + 1); last_space = NONE; @@ -298,6 +347,9 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec, Vec let (st, sh) = sp.space_elastic(g); let mut gl = Item::glue(w, st, sh); gl.hang = prev_hang; + if let Some(prev) = i.checked_sub(1).and_then(|j| glyphs.get(j)) { + gl.edge_elastic = crate::mojikumi::end_elastic(prev, sp.justify, sp.kinsoku_priority != 3); + } last_space = it.len(); it.push(gl); ig.push(i); @@ -307,6 +359,9 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec, Vec ig.push(i); let mut p = Item::penalty(0.0, 0.0, false); p.hang = prev_hang; + if let Some(prev) = i.checked_sub(1).and_then(|j| glyphs.get(j)) { + p.edge_elastic = crate::mojikumi::end_elastic(prev, sp.justify, sp.kinsoku_priority != 3); + } last_space = it.len(); it.push(p); ig.push(i); @@ -328,9 +383,8 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec, Vec } let (st, sh) = sp.box_elastic(g); let mut bx = Item::boxed(g.adv, st, sh); - if sp.optical { - bx.hang = hang_left(g); - } + bx.hang = (if sp.optical { hang_left(g) } else { 0.0 }) - g.moji.start; + bx.start_elastic = crate::mojikumi::start_elastic(g, sp.justify, sp.kinsoku_priority != 3); it.push(bx); ig.push(i); let next_is_space = glyphs.get(i + 1).is_none_or(|n| n.is_space()); @@ -338,10 +392,12 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec, Vec if matches!(g.ch, '-' | '\u{2010}') { let mut p = Item::penalty(0.0, 50.0, true); p.hang = right_hang(g, sp); + p.edge_elastic = crate::mojikumi::end_elastic(g, sp.justify, sp.kinsoku_priority != 3); push_inword(&mut it, &mut ig, p, i + 1, sp); } else if matches!(g.ch, '\u{2013}' | '\u{2014}' | '/') || g.break_after.unwrap_or_else(|| cjk_break(g, &glyphs[i + 1], sp)) { let mut p = Item::penalty(0.0, 0.0, false); p.hang = right_hang(g, sp); + p.edge_elastic = crate::mojikumi::end_elastic(g, sp.justify, sp.kinsoku_priority != 3); push_inword(&mut it, &mut ig, p, i + 1, sp); } else if hyph_after[i] { let hw = hyphen_width(g, &mut hy_cache); @@ -360,6 +416,9 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec, Vec ig.push(n); let mut p = Item::penalty(0.0, -INF, false); p.hang = if n > 0 { right_hang(&glyphs[n - 1], sp) } else { 0.0 }; + if let Some(g) = glyphs.last() { + p.edge_elastic = crate::mojikumi::end_elastic(g, sp.justify, sp.kinsoku_priority != 3); + } it.push(p); ig.push(n); (it, ig) @@ -422,9 +481,21 @@ fn kp_pass( sz[i + 1][t] = sz[i][t] + z[t]; } } - let first_hang = items.iter().find(|i| i.kind == Kind::Box).map_or(0.0, |b| b.hang); - let mut arena: Vec = - vec![Node { pos: 0, line: 0, fitness: 1, tw: first_hang, ty: [0.0; 3], tz: [0.0; 3], demerits: 0.0, prev: None, hyphens: 0, flagged: false }]; + let first_box = items.iter().find(|i| i.kind == Kind::Box); + let first_hang = first_box.map_or(0.0, |b| b.hang); + let first_elastic = first_box.map_or([0.0; 2], |b| b.start_elastic); + let mut arena: Vec = vec![Node { + pos: 0, + line: 0, + fitness: 1, + tw: first_hang, + ty: [-first_elastic[0], 0.0, 0.0], + tz: [-first_elastic[1], 0.0, 0.0], + demerits: 0.0, + prev: None, + hyphens: 0, + flagged: false, + }]; let limit = sp.hyphen_limit as usize; let states = if limit == 0 { 2 } else { (limit + 1).min(MAX_HYPHEN_STATES) }; let mut active: Vec = vec![0]; @@ -454,6 +525,8 @@ fn kp_pass( y[t] = sy[b][t] - n.ty[t]; z[t] = sz[b][t] - n.tz[t]; } + y[0] = (y[0] + it.edge_elastic[0]).max(0.0); + z[0] = (z[0] + it.edge_elastic[1]).max(0.0); let (r, feasible) = tiered_ratio(target - l, y, z); if feasible && !forced { keep.push(a); @@ -477,7 +550,12 @@ fn kp_pass( } } } - let bad = 100.0 * r.abs().powi(3); + let preference = match sp.kinsoku_priority { + 1 if r > 0.0 => 4.0, + 2 if r < 0.0 => 4.0, + _ => 1.0, + }; + let bad = preference * 100.0 * r.abs().powi(3); let lp = 10.0 + bad; let mut d = if pp >= 0.0 { lp * lp + pp * pp @@ -531,6 +609,8 @@ fn kp_pass( match x.kind { Kind::Box => { tw += x.hang; + ty[0] -= x.start_elastic[0]; + tz[0] -= x.start_elastic[1]; break; } Kind::Glue => { @@ -641,8 +721,9 @@ pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn F let may_hyphenate = |hyphens: u32| sp.hyphen_limit == 0 || hyphens < sp.hyphen_limit; while start < n { let w = width(line).max(1.0); - let mut x = if sp.optical { -hang_left(&glyphs[start]) } else { 0.0 }; - let mut shrink = 0.0; + let mut x = (if sp.optical { -hang_left(&glyphs[start]) } else { 0.0 }) + glyphs[start].moji.start; + let mut shrink = crate::mojikumi::start_elastic(&glyphs[start], sp.justify, sp.kinsoku_priority != 3)[1]; + let mut last_natural: Option<(usize, bool)> = None; let mut last_ok: Option<(usize, bool)> = None; // (break position = end glyph, hyphen) let mut last_space: Option<(usize, f64)> = None; // (break position, line width there) let mut i = start; @@ -658,13 +739,25 @@ pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn F shrink += sp.space_elastic(g).1; } last_ok = Some((i, false)); + if x <= w { + last_natural = last_ok; + } last_space = Some((i, x)); x += g.adv; i += 1; continue; } let hang_r = right_hang(g, sp); - if x + g.adv - hang_r > w + shrink + let end_shrink = crate::mojikumi::end_elastic(g, sp.justify, sp.kinsoku_priority != 3)[1]; + let available = shrink + sp.box_elastic(g).1.iter().sum::() + end_shrink; + if sp.kinsoku_priority == 2 + && x + g.adv - hang_r > w + && let Some((end, hyphen)) = last_natural + { + brk = Some((end, hyphen, false)); + break; + } + if x + g.adv - hang_r > w + available.max(0.0) && i > start && (last_ok.is_some() || glyphs.get(i - 1).is_none_or(|p| p.break_after != Some(false) && !p.no_break)) { @@ -680,14 +773,15 @@ pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn F break; } x += g.adv; - if sp.justify { - shrink += sp.box_elastic(g).1.iter().sum::(); - } + shrink += sp.box_elastic(g).1.iter().sum::(); if i + 1 < n && !glyphs[i + 1].is_space() && !g.no_break && g.break_after != Some(false) { if matches!(g.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/') || g.break_after.unwrap_or_else(|| cjk_break(g, &glyphs[i + 1], sp)) { last_ok = Some((i + 1, false)); + if x - hang_r <= w { + last_natural = last_ok; + } } else if hyph_after[i] && may_hyphenate(hyphens) { let hy = hyphen_width(g, &mut hy_cache) * if sp.optical { 1.0 - hang('-').1 } else { 1.0 }; if x + hy <= w + shrink { diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 861272cfc..a6365309a 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -14,6 +14,7 @@ mod bidi; pub mod breaker; mod cache; pub mod hyphen; +mod mojikumi; mod notes; mod overlay; mod ruby; @@ -660,6 +661,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C (col.width() - pp.left_indent - pp.right_indent - ind).max(1.0) }; 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 { breaker::greedy(&gl, &hy, &spacing, &width) @@ -752,6 +754,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C ); bidi::resolve_mirroring(&mut glyphs, &bidi_info); apply_desired_spacing(&mut glyphs, &pp); + mojikumi::apply(&mut glyphs, &doc.styles, &pp.mojikumi); let hyph_after = hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars)); let base_size = base_chars.size; let spacing = spacing_for(&pp, base_size); @@ -1305,6 +1308,12 @@ fn spacing_for(pp: &ParaProps, base_size: f64) -> Spacing { hyph_zone: if pp.align.is_justified() { 0.0 } else { pp.hyph_zone }, optical: pp.optical_margin, korean_char_breaks: pp.korean_char_breaks, + kinsoku_priority: match pp.kinsoku_type.as_str() { + "KinsokuPushInFirst" => 1, + "KinsokuPushOutFirst" => 2, + "KinsokuPushOutOnly" => 3, + _ => 0, + }, } } @@ -1820,6 +1829,7 @@ fn layout_line( bidi_info: &unicode_bidi::BidiInfo<'_>, ) -> (Vec, f64, f64) { let mut line: Vec = glyphs[s..e.max(s)].to_vec(); + mojikumi::edges(&mut line); let reference = line.iter().max_by(|a, b| a.size.total_cmp(&b.size)).cloned(); if let Some(reference) = reference { for g in &mut line { @@ -1910,7 +1920,10 @@ fn layout_line( }; let justify_this = align.is_justified() && (!last || align == Align::FullyJustified || forced_mid) && !has_tab; // A justified paragraph's last line may have been composed with shrunk spaces: shrink it too. - let squeeze_last = align.is_justified() && !justify_this && !has_tab && extra < 0.0 && !spaces.is_empty(); + let squeeze_last = !justify_this + && !has_tab + && extra < 0.0 + && (align.is_justified() || line.iter().any(|g| g.moji.shrink > 0.0 || mojikumi::start_elastic(g, false, true)[1] > 0.0)); // Extra advance per glyph (word spaces and letter gaps) and horizontal scale per glyph. let mut add = vec![0.0; line.len()]; let mut scale = vec![1.0; line.len()]; @@ -1929,6 +1942,9 @@ fn layout_line( } } } + if (justify_this || squeeze_last) && (extra >= 0.0 || sp.kinsoku_priority != 3) { + extra = mojikumi::distribute(&mut line, extra, &mut add); + } if (justify_this || squeeze_last) && !spaces.is_empty() { distribute(&line, &spaces, extra, sp, &mut add, &mut scale); } else if justify_this && spaces.is_empty() && line.len() > 1 && !last { @@ -1939,13 +1955,13 @@ fn layout_line( .iter() .enumerate() .take(line.len() - 1) - .filter(|(_, g)| !g.locked_advance || g.break_after != Some(false)) + .filter(|(_, g)| !g.moji.active && (!g.locked_advance || g.break_after != Some(false))) .map(|(i, _)| i) .collect(); if !gaps.is_empty() { let per = extra / gaps.len() as f64; for i in gaps { - add[i] = per; + add[i] += per; } } } diff --git a/crates/compose/src/mojikumi.rs b/crates/compose/src/mojikumi.rs new file mode 100644 index 000000000..a1ca09c9e --- /dev/null +++ b/crates/compose/src/mojikumi.rs @@ -0,0 +1,222 @@ +//! Mojikumi boundary widths. The same values feed line selection and placement. +use crate::shape::Glyph; +use designcraft_doc::{ + Styles, + mojikumi::{self as rules, Aki, Rules}, +}; + +#[derive(Clone, Copy, Debug, Default)] +pub struct Metrics { + pub active: bool, + /// Shaped body width before tracking and manual character spacing. + pub body: f64, + pub blank_before: f64, + pub blank_after: f64, + /// Desired internal gap attached to this glyph's advance. + pub gap: f64, + pub stretch: f64, + pub shrink: f64, + pub priority: u8, + pub discrete: bool, + /// Width changes when this glyph starts / ends a line. + pub start: f64, + pub end: f64, + pub end_aki: Aki, + pub start_aki: Aki, + pub at_start: bool, + pub explicit_before: bool, + pub explicit_after: bool, +} + +fn eligible(g: &Glyph) -> bool { + g.adv > 0.0 + && !g.locked_advance + && !g.ch.is_control() + && (!g.ch.is_whitespace() || g.ch == '\u{3000}') + && !matches!(g.ch, '\u{e000}'..='\u{e1ff}') +} +fn em(g: &Glyph) -> f64 { + // Actual scaled em, including vertical and composite-font scaling. + g.face.units_per_em() * if g.upright { g.sy } else { g.sx } +} + +pub fn apply(glyphs: &mut [Glyph], styles: &Styles, name: &str) { + let Ok(Some(table)) = Rules::resolve(styles, name) else { return }; + // Boundaries belong to shaped clusters, not individual glyphs. In particular + // the advance carrying a following gap must come after all attached marks. + let mut clusters = Vec::new(); + let mut start = 0; + while start < glyphs.len() { + let byte = glyphs[start].byte; + let mut end = start + 1; + while end < glyphs.len() && glyphs[end].byte == byte { + end += 1; + } + clusters.push((start, end)); + start = end; + } + for &(start, end) in &clusters { + let cluster = &mut glyphs[start..end]; + if !cluster.iter().any(eligible) { + continue; + } + let class = rules::class(cluster[0].rendered_char); + let unit = em(&cluster[0]); + let body: f64 = cluster.iter().map(|g| g.moji.body).sum(); + for g in cluster.iter_mut() { + g.moji.active = true; + } + // Tracking must not make a proportional or half-width glyph appear to + // contain a removable half-em body. Shaping features already affect body. + if body >= unit * 0.8 { + let before = if cluster[0].moji.explicit_before { 0.0 } else { rules::leading(class) * unit }; + let after = if cluster[0].moji.explicit_after { 0.0 } else { rules::trailing(class) * unit }; + let trim = before + after; + if let Some(owner) = cluster.iter().position(|g| g.adv >= trim) { + cluster[0].moji.blank_before = before / unit.max(1e-9); + cluster[end - start - 1].moji.blank_after = after / unit.max(1e-9); + cluster[owner].adv -= trim; + for (i, g) in cluster.iter_mut().enumerate() { + g.dx += if i > owner { trim - before } else { -before }; + } + } + } + let head = &mut cluster[0]; + head.moji.start_aki = table.pair_with_bodies(if start == 0 { 23 } else { 22 }, class, 0.0, head.moji.blank_before); + head.moji.start = if head.moji.explicit_before { 0.0 } else { head.moji.start_aki.desired * unit }; + } + for (index, &(start, end)) in clusters.iter().enumerate() { + if !glyphs[start].moji.active { + continue; + } + let class = rules::class(glyphs[start].rendered_char); + let unit = em(&glyphs[start]); + let mut gap = Aki::default(); + if let Some(&(next, _)) = clusters.get(index + 1) + && glyphs[next].moji.active + && !glyphs[end - 1].moji.explicit_after + && !glyphs[next].moji.explicit_before + { + gap = table.pair_with_bodies( + class, + rules::class(glyphs[next].rendered_char), + glyphs[end - 1].moji.blank_after, + glyphs[next].moji.blank_before, + ); + } + let width: f64 = glyphs[start..end].iter().map(|g| g.adv).sum(); + let g = &mut glyphs[end - 1]; + g.moji.end_aki = table.pair_with_bodies(class, 22, g.moji.blank_after, 0.0); + let desired = (gap.desired * unit).max(-width); + g.moji.gap = desired; + g.moji.stretch = (gap.max * unit - desired).max(0.0); + g.moji.shrink = (desired - gap.min * unit).max(0.0).min(width + desired); + g.moji.priority = gap.priority; + g.moji.discrete = gap.discrete; + g.moji.end = if g.moji.explicit_after { 0.0 } else { g.moji.end_aki.desired * unit } - desired; + g.adv += desired; + } +} + +pub fn edges(line: &mut [Glyph]) { + if let Some(g) = line.first_mut() { + g.moji.at_start = true; + g.adv += g.moji.start; + g.dx += g.moji.start; + } + if let Some(g) = line.iter_mut().rev().find(|g| !crate::breaker::is_forced(g.ch)) { + g.adv += g.moji.end; + // A line-end gap is not the internal gap that happened to follow it. + let unit = em(g); + let aki = g.moji.end_aki; + g.moji.stretch = if g.moji.explicit_after { 0.0 } else { (aki.max - aki.desired).max(0.0) * unit }; + g.moji.shrink = if g.moji.explicit_after { 0.0 } else { (aki.desired - aki.min).max(0.0) * unit }; + g.moji.priority = aki.priority; + g.moji.discrete = aki.discrete; + } +} + +/// Consume spacing in priority order; discrete rules use an entire endpoint. +/// Returns the residual for ordinary word/letter/glyph justification. +pub fn distribute(line: &mut [Glyph], extra: f64, add: &mut [f64]) -> f64 { + let mut rem = extra.abs(); + let sign = extra.signum(); + let properties = |g: &Glyph, before: bool| { + if before { + let a = g.moji.start_aki; + let cap = if !g.moji.at_start || g.moji.explicit_before { + 0.0 + } else if sign < 0.0 { + (a.desired - a.min).max(0.0) * em(g) + } else { + (a.max - a.desired).max(0.0) * em(g) + }; + (a.priority, a.discrete, cap) + } else { + (g.moji.priority, g.moji.discrete, if sign < 0.0 { g.moji.shrink } else { g.moji.stretch }) + } + }; + for priority in 0..=10 { + if rem <= 1e-9 { + break; + } + // Leading and following continuous gaps participate in one pool. + // Equal priorities must not depend on whether a gap precedes a glyph. + let total: f64 = line + .iter() + .flat_map(|g| [true, false].map(|before| properties(g, before))) + .filter(|&(p, discrete, _)| p == priority && !discrete) + .map(|(_, _, cap)| cap) + .sum(); + if total > 0.0 { + let take = rem.min(total); + for (g, a) in line.iter_mut().zip(add.iter_mut()) { + for before in [true, false] { + let (p, discrete, cap) = properties(g, before); + if p == priority && !discrete { + let amount = sign * take * cap / total; + *a += amount; + if before { + g.dx += amount; + } + } + } + } + rem -= take; + } + // Never interpolate a non-floating rule. Compression may have to use + // its entire endpoint even when that leaves a little unused line width; + // refusing it would contradict the capacity accepted by the breaker. + for (g, a) in line.iter_mut().zip(add.iter_mut()) { + for before in [true, false] { + let (p, discrete, cap) = properties(g, before); + if rem > 1e-9 && p == priority && discrete && cap > 0.0 && (sign < 0.0 || cap <= rem + 1e-9) { + *a += sign * cap; + if before { + g.dx += sign * cap; + } + rem -= cap; + } + } + } + } + sign * rem +} + +/// Replace the final internal gap's elasticity by its line-end elasticity. +pub fn end_elastic(g: &Glyph, justify: bool, shrink: bool) -> [f64; 2] { + let unit = em(g); + let a = g.moji.end_aki; + [ + if justify { (if g.moji.explicit_after { 0.0 } else { (a.max - a.desired).max(0.0) * unit }) - g.moji.stretch } else { 0.0 }, + if shrink { (if g.moji.explicit_after { 0.0 } else { (a.desired - a.min).max(0.0) * unit }) - g.moji.shrink } else { 0.0 }, + ] +} + +pub fn start_elastic(g: &Glyph, justify: bool, shrink: bool) -> [f64; 2] { + let a = g.moji.start_aki; + if g.moji.explicit_before { + return [0.0; 2]; + } + [if justify { (a.max - a.desired).max(0.0) * em(g) } else { 0.0 }, if shrink { (a.desired - a.min).max(0.0) * em(g) } else { 0.0 }] +} diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index 68d9788e5..4ef99f9c6 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -54,6 +54,7 @@ pub struct Glyph { /// Resolved CJK boundary constraint, independent of glyph outlines. pub break_after: Option, pub cjk_hang: f64, + pub moji: crate::mojikumi::Metrics, pub ideographic_space_elastic: bool, pub character_alignment: designcraft_doc::cjk::CharacterAlignment, pub leading_model: designcraft_doc::cjk::LeadingModel, @@ -930,6 +931,7 @@ fn control_glyph(face: &Arc, p: &CharProps, auto_leading: TypeEnv, sty space: face.advance(face.glyph_for(' ')) * k * p.h_scale, break_after: None, cjk_hang: 0.0, + moji: crate::mojikumi::Metrics { explicit_before: p.leading_aki.is_some(), explicit_after: p.trailing_aki.is_some(), ..Default::default() }, ideographic_space_elastic: false, character_alignment: p.character_alignment, leading_model: p.leading_model, @@ -1039,6 +1041,7 @@ fn shape_segment( (sg.x_offset as f64 * k * hs, -(sg.y_offset as f64) * k * p.v_scale) }; let (ascent, descent) = if up { (em_top * k_v, -em_bottom * k_v) } else { (ascent, descent) }; + let body = adv; if ch == SOFT_HYPHEN { adv = 0.0; } else if last_in_cluster { @@ -1076,6 +1079,12 @@ fn shape_segment( space, break_after: None, cjk_hang: 0.0, + moji: crate::mojikumi::Metrics { + body, + explicit_before: p.leading_aki.is_some(), + explicit_after: p.trailing_aki.is_some(), + ..Default::default() + }, ideographic_space_elastic: false, character_alignment: p.character_alignment, leading_model: p.leading_model, @@ -1124,5 +1133,7 @@ pub(crate) fn hyphen_after(g: &Glyph) -> Glyph { h.byte = g.byte + g.len; h.len = 0; h.ch = '-'; + h.rendered_char = '-'; + h.moji = Default::default(); h } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 0738c907f..b815b015e 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -3244,3 +3244,331 @@ fn generated_note_labels_use_coverage_fallback() { assert_ne!(glyph.gid, 0); assert_eq!(glyph.face.family, "Generated Label Coverage"); } + +#[test] +fn mojikumi_custom_pairs_change_positions_and_breaks_without_font_scaling() { + use designcraft_doc::cjk::{MojikumiAki, MojikumiTable}; + let (mut d, sid, _) = doc_with( + "A1A1A1", + Rect::new(0.0, 0.0, 55.0, 300.0), + ParaAttrs { hyphenate: Some(false), composer: Some(Composer::SingleLine), ..Default::default() }, + ); + let before = compose_story(&d, sid, &ComposeOptions::default()); + Arc::make_mut(&mut d.styles).mojikumi_tables.push(MojikumiTable { + name: "Synthetic".into(), + based_on: "SimpChineseDefault".into(), + overrides: vec![ + MojikumiAki { target_class: 18, side_class: 25, after: true, minimum: 1.0, desired: 1.0, maximum: 1.0, ..Default::default() }, + MojikumiAki { target_class: 25, side_class: 18, after: true, minimum: 1.0, desired: 1.0, maximum: 1.0, ..Default::default() }, + ], + }); + d.story_mut(sid).unwrap().paras[0].para.mojikumi = Some("MojikumiTable/Synthetic".into()); + let after = compose_story(&d, sid, &ComposeOptions::default()); + assert!(all_lines(&after).len() > all_lines(&before).len()); + let a = &all_lines(&after)[0].glyphs; + let b = &all_lines(&before)[0].glyphs; + assert!((a[1].x - b[1].x - 12.0).abs() < 0.001); + assert_eq!(a[0].sx, b[0].sx); + assert!(all_lines(&after).iter().all(|l| l.end_x <= l.x1 + 0.01)); +} + +#[test] +fn mojikumi_paragraph_indent_adds_to_paragraph_settings_and_rounds_no_glyphs() { + use designcraft_doc::cjk::{MojikumiAki, MojikumiTable}; + let (mut d, sid, _) = doc_with( + "ABC", + Rect::new(0.0, 0.0, 200.0, 100.0), + ParaAttrs { first_line_indent: Some(7.0), mojikumi: Some("MojikumiTable/Indent".into()), ..Default::default() }, + ); + Arc::make_mut(&mut d.styles).mojikumi_tables.push(MojikumiTable { + name: "Indent".into(), + based_on: "SimpChineseDefault".into(), + overrides: vec![MojikumiAki { + target_class: 18, + side_class: 23, + after: false, + minimum: 2.0, + desired: 2.0, + maximum: 2.0, + ..Default::default() + }], + }); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let line = all_lines(&cs)[0]; + assert!((line.glyphs[0].x - 31.0).abs() < 0.001, "{}", line.glyphs[0].x); + assert_eq!(line.glyphs.len(), 3); +} + +#[test] +fn mojikumi_justifies_cjk_without_spaces_and_respects_fixed_spacing() { + use designcraft_doc::cjk::{MojikumiAki, MojikumiTable}; + let (mut d, sid, _) = doc_with( + "A1A1", + Rect::new(0.0, 0.0, 100.0, 100.0), + ParaAttrs { align: Some(Align::FullyJustified), mojikumi: Some("MojikumiTable/Elastic".into()), ..Default::default() }, + ); + Arc::make_mut(&mut d.styles).mojikumi_tables.push(MojikumiTable { + name: "Elastic".into(), + based_on: "SimpChineseDefault".into(), + overrides: vec![MojikumiAki { + target_class: 18, + side_class: 25, + after: true, + minimum: 0.0, + desired: 0.0, + maximum: 5.0, + priority: 1, + ..Default::default() + }], + }); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!((all_lines(&cs)[0].end_x - 100.0).abs() < 0.001, "{}", all_lines(&cs)[0].end_x); +} + +#[test] +fn mojikumi_fixed_pairs_are_not_stretched_by_single_word_justification() { + use designcraft_doc::cjk::{MojikumiAki, MojikumiTable}; + let (mut d, sid, _) = doc_with( + "A1A1", + Rect::new(0.0, 0.0, 150.0, 100.0), + ParaAttrs { align: Some(Align::FullyJustified), mojikumi: Some("MojikumiTable/Fixed".into()), ..Default::default() }, + ); + Arc::make_mut(&mut d.styles).mojikumi_tables.push(MojikumiTable { + name: "Fixed".into(), + based_on: "SimpChineseDefault".into(), + overrides: [(18, 25), (25, 18)] + .into_iter() + .map(|(target_class, side_class)| MojikumiAki { + target_class, + side_class, + after: true, + minimum: 0.25, + desired: 0.25, + maximum: 0.25, + ..Default::default() + }) + .collect(), + }); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let line = all_lines(&cs)[0]; + assert!(line.end_x < 100.0, "Fixed spacing was stretched: {}", line.end_x); + d.story_mut(sid).unwrap().paras[0].para.align = Some(Align::Left); + let ragged = compose_story(&d, sid, &ComposeOptions::default()); + assert_eq!(line.end_x, all_lines(&ragged)[0].end_x); +} + +#[test] +fn mojikumi_kinsoku_push_in_and_push_out_select_different_legal_breaks() { + use designcraft_doc::cjk::{MojikumiAki, MojikumiTable}; + let text = "甲乙丙丁戊己庚辛"; + let (mut d, sid, _) = doc_with( + text, + Rect::new(0.0, 0.0, 45.0, 300.0), + ParaAttrs { + composer: Some(Composer::SingleLine), + mojikumi: Some("MojikumiTable/Compress".into()), + kinsoku_type: Some("KinsokuPushInFirst".into()), + ..Default::default() + }, + ); + Arc::make_mut(&mut d.styles).mojikumi_tables.push(MojikumiTable { + name: "Compress".into(), + based_on: "SimpChineseDefault".into(), + overrides: vec![MojikumiAki { + target_class: 12, + side_class: 12, + after: true, + minimum: 0.0, + desired: 1.0, + maximum: 1.0, + priority: 1, + ..Default::default() + }], + }); + let inside = compose_story(&d, sid, &ComposeOptions::default()); + d.story_mut(sid).unwrap().paras[0].para.kinsoku_type = Some("KinsokuPushOutFirst".into()); + let outside = compose_story(&d, sid, &ComposeOptions::default()); + assert!(all_lines(&inside)[0].range.end > all_lines(&outside)[0].range.end); + for cs in [&inside, &outside] { + assert!(all_lines(cs).iter().all(|l| l.end_x <= l.x1 + 0.01)); + } +} + +#[test] +fn mojikumi_priority_and_elastic_line_start_are_used_in_placement() { + use designcraft_doc::cjk::{MojikumiAki, MojikumiTable}; + let (mut d, sid, _) = doc_with("A1B2", Rect::new(0.0, 0.0, 45.0, 100.0), ParaAttrs::default()); + let natural = compose_story(&d, sid, &ComposeOptions::default()); + let middle_gap = all_lines(&natural)[0].glyphs[2].x - all_lines(&natural)[0].glyphs[1].x; + Arc::make_mut(&mut d.styles).mojikumi_tables.push(MojikumiTable { + name: "Priority".into(), + based_on: "SimpChineseDefault".into(), + overrides: vec![ + MojikumiAki { + target_class: 18, + side_class: 25, + after: true, + minimum: 0.0, + desired: 0.0, + maximum: 10.0, + priority: 1, + ..Default::default() + }, + MojikumiAki { + target_class: 25, + side_class: 18, + after: true, + minimum: 0.0, + desired: 0.0, + maximum: 10.0, + priority: 2, + ..Default::default() + }, + ], + }); + let para = &mut d.story_mut(sid).unwrap().paras[0].para; + para.mojikumi = Some("MojikumiTable/Priority".into()); + para.align = Some(Align::FullyJustified); + let result = compose_story(&d, sid, &ComposeOptions::default()); + let line = all_lines(&result)[0]; + assert!((line.end_x - 45.0).abs() < 0.001); + assert!((line.glyphs[2].x - line.glyphs[1].x - middle_gap).abs() < 0.001, "Priority 2 was used before priority 1 was exhausted"); + + let (mut d, sid, _) = doc_with( + "A", + Rect::new(0.0, 0.0, 30.0, 100.0), + ParaAttrs { mojikumi: Some("MojikumiTable/Start".into()), align: Some(Align::FullyJustified), ..Default::default() }, + ); + Arc::make_mut(&mut d.styles).mojikumi_tables.push(MojikumiTable { + name: "Start".into(), + based_on: "SimpChineseDefault".into(), + overrides: vec![MojikumiAki { + target_class: 18, + side_class: 23, + after: false, + minimum: 0.0, + desired: 0.0, + maximum: 5.0, + priority: 1, + ..Default::default() + }], + }); + let result = compose_story(&d, sid, &ComposeOptions::default()); + let line = all_lines(&result)[0]; + assert!((line.end_x - 30.0).abs() < 0.001); + assert!(line.glyphs[0].x > 10.0, "Leading gap must move the first outline too"); +} + +#[test] +fn mojikumi_equal_priority_shares_leading_and_internal_space() { + use designcraft_doc::cjk::{MojikumiAki, MojikumiTable}; + let (mut d, sid, _) = doc_with("A1", Rect::new(0.0, 0.0, 40.0, 100.0), ParaAttrs::default()); + let natural = compose_story(&d, sid, &ComposeOptions::default()); + let old = all_lines(&natural)[0]; + let extra = 40.0 - old.end_x; + let old_second = old.glyphs[1].x; + Arc::make_mut(&mut d.styles).mojikumi_tables.push(MojikumiTable { + name: "Shared".into(), + based_on: "SimpChineseDefault".into(), + overrides: [(18, 23, false), (18, 25, true)] + .into_iter() + .map(|(target_class, side_class, after)| MojikumiAki { + target_class, + side_class, + after, + maximum: 10.0, + priority: 1, + ..Default::default() + }) + .collect(), + }); + let para = &mut d.story_mut(sid).unwrap().paras[0].para; + para.mojikumi = Some("MojikumiTable/Shared".into()); + para.align = Some(Align::FullyJustified); + let result = compose_story(&d, sid, &ComposeOptions::default()); + let line = all_lines(&result)[0]; + assert!((line.glyphs[0].x - extra / 2.0).abs() < 0.001); + assert!((line.glyphs[1].x - old_second - extra).abs() < 0.001); + assert!((line.end_x - 40.0).abs() < 0.001); +} + +#[test] +fn mojikumi_nonfloating_and_leading_only_compression_fit_ragged_lines() { + use designcraft_doc::cjk::{MojikumiAki, MojikumiTable}; + for composer in [Composer::SingleLine, Composer::Paragraph] { + for leading in [true, false] { + let (mut d, sid, _) = doc_with( + "A", + Rect::new(0.0, 0.0, 15.0, 100.0), + ParaAttrs { composer: Some(composer), mojikumi: Some("MojikumiTable/Endpoint".into()), ..Default::default() }, + ); + Arc::make_mut(&mut d.styles).mojikumi_tables.push(MojikumiTable { + name: "Endpoint".into(), + based_on: "SimpChineseDefault".into(), + overrides: vec![MojikumiAki { + target_class: 18, + side_class: if leading { 23 } else { 22 }, + after: !leading, + minimum: 0.0, + desired: 1.0, + maximum: 1.0, + priority: 1, + does_not_float: true, + }], + }); + let result = compose_story(&d, sid, &ComposeOptions::default()); + let line = all_lines(&result)[0]; + assert!(line.end_x <= 15.0, "{composer:?}, leading={leading}: {}", line.end_x); + assert!(line.glyphs[0].x.abs() < 0.001, "Discrete leading gap must reach its endpoint"); + } + } +} + +#[test] +fn mojikumi_places_custom_gap_after_combining_marks() { + use designcraft_doc::cjk::{MojikumiAki, MojikumiTable}; + let (mut d, sid, _) = doc_with("A\u{0323}\u{0301}1", Rect::new(0.0, 0.0, 200.0, 100.0), ParaAttrs::default()); + let natural = compose_story(&d, sid, &ComposeOptions::default()); + let old = &all_lines(&natural)[0].glyphs; + assert!(old.len() >= 3, "Exercise a cluster with a separate combining glyph"); + Arc::make_mut(&mut d.styles).mojikumi_tables.push(MojikumiTable { + name: "Clusters".into(), + based_on: "SimpChineseDefault".into(), + overrides: vec![MojikumiAki { + target_class: 18, + side_class: 25, + after: true, + minimum: 1.0, + desired: 1.0, + maximum: 1.0, + ..Default::default() + }], + }); + d.story_mut(sid).unwrap().paras[0].para.mojikumi = Some("MojikumiTable/Clusters".into()); + let result = compose_story(&d, sid, &ComposeOptions::default()); + let new = &all_lines(&result)[0].glyphs; + assert_eq!(new.len(), old.len()); + for (before, after) in old.iter().zip(new) { + let expected = if before.rendered_char == '1' { 12.0 } else { 0.0 }; + assert!((after.x - before.x - expected).abs() < 0.001, "Glyph {:?} moved incorrectly", before.rendered_char); + } +} + +#[test] +fn mojikumi_does_not_restore_fullwidth_blanks_to_narrow_tracked_punctuation() { + let family = "Mojikumi Narrow Body"; + let font = designcraft_fonts::testing::font_mapping(family, &[('(', 'i'), ('A', 'A')]).unwrap(); + designcraft_fonts::FontDb::global().add_font(font); + let (mut d, sid, _) = doc_with("(A", Rect::new(0.0, 0.0, 200.0, 100.0), ParaAttrs::default()); + d.story_mut(sid).unwrap().format_chars(0.."(A".len(), |f| { + f.over.font_family = Some(family.into()); + f.over.tracking = Some(1000.0); + }); + let natural = compose_story(&d, sid, &ComposeOptions::default()); + d.story_mut(sid).unwrap().paras[0].para.mojikumi = Some("SimpChineseDefault".into()); + let result = compose_story(&d, sid, &ComposeOptions::default()); + for (a, b) in all_lines(&natural)[0].glyphs.iter().zip(&all_lines(&result)[0].glyphs) { + assert!((a.x - b.x).abs() < 0.001); + assert!((a.adv - b.adv).abs() < 0.001); + } +} diff --git a/crates/doc/src/cjk.rs b/crates/doc/src/cjk.rs index 1ad2221e4..a700ad0c5 100644 --- a/crates/doc/src/cjk.rs +++ b/crates/doc/src/cjk.rs @@ -127,8 +127,7 @@ pub enum KinsokuHang { Force, } -/// Preserved IDML spacing tables. Keeping these separate from the supported -/// character spacing is essential: a stored table is not an applied rule. +/// IDML spacing tables resolved by `mojikumi::Rules` for composition and diagnostics. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct MojikumiTable { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 2a4428dac..937bbbb4f 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -21,6 +21,7 @@ pub mod endnotes; pub mod ids; pub mod index; pub mod item; +pub mod mojikumi; pub mod notes; pub mod otf; pub mod page; diff --git a/crates/doc/src/mojikumi.rs b/crates/doc/src/mojikumi.rs new file mode 100644 index 000000000..fbb8a13df --- /dev/null +++ b/crates/doc/src/mojikumi.rs @@ -0,0 +1,211 @@ +//! Unicode Mojikumi rules shared by composition and preflight. +//! +//! IDML class numbers follow the documented Mojikumi override interchange format; +//! they are not the similarly numbered JLReq classes. Spacing is measured in em. +use crate::{Styles, cjk::MojikumiAki}; + +#[derive(Clone, Copy, Debug, Default)] +pub struct Aki { + pub min: f64, + pub desired: f64, + pub max: f64, + pub priority: u8, + /// Discrete endpoints rather than a continuously floating amount. + pub discrete: bool, +} +impl Aki { + fn fixed(value: f64) -> Self { + Self { min: value, desired: value, max: value, ..Self::default() } + } +} + +/// Resolved table; custom rows override the base preset in document order. +pub struct Rules<'a> { + chinese: bool, + rows: &'a [MojikumiAki], +} +impl<'a> Rules<'a> { + pub fn resolve(styles: &'a Styles, name: &str) -> Result, String> { + if matches!(name, "" | "Nothing" | "None") { + return Ok(None); + } + let key = name.strip_prefix("MojikumiTable/").unwrap_or(name); + let table = styles.mojikumi_tables.iter().find(|t| t.name == key); + let base = table.map_or(key, |t| if t.based_on.is_empty() { t.name.as_str() } else { t.based_on.as_str() }); + let chinese = match base.trim_start_matches("$ID/") { + "SimpChineseDefault" | "TradChineseDefault" => true, + "LineEndAllOneHalfEmEnum" | "kMojikumiDefaultName1" => false, + _ => return Err(format!("unknown base preset `{base}`")), + }; + let rows = table.map_or(&[][..], |t| t.overrides.as_slice()); + for r in rows { + if !valid_class(r.target_class) || !valid_class(r.side_class) { + return Err(format!("unsupported character classes {}/{}", r.target_class, r.side_class)); + } + if ![r.minimum, r.desired, r.maximum].iter().all(|v| v.is_finite() && (-1.0..=100.0).contains(v)) + || r.minimum > r.desired + || r.desired > r.maximum + || !(0..=9).contains(&r.priority) + { + return Err("invalid spacing range or priority".into()); + } + } + Ok(Some(Self { chinese, rows })) + } + + fn override_pair(&self, left: i16, right: i16) -> Option<&MojikumiAki> { + self.rows + .iter() + .rev() + .find(|r| if r.after { r.target_class == left && r.side_class == right } else { r.target_class == right && r.side_class == left }) + } + + /// Preset spacing restores only blank removed from shaped punctuation bodies. + /// Explicit custom rows retain their requested dimensions. + pub fn pair_with_bodies(&self, left: i16, right: i16, after_left: f64, before_right: f64) -> Aki { + let mut aki = self.pair(left, right); + if self.override_pair(left, right).is_none() { + let missing = if matches!(left, 22 | 23) { + leading(right) - before_right + } else if right == 22 { + trailing(left) - after_left + } else { + trailing(left).max(leading(right)) - after_left.max(before_right) + } + .max(0.0); + aki.min = (aki.min - missing).max(0.0); + aki.desired = (aki.desired - missing).max(0.0); + aki.max = (aki.max - missing).max(0.0); + } + aki + } + + pub fn pair(&self, left: i16, right: i16) -> Aki { + if let Some(row) = self.override_pair(left, right) { + return Aki { + min: row.minimum, + desired: row.desired, + max: row.maximum, + priority: if row.priority == 0 { 10 } else { row.priority as u8 }, + discrete: row.does_not_float, + }; + } + // Boundaries: Chinese keeps full punctuation; the half-em Japanese set + // trims opening punctuation at line start and closing punctuation at end. + if matches!(left, 22 | 23) { + return Aki::fixed(if self.chinese { leading(right) } else { 0.0 }); + } + if right == 22 { + return Aki::fixed(if self.chinese { trailing(left) } else { 0.0 }); + } + let punctuation = trailing(left).max(leading(right)); + if punctuation > 0.0 { + return Aki { min: 0.0, desired: punctuation, max: punctuation, priority: 1, discrete: false }; + } + let roman = |c| matches!(c, 18 | 25); + let ideograph = |c| matches!(c, 3 | 7..=9 | 11 | 12 | 24 | 33); + if (roman(left) && ideograph(right)) || (ideograph(left) && roman(right)) { + return Aki { min: 0.0, desired: 0.25, max: 0.5, priority: 3, discrete: false }; + } + if ideograph(left) && ideograph(right) { + return Aki { min: 0.0, desired: 0.0, max: 0.25, priority: 10, discrete: false }; + } + Aki::default() + } +} + +pub fn valid_class(c: i16) -> bool { + matches!(c, 1..=12 | 18 | 21..=33) +} +/// Natural half-body blank before a full-width punctuation character. +pub fn leading(c: i16) -> f64 { + match c { + 1 | 26 | 27 => 0.5, + 4 | 5 | 32 => 0.25, + _ => 0.0, + } +} +pub fn trailing(c: i16) -> f64 { + match c { + 2 | 6 | 21 | 28..=31 => 0.5, + 4 | 5 | 32 => 0.25, + _ => 0.0, + } +} + +/// Unicode classification, independent of font availability and localized names. +pub fn class(c: char) -> i16 { + match c { + '「' | '『' | '「' => 26, + '(' => 27, + '〈' | '《' | '【' | '〔' | '〖' | '〘' | '[' | '{' | '‘' | '“' => 1, + '」' | '』' | '」' => 28, + ')' => 29, + '〉' | '》' | '】' | '〕' | '〗' | '〙' | ']' | '}' | '’' | '”' => 2, + '。' | '。' => 6, + '、' | '、' => 21, + ',' => 30, + '.' => 31, + ':' | ';' => 32, + '・' | '·' => 5, + '!' | '?' => 4, + '…' | '‥' | '—' | '―' => 7, + '¥' | '$' | '£' | '¥' => 8, + '%' | '‰' | '℃' | '°' => 9, + '\u{3000}' => 10, + 'ぁ' | 'ぃ' | 'ぅ' | 'ぇ' | 'ぉ' | 'っ' | 'ゃ' | 'ゅ' | 'ょ' | 'ゎ' | 'ァ' | 'ィ' | 'ゥ' | 'ェ' | 'ォ' | 'ッ' | 'ャ' | 'ュ' | 'ョ' | 'ヮ' + | 'ー' | '々' | 'ゝ' | 'ゞ' | 'ヽ' | 'ヾ' => 3, + '\u{3040}'..='\u{309f}' => 11, + '\u{30a0}'..='\u{30ff}' | '\u{31f0}'..='\u{31ff}' => 33, + '0'..='9' => 24, + '0'..='9' => 25, + '\u{2e80}'..='\u{a4cf}' | '\u{ac00}'..='\u{d7af}' | '\u{f900}'..='\u{faff}' | '\u{ff01}'..='\u{ff60}' | '\u{20000}'..='\u{323af}' => 12, + _ => 18, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cjk::MojikumiTable; + + #[test] + fn overrides_are_directional_and_unknown_tables_are_not_silently_applied() { + let mut styles = Styles::default(); + styles.mojikumi_tables.push(MojikumiTable { + name: "Pair".into(), + based_on: "SimpChineseDefault".into(), + overrides: vec![MojikumiAki { + target_class: 12, + side_class: 18, + after: false, + minimum: 0.1, + desired: 0.2, + maximum: 0.3, + priority: 2, + does_not_float: false, + }], + }); + let rule = Rules::resolve(&styles, "MojikumiTable/Pair").unwrap().unwrap(); + assert_eq!(rule.pair(18, 12).desired, 0.2); + assert_eq!(rule.pair(12, 18).desired, 0.25); + assert!(Rules::resolve(&styles, "Unknown").is_err()); + assert!(Rules::resolve(&styles, "Nothing").unwrap().is_none()); + styles.mojikumi_tables[0].overrides[0].minimum = 0.4; + assert!(Rules::resolve(&styles, "MojikumiTable/Pair").is_err()); + } + + #[test] + fn chinese_and_half_em_boundaries_differ_and_classes_cover_punctuation() { + let styles = Styles::default(); + let cn = Rules::resolve(&styles, "SimpChineseDefault").unwrap().unwrap(); + let jp = Rules::resolve(&styles, "LineEndAllOneHalfEmEnum").unwrap().unwrap(); + assert_eq!(cn.pair(22, class('(')).desired, 0.5); + assert_eq!(jp.pair(22, class('(')).desired, 0.0); + assert_eq!(cn.pair(class('。'), 22).desired, 0.5); + assert_eq!(jp.pair(class('。'), 22).desired, 0.0); + assert_ne!(class('1'), class('1')); + assert_ne!(class(','), class('、')); + assert_ne!(class('「'), class('(')); + } +} diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 449e37d75..9a7444ed0 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -29,7 +29,7 @@ pub fn specs() -> Vec { } /// Every story's text, including table cells and footnotes. -fn for_each_story(d: &Document, f: &mut dyn FnMut(&Story)) { +pub(super) fn for_each_story(d: &Document, f: &mut dyn FnMut(&Story)) { let mut pending: Vec<&Story> = d.stories.values().map(AsRef::as_ref).collect(); while let Some(st) = pending.pop() { f(st); diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs index 25b9d45fd..5a69ff1bf 100644 --- a/crates/engine/src/cmd/preflight.rs +++ b/crates/engine/src/cmd/preflight.rs @@ -138,6 +138,9 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { page: location.and_then(|(_, page)| page), }); } + } + // Nested note and cell text uses paragraph rules too. + super::fonts::for_each_story(d, &mut |story| { let ranges = story.para_ranges(); for (pi, pf) in story.paras.iter().enumerate() { let (para, base) = d.styles.resolve_para(pf); @@ -155,10 +158,13 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { .insert("Paragraph Kashida width preset is preserved; automatic elongation uses the engine's bounded allocation".into()); } } - if !matches!(para.mojikumi.as_str(), "" | "Nothing" | "None") { - unsupported_typography.insert(format!("Mojikumi `{}` is preserved, but its spacing table is not applied", para.mojikumi)); + if let Err(reason) = designcraft_doc::mojikumi::Rules::resolve(&d.styles, ¶.mojikumi) { + unsupported_typography.insert(format!("Mojikumi `{}` is not applied: {reason}", para.mojikumi)); } - if !para.kinsoku_type.is_empty() { + if !matches!( + para.kinsoku_type.as_str(), + "" | "KinsokuPushInFirst" | "KinsokuPushOutFirst" | "KinsokuPushOutOnly" | "KinsokuPrioritizeAdjustmentAmount" + ) { unsupported_typography .insert(format!("Kinsoku priority `{}` is preserved, but push-in/push-out priority is not applied", para.kinsoku_type)); } @@ -180,7 +186,7 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec { } } } - } + }); // Use the same effective character-to-font mapping as Find Font, including // anonymous note/cell stories. Unused composite entries are not missing text. for font in super::fonts::used_fonts(d) { @@ -400,3 +406,25 @@ mod generated_glyph_tests { assert_eq!(issue["page"], 0); } } + +#[cfg(test)] +mod mojikumi_nested_tests { + use super::*; + use std::sync::Arc; + #[test] + fn unknown_table_in_a_note_inside_a_cell_is_reported() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("frame.create", &json!({"rect":[20,20,300,400],"content":"text","text":"Body"})).unwrap(); + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let sid = *d.stories.keys().next().unwrap(); + let mut table = designcraft_doc::Table::new(1, 1, 1, 0, 0, 100.0); + let cell = &mut table.cells[0].text; + let mut pf = designcraft_doc::ParaFormat::default(); + pf.para.mojikumi = Some("MojikumiTable/Unrecognized".into()); + cell.insert_note(0, "Note", pf); + d.story_mut(sid).unwrap().insert_table(0, table); + let issues = check(&s, 150.0); + assert_eq!(issues.iter().filter(|i| i.kind == "unsupportedTypography" && i.message.contains("Unrecognized")).count(), 1); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 35e7bbd7e..29287fd65 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -687,3 +687,17 @@ fn preflight_distinguishes_unplaced_stories_and_locates_footnote_overset() { assert_eq!(unplaced["severity"], "warning"); assert!(unplaced.get("page").is_none()); } + +#[test] +fn preflight_mojikumi_uses_the_same_supported_rules_as_composition() { + let mut s = session(); + s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "中文 A1"})).unwrap(); + let story = s.doc().unwrap().doc.stories.keys().next().unwrap().0; + s.execute("text.select", &json!({"story": story, "anchor": 0, "focus": 1})).unwrap(); + for (name, unsupported) in [("SimpChineseDefault", false), ("LineEndAllOneHalfEmEnum", false), ("UnknownPreset", true), ("Nothing", false)] { + s.execute("type.para", &json!({"attrs": {"mojikumi": name, "kinsokuType": "KinsokuPushInFirst"}})).unwrap(); + let result = s.execute("preflight.run", &json!({})).unwrap(); + let warnings: Vec<_> = result["issues"].as_array().unwrap().iter().filter(|i| i["kind"] == "unsupportedTypography").collect(); + assert_eq!(!warnings.is_empty(), unsupported, "{name}: {warnings:?}"); + } +} diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index fb719063e..0bd5de8ac 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -105,6 +105,7 @@ struct Importer<'r> { stroke_styles: Vec, styles: Styles, composite_names: HashMap, + mojikumi_names: HashMap, kinsoku: HashMap, para_names: HashMap, char_names: HashMap, @@ -142,6 +143,40 @@ fn lab_to_color(l: f32, a: f32, b: f32) -> Color { Color::rgb(rgb[0], rgb[1], rgb[2]) } +fn mojikumi_row(e: &El) -> Result { + let number = |key: &str| -> Result { + let Some(raw) = e.prop(key) else { + return Ok(0.0); + }; + raw.trim().parse::().ok().filter(|v| v.is_finite()).ok_or_else(|| IdmlError::Invalid(format!("Mojikumi row has invalid {key}"))) + }; + let integer = |key: &str| -> Result { + let v = number(key)?; + if v.fract() != 0.0 || v < f64::from(i16::MIN) || v > f64::from(i16::MAX) { + return Err(IdmlError::Invalid(format!("Mojikumi {key} must be an in-range integer"))); + } + Ok(v as i16) + }; + let boolean = |key: &str| -> Result { + match e.prop(key).as_deref().map(str::trim) { + None | Some("false") => Ok(false), + Some("true") => Ok(true), + _ => Err(IdmlError::Invalid(format!("Mojikumi row has invalid {key}"))), + } + }; + let row = designcraft_doc::cjk::MojikumiAki { + target_class: integer("TargetMojikumiClass")?, + side_class: integer("SideMojikumiClass")?, + after: boolean("SideIsAfterTarget")?, + minimum: number("Minimum")?, + desired: number("Desired")?, + maximum: number("Maximum")?, + priority: integer("CompressionPriority")?, + does_not_float: boolean("AkiDoesNotFloat")?, + }; + Ok(row) +} + fn nums(s: &str) -> Vec { s.split_whitespace().filter_map(|v| v.parse().ok()).collect() } @@ -193,6 +228,7 @@ impl<'r> Importer<'r> { stroke_styles: Vec::new(), styles, composite_names: HashMap::new(), + mojikumi_names: HashMap::new(), kinsoku: HashMap::new(), para_names: HashMap::new(), char_names: HashMap::new(), @@ -314,23 +350,14 @@ impl<'r> Importer<'r> { } } for e in top.iter().filter(|e| e.local() == "MojikumiTable") { - let overrides = e - .prop_el("OverrideMojikumiAkiList") - .map(|l| { - l.find_all("OverrideMojikumiAkiType") - .map(|r| designcraft_doc::cjk::MojikumiAki { - target_class: r.num("TargetMojikumiClass").unwrap_or(0.0) as i16, - side_class: r.num("SideMojikumiClass").unwrap_or(0.0) as i16, - after: r.boolean("SideIsAfterTarget").unwrap_or(false), - minimum: r.num("Minimum").unwrap_or(0.0), - desired: r.num("Desired").unwrap_or(0.0), - maximum: r.num("Maximum").unwrap_or(0.0), - priority: r.num("CompressionPriority").unwrap_or(0.0) as i16, - does_not_float: r.boolean("AkiDoesNotFloat").unwrap_or(false), - }) - .collect() - }) - .unwrap_or_default(); + if let (Some(id), Some(name)) = (e.get("Self"), e.get("Name")) { + self.mojikumi_names.insert(id.into(), format!("MojikumiTable/{}", unescape_id(name))); + } + let overrides = if let Some(list) = e.prop_el("OverrideMojikumiAkiList") { + list.find_all("OverrideMojikumiAkiType").map(mojikumi_row).collect::>>()? + } else { + Vec::new() + }; self.styles.mojikumi_tables.push(designcraft_doc::cjk::MojikumiTable { name: unescape_id(e.get("Name").unwrap_or("")), based_on: e.get("BasedOnMojikumiSet").unwrap_or("").into(), @@ -1184,7 +1211,7 @@ impl<'r> Importer<'r> { "KinsokuHangForce" => Some(designcraft_doc::cjk::KinsokuHang::Force), _ => None, }); - a.mojikumi = e.prop("Mojikumi"); + a.mojikumi = e.prop("Mojikumi").map(|name| self.mojikumi_names.get(&name).cloned().unwrap_or_else(|| unescape_id(&name))); a.kinsoku_type = e.prop("KinsokuType"); a.bunri_kinshi = e.boolean("BunriKinshi"); a.rensuuji = e.boolean("Rensuuji"); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index eddd42e12..4f27e1593 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1178,12 +1178,11 @@ fn cjk_composite_fonts_and_custom_kinsoku_are_document_resources() { } #[test] -fn cjk_unsupported_mojikumi_is_preserved_instead_of_silently_dropped() { - let map = DESIGNMAP.replace("", r#" +fn cjk_mojikumi_overrides_and_priority_round_trip() { + let map = DESIGNMAP.replace("", r#" "#); - let story = - STORY.replace(""#; + for (key, value) in [ + ("TargetMojikumiClass", "12.9"), + ("SideMojikumiClass", "65536"), + ("Minimum", "NaN"), + ("Desired", "oops"), + ("Minimum", "0.4"), + ("Maximum", "0.1"), + ("Maximum", "101"), + ("CompressionPriority", "10"), + ("AkiDoesNotFloat", "maybe"), + ] { + let mut row = crate::xml::parse(good.as_bytes()).unwrap(); + row.set(key, value); + let mut xml = String::new(); + row.write(&mut xml, 0); + let map = DESIGNMAP.replace("", &format!("{xml}")); + let bytes = zip_files(&[ + ("designmap.xml", &map), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", STYLES), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", SPREAD), + ("Stories/Story_s1.xml", STORY), + ]); + if matches!((key, value), ("Minimum", "0.4") | ("Maximum", "0.1" | "101") | ("CompressionPriority", "10")) { + // Finite, representable rules survive import even if composition + // cannot apply them. The resolver supplies a diagnostic instead. + let document = import_idml(&bytes).unwrap(); + document.check().unwrap(); + assert!(designcraft_doc::mojikumi::Rules::resolve(&document.styles, "MojikumiTable/Bad").is_err()); + let restored = import_idml(&export_idml(&document)).unwrap(); + assert_eq!(restored.styles.mojikumi_tables, document.styles.mojikumi_tables); + } else { + let err = import_idml(&bytes).unwrap_err().to_string(); + assert!(err.contains("Mojikumi"), "{key}={value}: {err}"); + } + } +} diff --git a/docs/mojikumi.md b/docs/mojikumi.md new file mode 100644 index 000000000..fcdf28074 --- /dev/null +++ b/docs/mojikumi.md @@ -0,0 +1,18 @@ +# Mojikumi repair scope + +The retained changes address reproducible implementation defects: equal-priority +spacing allocation, discrete compression endpoints, leading-only compression, +spacing after complete shaped clusters, and tracking that made narrow punctuation +look full-width. Preflight also checks nested note and table-cell paragraph rules. + +IDML import rejects malformed/non-finite numbers and integer coercion. Finite, +representable rules outside the composer's supported range remain preserved; +the rule resolver reports unsupported settings instead of rejecting the document. + +The broad preset expansion, regional side-bearing changes, Tsume combination +policy, and CID preference/diagnostic extension were withdrawn after review. +The original supported preset set and Unicode classification remain in place. +No claim of full Adobe Mojikumi conformance or exact output parity is made. + +Reference for priority semantics: +[Adobe spacing priorities](https://helpx.adobe.com/indesign/desktop/language-and-proofing/chinese-japanese-and-korean/set-spacing-priorities-in-mojikumi-character-classes.html).