mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 19:26:46 -06:00
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:
+11
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('('));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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, ¶.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
Reference in New Issue
Block a user