Merge #129: Fix IDML Mojikumi spacing, kinsoku composition, and nested text preflight

#129's first commit (4f8d230) holds #124 and #126, which are already merged. Each conflicted file is a three-way merge of #129's own commits (4f8d230..pr/129) onto this branch, which takes #129's side against #126 and keeps the chain's (#81/#84) code.
Conflict: ROADMAP.md: kept both entries
Conflict: crates/compose/src/shape.rs: #129's Mojikumi metrics added; #81's pieces, the #126 composite-note piece and the #124 inline-object metrics unchanged
Conflict: crates/compose/src/tests.rs: tests only; #129's tests added, no duplicates of #124/#126's
Conflict: crates/engine/src/cmd/fonts.rs: #129's pub(super) for_each_story; #126's tests kept once
Conflict: crates/engine/src/cmd/preflight.rs: #129's nested-story paragraph rules; the used_fonts loop kept once
Conflict: crates/engine/src/tests.rs: tests only; #129's test added
Conflict: crates/fonts/src/fontdb.rs: #126 content (same in #129); no change
Conflict: crates/idml/src/import.rs: #129's Mojikumi import added
Conflict: crates/idml/src/tests.rs: tests only; #129's tests added
Conflict: crates/ui-egui/src/dialogs.rs: #126 content (same in #129); no change
Conflict: crates/ui-egui/src/lib.rs: #126 content (same in #129); no change
This commit is contained in:
aiongg
2026-10-09 22:52:22 +00:00
15 changed files with 1074 additions and 52 deletions
+11
View File
@@ -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.
+114 -20
View File
@@ -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<Item>, 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<Item>, 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<Item>, 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<Item>, 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<Item>, 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<Item>, 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<Node> =
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<Node> = 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<usize> = 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::<f64>() + 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::<f64>();
}
shrink += sp.box_elastic(g).1.iter().sum::<f64>();
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 {
+19 -3
View File
@@ -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<PlacedGlyph>, f64, f64) {
let mut line: Vec<Glyph> = 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;
}
}
}
+222
View File
@@ -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 }]
}
+11
View File
@@ -54,6 +54,7 @@ pub struct Glyph {
/// Resolved CJK boundary constraint, independent of glyph outlines.
pub break_after: Option<bool>,
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<FontFace>, 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
}
+328
View File
@@ -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);
}
}
+1 -2
View File
@@ -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 {
+1
View File
@@ -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;
+211
View File
@@ -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<Option<Self>, 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('('));
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
/// 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);
+32 -4
View File
@@ -138,6 +138,9 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
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<Issue> {
.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, &para.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<Issue> {
}
}
}
}
});
// 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);
}
}
+14
View File
@@ -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:?}");
}
}
+45 -18
View File
@@ -105,6 +105,7 @@ struct Importer<'r> {
stroke_styles: Vec<designcraft_doc::StrokeStyleDef>,
styles: Styles,
composite_names: HashMap<String, String>,
mojikumi_names: HashMap<String, String>,
kinsoku: HashMap<String, designcraft_doc::cjk::Kinsoku>,
para_names: HashMap<String, String>,
char_names: HashMap<String, String>,
@@ -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<designcraft_doc::cjk::MojikumiAki> {
let number = |key: &str| -> Result<f64> {
let Some(raw) = e.prop(key) else {
return Ok(0.0);
};
raw.trim().parse::<f64>().ok().filter(|v| v.is_finite()).ok_or_else(|| IdmlError::Invalid(format!("Mojikumi row has invalid {key}")))
};
let integer = |key: &str| -> Result<i16> {
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<bool> {
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<f64> {
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::<Result<Vec<_>>>()?
} 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");
+46 -4
View File
@@ -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("</Document>", r#"<MojikumiTable Self="MojikumiTable/Spacing" Name="Spacing" BasedOnMojikumiSet="SimpChineseDefault"><Properties><OverrideMojikumiAkiList>
fn cjk_mojikumi_overrides_and_priority_round_trip() {
let map = DESIGNMAP.replace("</Document>", r#"<MojikumiTable Self="uMojikumi1" Name="Spacing" BasedOnMojikumiSet="SimpChineseDefault"><Properties><OverrideMojikumiAkiList>
<OverrideMojikumiAkiType TargetMojikumiClass="1" SideMojikumiClass="23" SideIsAfterTarget="false" Minimum="-0.1" Desired="0.25" Maximum="0.5" CompressionPriority="3" AkiDoesNotFloat="true"/>
</OverrideMojikumiAkiList></Properties></MojikumiTable></Document>"#);
let story =
STORY.replace("<ParagraphStyleRange ", "<ParagraphStyleRange Mojikumi=\"MojikumiTable/Spacing\" KinsokuType=\"KinsokuPushOutFirst\" ");
let story = STORY.replace("<ParagraphStyleRange ", "<ParagraphStyleRange Mojikumi=\"uMojikumi1\" KinsokuType=\"KinsokuPushOutFirst\" ");
let bytes = zip_files(&[
("designmap.xml", &map),
("Resources/Graphic.xml", GRAPHIC),
@@ -1487,3 +1486,46 @@ fn composite_references_resolve_by_identity_and_export_as_objects() {
let back = import_idml(&output).unwrap();
assert_eq!(back.styles.composite_fonts, d.styles.composite_fonts);
}
#[test]
fn malformed_mojikumi_rows_are_not_silently_coerced() {
let good = r#"<OverrideMojikumiAkiType TargetMojikumiClass="12" SideMojikumiClass="18" SideIsAfterTarget="true" Minimum="0" Desired="0.25" Maximum="0.5" CompressionPriority="3" AkiDoesNotFloat="false"/>"#;
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("</Document>", &format!("<MojikumiTable Name=\"Bad\" BasedOnMojikumiSet=\"SimpChineseDefault\"><Properties><OverrideMojikumiAkiList>{xml}</OverrideMojikumiAkiList></Properties></MojikumiTable></Document>"));
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}");
}
}
}
+18
View File
@@ -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).