mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-13 23:17:42 +00:00
Merge pull request #75 from ShaoWenbinSaleh/arabic-chinese-typesetting-rules-fix
Preserve and compose Chinese and Arabic typesetting rules
This commit is contained in:
37 files changed
+2191
-144
No files matched your search
Generated
+2
@@ -910,6 +910,7 @@ dependencies = [
|
||||
"proptest",
|
||||
"regex",
|
||||
"unicode-bidi",
|
||||
"unicode-bidi-mirroring",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -972,6 +973,7 @@ dependencies = [
|
||||
"log",
|
||||
"skrifa 0.47.0",
|
||||
"unicode-bidi",
|
||||
"unicode-script",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -39,6 +39,7 @@ log = "0.4"
|
||||
proptest = "1"
|
||||
rayon = "1"
|
||||
unicode-bidi = "0.3"
|
||||
unicode-script = "0.5"
|
||||
unicode-bidi-mirroring = "0.4"
|
||||
unicode-segmentation = "1"
|
||||
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] }
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# DesignCraft roadmap
|
||||
|
||||
- AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it.
|
||||
|
||||
- AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md.
|
||||
- CJK1: IDML composite fonts now retain character mappings and metrics; explicit aki, tsume, jidori, em alignment/leading, named/custom kinsoku boundaries, hanging punctuation, tate-chu-yoko offsets and kenten symbols flow through composition and IDML export. Automatic kerning no longer imports inactive numeric values as enormous manual spacing. Composite definitions are editable through commands with undo/cache invalidation. Mojikumi definitions are retained and unsupported spacing-table / push-in-push-out policies are reported by preflight. Full InDesign CJK composition parity, frame-grid layout and detailed ruby/warichu remain unfinished.
|
||||
|
||||
DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web.
|
||||
|
||||
## Status (2026-10-01)
|
||||
|
||||
@@ -10,6 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
unicode-bidi = { workspace = true }
|
||||
unicode-bidi-mirroring = { workspace = true }
|
||||
designcraft-geom = { workspace = true }
|
||||
designcraft-color = { workspace = true }
|
||||
designcraft-doc = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Paragraph-wide Unicode bidi input, retaining cluster offsets through line breaking.
|
||||
use super::shape::Glyph;
|
||||
use designcraft_doc::arabic::CharacterDirection as D;
|
||||
|
||||
pub fn paragraph_text(glyphs: &mut [Glyph], source: &str) -> String {
|
||||
let mut text = String::new();
|
||||
let mut direction = D::Default;
|
||||
let mut previous: Option<(usize, usize, usize)> = None;
|
||||
for g in glyphs {
|
||||
if g.len == 0
|
||||
&& let Some((byte, start, end)) = previous
|
||||
&& byte == g.byte
|
||||
{
|
||||
g.bidi_offset = start;
|
||||
g.bidi_end = end;
|
||||
continue;
|
||||
}
|
||||
if g.character_direction != direction {
|
||||
if direction != D::Default {
|
||||
text.push('\u{202C}');
|
||||
}
|
||||
direction = g.character_direction;
|
||||
match direction {
|
||||
D::Default => {}
|
||||
D::LeftToRight => text.push('\u{202D}'),
|
||||
D::RightToLeft => text.push('\u{202E}'),
|
||||
}
|
||||
}
|
||||
g.bidi_offset = text.len();
|
||||
if g.len > 0 && g.display_char == g.ch {
|
||||
match source.get(g.byte..g.byte.saturating_add(g.len)) {
|
||||
Some(s) => text.push_str(s),
|
||||
None => text.push(g.display_char),
|
||||
}
|
||||
} else {
|
||||
text.push(g.display_char);
|
||||
}
|
||||
g.bidi_end = text.len();
|
||||
previous = (g.len > 0).then_some((g.byte, g.bidi_offset, g.bidi_end));
|
||||
}
|
||||
if direction != D::Default {
|
||||
text.push('\u{202C}');
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// Correct single-character mirrored clusters when their paragraph direction differs
|
||||
/// from the independently shaped run. Keep kerning/tracking in the advance delta.
|
||||
pub fn resolve_mirroring(glyphs: &mut [Glyph], info: &unicode_bidi::BidiInfo<'_>) {
|
||||
for i in 0..glyphs.len() {
|
||||
let g = &glyphs[i];
|
||||
if g.len != g.display_char.len_utf8() || glyphs.get(i + 1).is_some_and(|next| next.byte == g.byte) {
|
||||
continue;
|
||||
}
|
||||
let rtl = info.levels.get(g.bidi_offset).is_some_and(|l| l.is_rtl());
|
||||
if rtl == g.shaping_rtl {
|
||||
continue;
|
||||
}
|
||||
let Some(mirror) = unicode_bidi_mirroring::get_mirrored(g.display_char) else { continue };
|
||||
let gid = g.face.glyph_for(if rtl { mirror } else { g.display_char });
|
||||
if gid != 0 {
|
||||
let g = &mut glyphs[i];
|
||||
g.adv += (g.face.advance(gid) - g.face.advance(g.gid)) * g.sx;
|
||||
g.gid = gid;
|
||||
g.shaping_rtl = rtl;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ 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 {
|
||||
if !self.justify || g.locked_advance {
|
||||
return ([0.0; 3], [0.0; 3]);
|
||||
}
|
||||
let natural = g.adv / self.glyph_desired.max(0.01);
|
||||
@@ -86,7 +86,7 @@ impl Spacing {
|
||||
}
|
||||
/// Word-space stretch and shrink of space glyph `g` (only U+0020 is elastic).
|
||||
fn space_elastic(&self, g: &Glyph) -> (f64, f64) {
|
||||
if g.ch != ' ' {
|
||||
if g.ch != ' ' && !(g.ch == '\u{3000}' && g.ideographic_space_elastic) {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
(g.space * (self.word_max - self.word_desired).max(0.0), g.space * (self.word_desired - self.word_min).max(0.0))
|
||||
@@ -205,6 +205,10 @@ fn hang_left(g: &Glyph) -> f64 {
|
||||
hang(g.ch).0 * g.adv
|
||||
}
|
||||
|
||||
fn right_hang(g: &Glyph, sp: &Spacing) -> f64 {
|
||||
g.cjk_hang.max(if sp.optical { hang_right(g) } else { 0.0 })
|
||||
}
|
||||
|
||||
fn hang_right(g: &Glyph) -> f64 {
|
||||
hang(g.ch).1 * g.adv
|
||||
}
|
||||
@@ -248,7 +252,7 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec<Item>, Vec
|
||||
let mut hy_cache = Vec::new();
|
||||
let mut last_space = NONE;
|
||||
for (i, g) in glyphs.iter().enumerate() {
|
||||
let prev_hang = if sp.optical && i > 0 { hang_right(&glyphs[i - 1]) } else { 0.0 };
|
||||
let prev_hang = if i > 0 { right_hang(&glyphs[i - 1], sp) } else { 0.0 };
|
||||
if is_forced(g.ch) {
|
||||
it.push(Item::glue(0.0, INF, 0.0));
|
||||
ig.push(i);
|
||||
@@ -259,7 +263,7 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec<Item>, Vec
|
||||
last_space = NONE;
|
||||
continue;
|
||||
}
|
||||
if g.is_space() && !g.no_break {
|
||||
if g.is_space() && !g.no_break && g.break_after != Some(false) {
|
||||
let w = g.adv;
|
||||
if sp.justify {
|
||||
let (st, sh) = sp.space_elastic(g);
|
||||
@@ -301,14 +305,14 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec<Item>, Vec
|
||||
it.push(bx);
|
||||
ig.push(i);
|
||||
let next_is_space = glyphs.get(i + 1).is_none_or(|n| n.is_space());
|
||||
if i + 1 < n && !next_is_space && !g.no_break {
|
||||
if i + 1 < n && !next_is_space && !g.no_break && g.break_after != Some(false) {
|
||||
if matches!(g.ch, '-' | '\u{2010}') {
|
||||
let mut p = Item::penalty(0.0, 50.0, true);
|
||||
p.hang = if sp.optical { hang_right(g) } else { 0.0 };
|
||||
p.hang = right_hang(g, sp);
|
||||
push_inword(&mut it, &mut ig, p, i + 1, sp);
|
||||
} else if matches!(g.ch, '\u{2013}' | '\u{2014}' | '/') || cjk_break_between(g.ch, glyphs[i + 1].ch) {
|
||||
} else if matches!(g.ch, '\u{2013}' | '\u{2014}' | '/') || g.break_after.unwrap_or_else(|| cjk_break_between(g.ch, glyphs[i + 1].ch)) {
|
||||
let mut p = Item::penalty(0.0, 0.0, false);
|
||||
p.hang = if sp.optical { hang_right(g) } else { 0.0 };
|
||||
p.hang = right_hang(g, sp);
|
||||
push_inword(&mut it, &mut ig, p, i + 1, sp);
|
||||
} else if hyph_after[i] {
|
||||
let hw = hyphen_width(g, &mut hy_cache);
|
||||
@@ -326,7 +330,7 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec<Item>, Vec
|
||||
it.push(Item::glue(0.0, INF, 0.0));
|
||||
ig.push(n);
|
||||
let mut p = Item::penalty(0.0, -INF, false);
|
||||
p.hang = if sp.optical && n > 0 { hang_right(&glyphs[n - 1]) } else { 0.0 };
|
||||
p.hang = if n > 0 { right_hang(&glyphs[n - 1], sp) } else { 0.0 };
|
||||
it.push(p);
|
||||
ig.push(n);
|
||||
(it, ig)
|
||||
@@ -581,7 +585,8 @@ fn breaks_from_positions(items: &[Item], ig: &[usize], glyphs: &[Glyph], chain:
|
||||
out.push(Break { start: 0, end: n, next: n, hyphen: false, forced: true });
|
||||
}
|
||||
// Lines produced past the paragraph end (e.g. a trailing forced break) are dropped.
|
||||
out.retain(|b| b.start <= n);
|
||||
let trailing_forced = glyphs.last().is_some_and(|g| is_forced(g.ch));
|
||||
out.retain(|b| b.start < n || (b.start == n && trailing_forced));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -611,7 +616,7 @@ pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn F
|
||||
brk = Some((i + 1, false, true));
|
||||
break;
|
||||
}
|
||||
if g.is_space() && !g.no_break {
|
||||
if g.is_space() && !g.no_break && g.break_after != Some(false) {
|
||||
if sp.justify {
|
||||
shrink += sp.space_elastic(g).1;
|
||||
}
|
||||
@@ -621,8 +626,11 @@ pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn F
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let hang_r = if sp.optical { hang_right(g) } else { 0.0 };
|
||||
if x + g.adv - hang_r > w + shrink && i > start {
|
||||
let hang_r = right_hang(g, sp);
|
||||
if x + g.adv - hang_r > w + shrink
|
||||
&& i > start
|
||||
&& (last_ok.is_some() || glyphs.get(i - 1).is_none_or(|p| p.break_after != Some(false) && !p.no_break))
|
||||
{
|
||||
// Overflow: break at the last opportunity, else before this glyph.
|
||||
brk = Some(match last_ok {
|
||||
// Hyphenation zone: break before the word if that leaves little space.
|
||||
@@ -638,8 +646,10 @@ pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn F
|
||||
if sp.justify {
|
||||
shrink += sp.box_elastic(g).1.iter().sum::<f64>();
|
||||
}
|
||||
if i + 1 < n && !glyphs[i + 1].is_space() && !g.no_break {
|
||||
if matches!(g.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/') || cjk_break_between(g.ch, glyphs[i + 1].ch) {
|
||||
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_between(g.ch, glyphs[i + 1].ch))
|
||||
{
|
||||
last_ok = Some((i + 1, false));
|
||||
} 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 };
|
||||
|
||||
+167
-43
@@ -10,6 +10,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod bidi;
|
||||
pub mod breaker;
|
||||
mod cache;
|
||||
pub mod hyphen;
|
||||
@@ -67,6 +68,7 @@ pub struct RunStyle {
|
||||
/// Ruby over the run, and kenten (emphasis dots).
|
||||
pub ruby: Option<String>,
|
||||
pub kenten: bool,
|
||||
pub kenten_character: String,
|
||||
}
|
||||
|
||||
/// An underline or strikethrough bar: its top edge `offset` below the baseline (negative =
|
||||
@@ -418,13 +420,29 @@ const DEFAULT_TAB: f64 = 36.0;
|
||||
|
||||
/// Compose `story` into `frames`.
|
||||
pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &ComposeOptions) -> ComposedStory {
|
||||
let db = FontDb::global();
|
||||
compose_with_db(doc, story, frames, opts, FontDb::global())
|
||||
}
|
||||
|
||||
fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &ComposeOptions, db: &FontDb) -> ComposedStory {
|
||||
let mut out = ComposedStory { story: story.id, rev: story.rev, text_len: story.text.len(), ..Default::default() };
|
||||
let mut styles_tab: Vec<RunStyle> = Vec::new();
|
||||
let mut missing_fonts: HashMap<String, bool> = HashMap::new();
|
||||
out.frames = frames.iter().map(|f| FrameText { frame: f.id, vertical: f.opts.vertical, columns: f.columns(), ..Default::default() }).collect();
|
||||
let cols: Vec<Vec<Rect>> = frames.iter().map(FrameSpec::columns).collect();
|
||||
let mut cur = Cursor { fi: 0, col: 0, last_baseline: None, last_descent: 0.0, pending: 0.0 };
|
||||
let cols: Vec<Vec<Rect>> = frames
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let mut columns = f.columns();
|
||||
if story.direction == designcraft_doc::TextDirection::RightToLeft && !f.opts.vertical {
|
||||
columns.reverse();
|
||||
}
|
||||
columns
|
||||
})
|
||||
.collect();
|
||||
out.frames = frames
|
||||
.iter()
|
||||
.zip(&cols)
|
||||
.map(|(f, columns)| FrameText { frame: f.id, vertical: f.opts.vertical, columns: columns.clone(), ..Default::default() })
|
||||
.collect();
|
||||
let mut cur = Cursor { fi: 0, col: 0, last_baseline: None, last_descent: 0.0, last_reference: 0.0, pending: 0.0 };
|
||||
let para_ranges = story.para_ranges();
|
||||
let np = para_ranges.len();
|
||||
let hyph_exceptions = doc.hyphenation_exception_map();
|
||||
@@ -640,6 +658,12 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
|
||||
prepend_label(db, &mut sp.glyphs, label, prange.start, &base_chars, &pp, &mut table);
|
||||
}
|
||||
let mut glyphs = sp.glyphs;
|
||||
let bidi_text = bidi::paragraph_text(&mut glyphs, &story.text);
|
||||
let bidi_info = unicode_bidi::BidiInfo::new(
|
||||
&bidi_text,
|
||||
Some(if pp.direction == designcraft_doc::TextDirection::RightToLeft { unicode_bidi::Level::rtl() } else { unicode_bidi::Level::ltr() }),
|
||||
);
|
||||
bidi::resolve_mirroring(&mut glyphs, &bidi_info);
|
||||
apply_desired_spacing(&mut glyphs, &pp);
|
||||
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;
|
||||
@@ -683,7 +707,8 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
|
||||
let col = match pp.span_columns {
|
||||
SpanColumns::Span(n) if cur.col == 0 => {
|
||||
let n = if n == 0 { cols[cur.fi].len() } else { (n as usize).min(cols[cur.fi].len()) };
|
||||
Rect::new(col.x0, col.y0, cols[cur.fi][n - 1].x1, col.y1)
|
||||
let other = cols[cur.fi][n - 1];
|
||||
Rect::new(col.x0.min(other.x0), col.y0, col.x1.max(other.x1), col.y1)
|
||||
}
|
||||
_ => col,
|
||||
};
|
||||
@@ -709,7 +734,11 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
|
||||
let (s, e) = (g0 + b.start, g0 + b.end);
|
||||
let line_glyphs = &glyphs[s..e.max(s)];
|
||||
let (asc, desc, lead) = line_metrics(line_glyphs, &glyphs, s, base_leading, base_chars.size, db, &base_chars);
|
||||
let reference = cjk_line_reference(line_glyphs);
|
||||
let mut baseline = cur.next_baseline(f, col, lead, asc, &pp);
|
||||
if cur.last_baseline.is_some() {
|
||||
baseline += cur.last_reference - reference;
|
||||
}
|
||||
// Baseline grid.
|
||||
if let Some((g_start, inc)) = f.grid
|
||||
&& (pp.grid_align == GridAlign::AllLines || (pp.grid_align == GridAlign::FirstLineOnly && line_no == 0))
|
||||
@@ -794,7 +823,7 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
|
||||
let lx1 = x1 - pp.right_indent;
|
||||
let last = k + 1 == breaks.len();
|
||||
let (mut placed, end_x, ratio) =
|
||||
layout_line(&glyphs, s, e, b.hyphen, lx0, lx1, col.x0, &pp, &spacing, last, b.forced && !last, f.left_page);
|
||||
layout_line(&glyphs, s, e, b.hyphen, lx0, lx1, col.x0, &pp, &spacing, last, b.forced && !last, f.left_page, &bidi_info);
|
||||
ruby::annotate(&styles_tab, &mut placed);
|
||||
let range_end = if last { prange.end } else { glyphs.get(g0 + b.next).map(|g| g.byte).unwrap_or(prange.end) };
|
||||
let range_start = glyphs.get(s).map(|g| g.byte).unwrap_or(prange.start).min(range_end);
|
||||
@@ -837,6 +866,7 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
|
||||
}
|
||||
}
|
||||
cur.last_baseline = Some(baseline);
|
||||
cur.last_reference = reference;
|
||||
cur.last_descent = desc;
|
||||
cur.pending = 0.0;
|
||||
line_no += 1;
|
||||
@@ -1103,6 +1133,35 @@ fn spacing_for(pp: &ParaProps, base_size: f64) -> Spacing {
|
||||
|
||||
/// Desired word spacing, letter spacing and glyph scaling apply to every line (any alignment).
|
||||
fn apply_desired_spacing(glyphs: &mut [Glyph], pp: &ParaProps) {
|
||||
for i in 0..glyphs.len() {
|
||||
let (left, right) = glyphs.split_at_mut(i + 1);
|
||||
let Some(g) = left.last_mut() else { continue };
|
||||
if pp.kinsoku_hang != designcraft_doc::cjk::KinsokuHang::None && pp.kinsoku.as_ref().is_some_and(|k| k.hanging.contains(g.ch)) {
|
||||
g.cjk_hang = g.adv;
|
||||
}
|
||||
g.ideographic_space_elastic = pp.treat_ideographic_space_as_space;
|
||||
if g.ch == '\u{3000}' && pp.treat_ideographic_space_as_space {
|
||||
g.space = g.adv;
|
||||
}
|
||||
if let Some(next) = right.first() {
|
||||
let a = g.ch;
|
||||
let b = next.ch;
|
||||
let cjk = upright_in_vertical(a) || upright_in_vertical(b);
|
||||
if g.break_after != Some(false) {
|
||||
if let Some(set) = &pp.kinsoku {
|
||||
if !set.allows(a, b) {
|
||||
g.break_after = Some(false);
|
||||
} else if cjk {
|
||||
g.break_after = Some(true);
|
||||
}
|
||||
}
|
||||
if (pp.bunri_kinshi && a == b && matches!(a, '.' | '-' | '…' | '‥' | '—' | '―')) || (pp.rensuuji && a.is_numeric() && b.is_numeric())
|
||||
{
|
||||
g.break_after = Some(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let ws = pp.word_space_desired;
|
||||
let ls = pp.letter_space_desired;
|
||||
let gs = pp.glyph_scale_desired.max(0.01);
|
||||
@@ -1110,7 +1169,10 @@ fn apply_desired_spacing(glyphs: &mut [Glyph], pp: &ParaProps) {
|
||||
return;
|
||||
}
|
||||
for g in glyphs {
|
||||
if g.ch == ' ' {
|
||||
if g.locked_advance {
|
||||
continue;
|
||||
}
|
||||
if g.ch == ' ' || (g.ch == '\u{3000}' && g.ideographic_space_elastic) {
|
||||
g.adv += g.space * (ws - 1.0);
|
||||
} else if !g.is_space() && g.adv > 0.0 {
|
||||
g.adv = g.adv * gs + ls * g.space;
|
||||
@@ -1267,6 +1329,7 @@ struct Cursor {
|
||||
col: usize,
|
||||
last_baseline: Option<f64>,
|
||||
last_descent: f64,
|
||||
last_reference: f64,
|
||||
/// Space before/after waiting to be added to the next line.
|
||||
pending: f64,
|
||||
}
|
||||
@@ -1305,6 +1368,38 @@ impl Cursor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Em box relative to the baseline. Font ascender/descender proportions locate it;
|
||||
/// the box's total extent is the em, rather than the font's optional line gap.
|
||||
fn cjk_em_box(g: &Glyph) -> (f64, f64) {
|
||||
let height = g.face.units_per_em() * g.sy.abs();
|
||||
let top = -height * g.ascent / (g.ascent + g.descent).max(1e-9);
|
||||
(top, top + height)
|
||||
}
|
||||
fn cjk_alignment_shift(g: &Glyph, reference: &Glyph) -> f64 {
|
||||
use designcraft_doc::cjk::CharacterAlignment as A;
|
||||
let (t, b) = cjk_em_box(g);
|
||||
let (rt, rb) = cjk_em_box(reference);
|
||||
match g.character_alignment {
|
||||
A::Baseline => 0.0,
|
||||
A::EmTop => t - rt,
|
||||
A::EmCenter => (t + b - rt - rb) / 2.0,
|
||||
A::EmBottom => b - rb,
|
||||
A::IcfTop => reference.ascent - g.ascent,
|
||||
A::IcfBottom => g.descent - reference.descent,
|
||||
}
|
||||
}
|
||||
fn cjk_line_reference(line: &[Glyph]) -> f64 {
|
||||
use designcraft_doc::cjk::LeadingModel as L;
|
||||
let Some(g) = line.iter().max_by(|a, b| a.size.total_cmp(&b.size)) else { return 0.0 };
|
||||
let (top, bottom) = cjk_em_box(g);
|
||||
match g.leading_model {
|
||||
L::Roman => 0.0,
|
||||
L::AkiBelow => bottom,
|
||||
L::AkiAbove => top,
|
||||
L::Center | L::CenterDown => (top + bottom) / 2.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn line_metrics(
|
||||
line: &[Glyph],
|
||||
all: &[Glyph],
|
||||
@@ -1324,9 +1419,11 @@ fn line_metrics(
|
||||
let mut asc: f64 = 0.0;
|
||||
let mut desc: f64 = 0.0;
|
||||
let mut lead: f64 = 0.0;
|
||||
let reference = src.iter().max_by(|a, b| a.size.total_cmp(&b.size));
|
||||
for g in src {
|
||||
asc = asc.max(g.ascent + g.shift.max(0.0));
|
||||
desc = desc.max(g.descent - g.shift.min(0.0));
|
||||
let shift = g.shift + reference.map_or(0.0, |r| cjk_alignment_shift(g, r));
|
||||
asc = asc.max(g.ascent + shift.max(0.0));
|
||||
desc = desc.max(g.descent - shift.min(0.0));
|
||||
lead = lead.max(g.leading);
|
||||
}
|
||||
(asc, desc, lead)
|
||||
@@ -1430,8 +1527,15 @@ fn layout_line(
|
||||
last: bool,
|
||||
forced_mid: bool,
|
||||
left_page: bool,
|
||||
bidi_info: &unicode_bidi::BidiInfo<'_>,
|
||||
) -> (Vec<PlacedGlyph>, f64, f64) {
|
||||
let mut line: Vec<Glyph> = glyphs[s..e.max(s)].to_vec();
|
||||
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 {
|
||||
g.shift += cjk_alignment_shift(g, &reference);
|
||||
}
|
||||
}
|
||||
if hyphen && let Some(g) = line.last() {
|
||||
let h = shape::hyphen_after(g);
|
||||
line.push(h);
|
||||
@@ -1479,11 +1583,18 @@ fn layout_line(
|
||||
let has_tab = line.iter().any(|g| g.ch == '\t' || g.ch == story::RIGHT_INDENT_TAB);
|
||||
// Optical margin alignment: the measure grows by the hang of the edge glyphs.
|
||||
let (mut x0, mut measure) = (x0, measure);
|
||||
if sp.optical && !has_tab {
|
||||
if (sp.optical || pp.kinsoku_hang != designcraft_doc::cjk::KinsokuHang::None) && !has_tab {
|
||||
let first = line.iter().find(|g| g.adv > 0.0 && !g.is_space());
|
||||
let lastg = line.iter().rev().find(|g| g.adv > 0.0 && !g.is_space());
|
||||
let hl = first.map_or(0.0, |g| breaker::hang(g.ch).0 * g.adv);
|
||||
let hr = lastg.map_or(0.0, |g| breaker::hang(g.ch).1 * g.adv);
|
||||
let hl = if sp.optical { first.map_or(0.0, |g| breaker::hang(g.ch).0 * g.adv) } else { 0.0 };
|
||||
let optical_right = if sp.optical { lastg.map_or(0.0, |g| breaker::hang(g.ch).1 * g.adv) } else { 0.0 };
|
||||
let cjk_right = lastg.map_or(0.0, |g| g.cjk_hang);
|
||||
let cjk_right = if pp.kinsoku_hang == designcraft_doc::cjk::KinsokuHang::Regular {
|
||||
cjk_right.min((line.iter().map(|g| g.adv).sum::<f64>() - measure).max(0.0))
|
||||
} else {
|
||||
cjk_right
|
||||
};
|
||||
let hr = optical_right.max(cjk_right);
|
||||
x0 -= hl;
|
||||
measure += hl + hr;
|
||||
}
|
||||
@@ -1516,7 +1627,7 @@ fn layout_line(
|
||||
let mut offset = 0.0;
|
||||
// Kashidas: in justified Arabic, the joins of words take the extra length first.
|
||||
let mut kashidas: Vec<(usize, f64)> = Vec::new();
|
||||
if justify_this && extra > 0.0 && pp.kashidas {
|
||||
if justify_this && extra > 0.0 && pp.kashidas && pp.arabic_justification != "DefaultJustification" {
|
||||
let points = kashida_points(&line);
|
||||
if !points.is_empty() {
|
||||
let total = extra.min(points.iter().map(|&i| line[i].size * 1.5).sum());
|
||||
@@ -1534,9 +1645,18 @@ fn layout_line(
|
||||
// Single word: Single Word Justification.
|
||||
match pp.single_word_justify {
|
||||
Align::FullyJustified => {
|
||||
let per = extra / (line.len() - 1) as f64;
|
||||
for a in &mut add[..line.len() - 1] {
|
||||
*a = per;
|
||||
let gaps: Vec<_> = line
|
||||
.iter()
|
||||
.enumerate()
|
||||
.take(line.len() - 1)
|
||||
.filter(|(_, g)| !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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Align::Center => offset = extra / 2.0,
|
||||
@@ -1574,8 +1694,7 @@ fn layout_line(
|
||||
let ratio = if ratio_n > 0 { ratio_sum / ratio_n as f64 } else { 1.0 };
|
||||
let mut out = Vec::with_capacity(line.len());
|
||||
let mut x = x0 + offset;
|
||||
let rtl_para = pp.direction == designcraft_doc::TextDirection::RightToLeft;
|
||||
let bidi = (rtl_para || line.iter().any(|g| designcraft_fonts::is_rtl(g.ch))) && leaders.is_empty();
|
||||
let bidi = bidi_info.has_rtl();
|
||||
let mut pens = Vec::with_capacity(if bidi { line.len() } else { 0 });
|
||||
for (i, g) in line.iter().enumerate() {
|
||||
if bidi {
|
||||
@@ -1588,28 +1707,29 @@ fn layout_line(
|
||||
p.adv *= scale[i];
|
||||
}
|
||||
p.adv += add[i];
|
||||
if let Some((_, l)) = kashidas.iter().find(|k| k.0 == i) {
|
||||
// Right to left: the stretch is on the glyph's left, toward the letter it joins.
|
||||
if let Some((_, l)) = kashidas.iter().find(|k| line[k.0].byte == g.byte) {
|
||||
// Move the whole cluster, including its attached marks, past the elongation.
|
||||
p.x += l;
|
||||
}
|
||||
let leader = leaders.iter().find(|(k, _)| *k == i).map(|(_, l)| l.as_str());
|
||||
let (tab_x, tab_w) = (x, p.adv);
|
||||
x += p.adv;
|
||||
if let Some((_, l)) = kashidas.iter().find(|k| k.0 == i) {
|
||||
// The glyph keeps its own width; the gap before it is the tatweel's.
|
||||
p.adv -= l;
|
||||
}
|
||||
out.push(p);
|
||||
if let Some(l) = leader {
|
||||
tab_leader(g, l, tab_x, tab_w, tab_origin, &mut out);
|
||||
}
|
||||
}
|
||||
if bidi {
|
||||
pens.push(x);
|
||||
reorder_visual(&line, &mut out, &pens, x0 + offset, rtl_para);
|
||||
reorder_visual(&line, &mut out, &pens, x0 + offset, bidi_info);
|
||||
}
|
||||
for (i, leader) in &leaders {
|
||||
if let Some((g, p)) = line.get(*i).zip(out.get(*i)) {
|
||||
tab_leader(g, leader, p.x, p.adv, tab_origin, &mut out);
|
||||
}
|
||||
}
|
||||
for (i, l) in kashidas {
|
||||
let (g, at) = (&line[i], out[i].x - l);
|
||||
let first = (0..=i).rev().take_while(|&j| line[j].byte == line[i].byte).last().unwrap_or(i);
|
||||
let (g, at) = (&line[i], out[first].x - line[first].dx - l);
|
||||
let gid = designcraft_fonts::first_glyph(&g.face, &['\u{0640}']);
|
||||
let w = g.face.advance(gid) * g.sx;
|
||||
if gid == 0 || w <= 0.0 {
|
||||
@@ -1640,23 +1760,25 @@ fn joins_prev(c: char) -> bool {
|
||||
/// Where kashidas go on a line: one join per word, after a seen or sad when there is one, else the
|
||||
/// word's last join (indices of the glyph before the join).
|
||||
fn kashida_points(line: &[Glyph]) -> Vec<usize> {
|
||||
let is_mark = |g: &Glyph| matches!(g.ch as u32, 0x064B..=0x065F | 0x0670 | 0x06D6..=0x06ED);
|
||||
let mut out = Vec::new();
|
||||
let mut best: Option<(bool, usize)> = None;
|
||||
for i in 0..line.len() {
|
||||
let g = &line[i];
|
||||
if g.is_space() || g.len == 0 {
|
||||
for (i, g) in line.iter().enumerate() {
|
||||
if g.is_space() {
|
||||
if let Some((_, k)) = best.take() {
|
||||
out.push(k);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !joins_next(g.ch) || line.get(i + 1).is_some_and(|n| n.byte == g.byte) {
|
||||
// Only consider boundaries between complete clusters. Marks with len=0
|
||||
// are part of their base cluster, not word boundaries or insertion sites.
|
||||
let Some(n) = line.get(i + 1) else { continue };
|
||||
if n.byte == g.byte || !g.allow_kashidas || !n.allow_kashidas || !n.safe_tatweel_before {
|
||||
continue;
|
||||
}
|
||||
// The next letter (past any marks) must join back, and lam–alef stays a ligature.
|
||||
let Some(n) = line[i + 1..].iter().find(|n| !is_mark(n)) else { continue };
|
||||
if !joins_prev(n.ch) || n.len == 0 || (g.ch == '\u{0644}' && matches!(n.ch, '\u{0622}' | '\u{0623}' | '\u{0625}' | '\u{0627}')) {
|
||||
if g.face.id() != n.face.id() || g.face.glyph_for('\u{0640}') == 0 {
|
||||
continue;
|
||||
}
|
||||
if !joins_next(g.ch) || !joins_prev(n.ch) || (g.ch == '\u{0644}' && matches!(n.ch, '\u{0622}' | '\u{0623}' | '\u{0625}' | '\u{0627}')) {
|
||||
continue;
|
||||
}
|
||||
let seen = matches!(g.ch as u32, 0x0633..=0x0636);
|
||||
@@ -1671,19 +1793,21 @@ fn kashida_points(line: &[Glyph]) -> Vec<usize> {
|
||||
/// Bidi: put a laid-out line (glyphs in text order, `pens` their pen positions and then the end
|
||||
/// position) into visual order from `start`, cluster by cluster (Unicode Bidirectional
|
||||
/// Algorithm, rule L2).
|
||||
fn reorder_visual(line: &[Glyph], out: &mut [PlacedGlyph], pens: &[f64], start: f64, rtl: bool) {
|
||||
fn reorder_visual(line: &[Glyph], out: &mut [PlacedGlyph], pens: &[f64], start: f64, info: &unicode_bidi::BidiInfo<'_>) {
|
||||
use unicode_bidi::{BidiInfo, Level};
|
||||
// Clusters: runs of glyphs from the same source character.
|
||||
let mut units: Vec<std::ops::Range<usize>> = Vec::new();
|
||||
for i in 0..line.len() {
|
||||
match units.last_mut() {
|
||||
Some(u) if line[u.start].byte == line[i].byte && line[i].len > 0 => u.end = i + 1,
|
||||
Some(u) if line[u.start].bidi_offset == line[i].bidi_offset && line[u.start].byte == line[i].byte => u.end = i + 1,
|
||||
_ => units.push(i..i + 1),
|
||||
}
|
||||
}
|
||||
let text: String = units.iter().map(|u| line[u.start].ch).collect();
|
||||
let info = BidiInfo::new(&text, Some(if rtl { Level::rtl() } else { Level::ltr() }));
|
||||
let levels: Vec<Level> = text.char_indices().map(|(b, _)| info.levels[b]).collect();
|
||||
let Some(first) = line.first() else { return };
|
||||
let Some(para) = info.paragraphs.iter().find(|p| p.range.contains(&first.bidi_offset)) else { return };
|
||||
let end = line.iter().map(|g| g.bidi_end).max().unwrap_or(first.bidi_end).min(para.range.end);
|
||||
let resolved = info.reordered_levels(para, first.bidi_offset..end);
|
||||
let levels: Vec<Level> = units.iter().map(|u| resolved.get(line[u.start].bidi_offset).copied().unwrap_or(para.level)).collect();
|
||||
let order = BidiInfo::reorder_visual(&levels);
|
||||
let width = |u: &std::ops::Range<usize>| pens[u.end] - pens[u.start];
|
||||
let mut cur = start;
|
||||
@@ -1748,7 +1872,7 @@ fn distribute(line: &[Glyph], spaces: &[usize], extra: f64, sp: &Spacing, add: &
|
||||
.iter()
|
||||
.map(|&i| {
|
||||
let g = &line[i];
|
||||
if g.ch != ' ' {
|
||||
if g.ch != ' ' && !(g.ch == '\u{3000}' && g.ideographic_space_elastic) {
|
||||
0.0
|
||||
} else if stretch {
|
||||
g.space * (sp.word_max - sp.word_desired).max(0.0)
|
||||
@@ -1759,7 +1883,7 @@ fn distribute(line: &[Glyph], spaces: &[usize], extra: f64, sp: &Spacing, add: &
|
||||
.collect();
|
||||
// Letter gaps: between visible glyphs (not after the line's last glyph).
|
||||
let last_box = line.iter().rposition(|g| !g.is_space() && g.adv > 0.0).unwrap_or(0);
|
||||
let is_box = |i: usize, g: &Glyph| i < last_box && !g.is_space() && g.adv > 0.0;
|
||||
let is_box = |i: usize, g: &Glyph| i < last_box && !g.is_space() && g.adv > 0.0 && (!g.locked_advance || g.break_after != Some(false));
|
||||
let letter: Vec<f64> = line
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -1776,7 +1900,7 @@ fn distribute(line: &[Glyph], spaces: &[usize], extra: f64, sp: &Spacing, add: &
|
||||
let glyph: Vec<f64> = line
|
||||
.iter()
|
||||
.map(|g| {
|
||||
if g.is_space() || g.adv <= 0.0 {
|
||||
if g.is_space() || g.adv <= 0.0 || g.locked_advance {
|
||||
0.0
|
||||
} else {
|
||||
let natural = g.adv / sp.glyph_desired.max(0.01);
|
||||
|
||||
@@ -40,8 +40,16 @@ fn set(base: &PlacedGlyph, text: &str, size: f64, base_size: f64) -> (Vec<Placed
|
||||
}
|
||||
|
||||
/// A sesame dot (or a bullet, when the font has none) centred over `b`.
|
||||
fn kenten(b: &PlacedGlyph, size: f64) -> Vec<PlacedGlyph> {
|
||||
let (mut dot, mut w) = set(b, "\u{FE45}", size * SCALE, size);
|
||||
fn kenten(b: &PlacedGlyph, size: f64, character: &str) -> Vec<PlacedGlyph> {
|
||||
let character = if character.is_empty() { "\u{FE45}" } else { character };
|
||||
let mut base = b.clone();
|
||||
if let Some(c) = character.chars().next()
|
||||
&& !base.face.covers(c)
|
||||
&& let Some(face) = designcraft_fonts::FontDb::global().fallback_for(c, base.face.id())
|
||||
{
|
||||
base.face = designcraft_fonts::FaceRef::of(&face);
|
||||
}
|
||||
let (mut dot, mut w) = set(&base, character, size * SCALE, size);
|
||||
if dot.first().is_some_and(|d| d.gid == 0) {
|
||||
(dot, w) = set(b, "\u{2022}", size * SCALE, size);
|
||||
}
|
||||
@@ -74,7 +82,7 @@ pub(crate) fn annotate(styles: &[RunStyle], line: &mut Vec<PlacedGlyph>) {
|
||||
continue;
|
||||
};
|
||||
if st.kenten && g.adv > 0.0 {
|
||||
extra.extend(kenten(g, st.size));
|
||||
extra.extend(kenten(g, st.size, &st.kenten_character));
|
||||
}
|
||||
let Some(text) = &st.ruby else {
|
||||
i += 1;
|
||||
@@ -106,7 +114,7 @@ pub(crate) fn annotate(styles: &[RunStyle], line: &mut Vec<PlacedGlyph>) {
|
||||
extra.extend(glyphs);
|
||||
// Kenten on the rest of the group.
|
||||
for b in line[i + 1..j].iter().filter(|b| st.kenten && b.len > 0 && b.adv > 0.0) {
|
||||
extra.extend(kenten(b, st.size));
|
||||
extra.extend(kenten(b, st.size, &st.kenten_character));
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
|
||||
+213
-18
@@ -38,17 +38,32 @@ pub struct Glyph {
|
||||
pub style: u32,
|
||||
/// Unbreakable (No Break).
|
||||
pub no_break: bool,
|
||||
pub locked_advance: bool,
|
||||
/// Tate-chu-yoko in a vertical frame: [centre of the group's em along the line from `x`,
|
||||
/// this glyph's left edge across the line from that centre, the em] (see [`collapse_tcy`]).
|
||||
pub tcy: Option<[f64; 3]>,
|
||||
pub tcy_offset: [f64; 2],
|
||||
/// Width of a word space (U+0020) in this glyph's font, size and horizontal scale: the unit of
|
||||
/// word and letter spacing in justification.
|
||||
pub space: f64,
|
||||
/// Resolved CJK boundary constraint, independent of glyph outlines.
|
||||
pub break_after: Option<bool>,
|
||||
pub cjk_hang: f64,
|
||||
pub ideographic_space_elastic: bool,
|
||||
pub character_alignment: designcraft_doc::cjk::CharacterAlignment,
|
||||
pub leading_model: designcraft_doc::cjk::LeadingModel,
|
||||
pub character_direction: designcraft_doc::arabic::CharacterDirection,
|
||||
pub display_char: char,
|
||||
pub bidi_offset: usize,
|
||||
pub bidi_end: usize,
|
||||
pub allow_kashidas: bool,
|
||||
pub safe_tatweel_before: bool,
|
||||
pub shaping_rtl: bool,
|
||||
}
|
||||
|
||||
impl Glyph {
|
||||
pub fn is_space(&self) -> bool {
|
||||
matches!(self.ch, ' ' | '\u{2002}'..='\u{200A}' | '\u{3000}')
|
||||
matches!(self.ch, ' ' | '\u{2002}'..='\u{200A}') || (self.ch == '\u{3000}' && self.ideographic_space_elastic)
|
||||
}
|
||||
pub fn is_letter(&self) -> bool {
|
||||
self.ch.is_alphabetic() || matches!(self.ch, '\'' | '’')
|
||||
@@ -143,6 +158,7 @@ impl StyleTable<'_> {
|
||||
xml_tag: (!p.xml_tag.is_empty()).then(|| p.xml_tag.clone()),
|
||||
ruby: (!p.ruby.is_empty()).then(|| p.ruby.clone()),
|
||||
kenten: p.kenten,
|
||||
kenten_character: p.kenten_character.clone(),
|
||||
};
|
||||
if let Some(i) = self.styles.iter().rposition(|s| *s == rs) {
|
||||
return i as u32;
|
||||
@@ -196,6 +212,7 @@ pub(crate) fn shape_para(
|
||||
segments.push((w[0], w[1], top, fmt));
|
||||
}
|
||||
}
|
||||
let mut resolved = Vec::new();
|
||||
for (a, b, overlay, fmt) in segments {
|
||||
// A nested / GREP character style sits under the run's own style and overrides.
|
||||
let base;
|
||||
@@ -207,6 +224,24 @@ pub(crate) fn shape_para(
|
||||
None => para_chars,
|
||||
};
|
||||
let props = styles.resolve_char(para_chars, fmt);
|
||||
let family = props.font_family.trim_start_matches("CompositeFont/");
|
||||
if let Some(font) = styles.composite_fonts.iter().find(|f| f.name == family) {
|
||||
let mut start = a;
|
||||
let mut selected = None;
|
||||
for (offset, c) in story.text.get(a..b).unwrap_or("").char_indices() {
|
||||
let entry = font.entry(c);
|
||||
if entry != selected && a + offset > start {
|
||||
resolved.push((start, a + offset, composite_props(&props, selected), fmt, para_chars.clone()));
|
||||
start = a + offset;
|
||||
}
|
||||
selected = entry;
|
||||
}
|
||||
resolved.push((start, b, composite_props(&props, selected), fmt, para_chars.clone()));
|
||||
} else {
|
||||
resolved.push((a, b, props, fmt, para_chars.clone()));
|
||||
}
|
||||
}
|
||||
for (a, b, props, fmt, resolved_base) in resolved {
|
||||
let style = table.intern(&props);
|
||||
let deleted = props.change == designcraft_doc::ChangeMark::Deleted;
|
||||
if deleted || (!props.conditions.is_empty() && props.conditions.iter().all(|c| sub.hidden_conditions.contains(c))) {
|
||||
@@ -236,11 +271,11 @@ pub(crate) fn shape_para(
|
||||
rf.style = cs.clone();
|
||||
}
|
||||
rf.over.position = Some(sub.note_position);
|
||||
let rprops = styles.resolve_char(para_chars, &rf);
|
||||
let rprops = styles.resolve_char(&resolved_base, &rf);
|
||||
let rstyle = table.intern(&rprops);
|
||||
let mut ef = fmt.clone();
|
||||
ef.over.position = Some(designcraft_doc::Position::Superscript);
|
||||
let eprops = styles.resolve_char(para_chars, &ef);
|
||||
let eprops = styles.resolve_char(&resolved_base, &ef);
|
||||
let estyle = table.intern(&eprops);
|
||||
let mut k = a;
|
||||
for (i, m) in story.text[a..b].match_indices(is_ref) {
|
||||
@@ -285,7 +320,7 @@ pub(crate) fn collapse_tcy(glyphs: &mut [Glyph], vertical: bool) {
|
||||
let step = em / run.len() as f64;
|
||||
let mut across = -width / 2.0;
|
||||
for (k, g) in run.iter_mut().enumerate() {
|
||||
g.tcy = Some([em / 2.0 - k as f64 * step, across + g.dx, em]);
|
||||
g.tcy = Some([em / 2.0 - k as f64 * step + g.tcy_offset[1], across + g.dx + g.tcy_offset[0], em]);
|
||||
across += g.adv;
|
||||
g.adv = step;
|
||||
g.dx = 0.0;
|
||||
@@ -319,18 +354,108 @@ fn features_for(p: &CharProps) -> Vec<Feature> {
|
||||
Position::OtDenominator => v.extend(feature("dnom")),
|
||||
_ => {}
|
||||
}
|
||||
let form = match p.glyph_form.as_str() {
|
||||
"ExpertForm" => "expt",
|
||||
"TraditionalForm" => "trad",
|
||||
"SimplifiedForm" => "smpl",
|
||||
"JIS78Form" => "jp78",
|
||||
"JIS83Form" => "jp83",
|
||||
"JIS90Form" => "jp90",
|
||||
"JIS04Form" => "jp04",
|
||||
_ => "",
|
||||
};
|
||||
if !form.is_empty() {
|
||||
v.extend(feature(form));
|
||||
}
|
||||
for f in &p.otf_features {
|
||||
v.extend(feature(f));
|
||||
}
|
||||
let positional = match p.positional_form.as_str() {
|
||||
"Initial" => Some("init"),
|
||||
"Medial" => Some("medi"),
|
||||
"Final" => Some("fina"),
|
||||
"Isolated" => Some("isol"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(tag) = positional {
|
||||
for form in ["isol", "init", "medi", "fina"] {
|
||||
v.extend(feature(&format!("{}{}", if form == tag { "" } else { "-" }, form)));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn is_mark(c: char) -> bool {
|
||||
matches!(c as u32, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF | 0xFE20..=0xFE2F | 0x200D | 0xFE00..=0xFE0F)
|
||||
unicode_bidi::bidi_class(c) == unicode_bidi::BidiClass::NSM || matches!(c, '\u{200C}' | '\u{200D}')
|
||||
}
|
||||
|
||||
fn composite_props(base: &CharProps, entry: Option<&designcraft_doc::cjk::CompositeFontEntry>) -> CharProps {
|
||||
let mut p = base.clone();
|
||||
if let Some(e) = entry {
|
||||
p.font_family.clone_from(&e.family);
|
||||
p.font_style.clone_from(&e.style);
|
||||
p.size *= e.relative_size.clamp(0.01, 100.0);
|
||||
p.h_scale *= e.horizontal_scale.clamp(0.01, 100.0);
|
||||
p.v_scale *= e.vertical_scale.clamp(0.01, 100.0);
|
||||
p.baseline_shift += base.size * e.baseline_shift;
|
||||
if e.scale_option {
|
||||
let face = FontDb::global().face(&p.font_family, &p.font_style);
|
||||
let (asc, desc) = face.vertical_metrics();
|
||||
let center = (asc - desc) / (2.0 * (asc + desc).max(1e-9));
|
||||
p.baseline_shift += (base.size * base.v_scale - p.size * p.v_scale) * center;
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn shape_run(
|
||||
fn shape_run(db: &FontDb, text: &str, range: std::ops::Range<usize>, p: &CharProps, env: TypeEnv, style: u32, sub: &SubstCtx, out: &mut Vec<Glyph>) {
|
||||
let start = out.len();
|
||||
shape_run_raw(db, text, range, p, env, style, sub, out);
|
||||
let run = &mut out[start..];
|
||||
let em = p.size * p.h_scale;
|
||||
for g in run.iter_mut().filter(|g| g.adv > 0.0 && !g.ch.is_control()) {
|
||||
let (mut left, mut right) = (0.0, 0.0);
|
||||
if p.tsume > 0.0 && !g.ch.is_whitespace() {
|
||||
let outline = db.outline(&g.face, g.gid);
|
||||
if !outline.elements().is_empty() {
|
||||
let bounds = designcraft_geom::Shape::bounding_box(&*outline);
|
||||
left = (g.dx + bounds.x0 * g.sx).clamp(0.0, g.adv);
|
||||
right = (g.adv - g.dx - bounds.x1 * g.sx).clamp(0.0, g.adv - left);
|
||||
}
|
||||
}
|
||||
let tsume = p.tsume.clamp(0.0, 1.0);
|
||||
let before = p.leading_aki.unwrap_or(0.0).clamp(0.0, 100.0) * em;
|
||||
let after = p.trailing_aki.unwrap_or(0.0).clamp(0.0, 100.0) * em;
|
||||
g.dx += before - left * tsume;
|
||||
g.adv += before + after - (left + right) * tsume;
|
||||
}
|
||||
if p.jidori > 0 && !run.is_empty() && !run.iter().any(|g| g.ch.is_control()) {
|
||||
let clusters: Vec<usize> = run.iter().enumerate().filter(|(_, g)| g.len > 0).map(|(i, _)| i).collect();
|
||||
let natural: f64 = run.iter().map(|g| g.adv).sum();
|
||||
let target = f64::from(p.jidori.min(10000)) * em;
|
||||
if clusters.len() == 1 {
|
||||
if let Some(g) = run.first_mut() {
|
||||
g.dx += (target - natural) * 0.5;
|
||||
g.adv += target - natural;
|
||||
}
|
||||
} else if clusters.len() > 1 {
|
||||
let gap = (target - natural) / (clusters.len() - 1) as f64;
|
||||
for &i in clusters.iter().skip(1) {
|
||||
if let Some(g) = run.get_mut(i.saturating_sub(1)) {
|
||||
g.adv += gap;
|
||||
}
|
||||
}
|
||||
}
|
||||
let end = run.len().saturating_sub(1);
|
||||
for g in run.iter_mut().take(end) {
|
||||
g.break_after = Some(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn shape_run_raw(
|
||||
db: &FontDb,
|
||||
text: &str,
|
||||
range: std::ops::Range<usize>,
|
||||
@@ -341,6 +466,12 @@ fn shape_run(
|
||||
out: &mut Vec<Glyph>,
|
||||
) {
|
||||
let primary = db.face(&p.font_family, &p.font_style);
|
||||
let strong = |c| match unicode_bidi::bidi_class(c) {
|
||||
unicode_bidi::BidiClass::AL => Some(true),
|
||||
unicode_bidi::BidiClass::L | unicode_bidi::BidiClass::R => Some(false),
|
||||
_ => None,
|
||||
};
|
||||
let mut arabic_context = text.get(..range.start).unwrap_or("").chars().rev().take_while(|c| !matches!(c, '\n' | '\r')).find_map(strong);
|
||||
// Split into segments: font coverage changes and special characters (markers, tabs, breaks).
|
||||
let mut seg_start = range.start;
|
||||
let mut seg_face = primary.clone();
|
||||
@@ -351,14 +482,27 @@ fn shape_run(
|
||||
};
|
||||
for (i, c) in text[range.clone()].char_indices() {
|
||||
let i = range.start + i;
|
||||
if let Some(context) = strong(c) {
|
||||
arabic_context = Some(context);
|
||||
}
|
||||
// Digits drawn in another script (World-Ready Digits): each is set as its substitute.
|
||||
if c.is_ascii_digit() && p.digits != designcraft_doc::Digits::Default {
|
||||
let d = p.digits.map(c, &p.language);
|
||||
{
|
||||
use designcraft_doc::Digits;
|
||||
let mode = if p.digits != Digits::Default {
|
||||
p.digits
|
||||
} else {
|
||||
match arabic_context {
|
||||
Some(false) => Digits::Arabic,
|
||||
Some(true) if Digits::Native.map('0', &p.language) == '0' => Digits::Hindi,
|
||||
_ => Digits::Native,
|
||||
}
|
||||
};
|
||||
let d = mode.map(c, &p.language);
|
||||
if d != c {
|
||||
flush(seg_start, i, &seg_face, out);
|
||||
seg_start = i + 1;
|
||||
seg_start = i + c.len_utf8();
|
||||
let face = if primary.covers(d) { primary.clone() } else { db.fallback_for(d, primary.id()).unwrap_or_else(|| primary.clone()) };
|
||||
shape_segment(db, text, i..i + 1, Some(d.encode_utf8(&mut [0; 4])), p, &face, auto_leading, style, out, sub.vertical);
|
||||
shape_segment(db, text, i..i + c.len_utf8(), Some(d.encode_utf8(&mut [0; 4])), p, &face, auto_leading, style, out, sub.vertical);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -496,12 +640,12 @@ fn word_map(key: WordKey) -> Arc<RwLock<WordMap>> {
|
||||
/// Shape `src` word by word through a cache (like a browser's word cache): the text is split
|
||||
/// after each U+0020 so repeated words are shaped once. Shaping does not cross word spaces in the
|
||||
/// scripts we lay out, and clusters stay byte offsets into `src`.
|
||||
fn shape_cached(face: &FontFace, src: &str, feats: &[Feature], caps: bool) -> Vec<ShapedGlyph> {
|
||||
fn shape_cached(face: &FontFace, src: &str, feats: &[Feature], caps: bool, context: designcraft_fonts::ShapeContext<'_>) -> Vec<ShapedGlyph> {
|
||||
let map = |c: char| if caps { c.to_uppercase().next().unwrap_or(c) } else { c };
|
||||
if src.len() < 2 || !src.contains(' ') {
|
||||
return designcraft_fonts::shape(face, src, feats, map);
|
||||
if src.len() < 2 || !src.contains(' ') || src.chars().any(designcraft_fonts::is_rtl) {
|
||||
return designcraft_fonts::shape_with_context(face, src, feats, map, context);
|
||||
}
|
||||
let words = word_map((face.id(), caps, format!("{feats:?}")));
|
||||
let words = word_map((face.id(), caps, format!("{feats:?}/{}", context.language)));
|
||||
let pieces: Vec<&str> = src.split_inclusive(' ').collect();
|
||||
let mut found: Vec<Option<Arc<[ShapedGlyph]>>> = {
|
||||
let r = words.read().unwrap_or_else(|e| e.into_inner());
|
||||
@@ -510,7 +654,14 @@ fn shape_cached(face: &FontFace, src: &str, feats: &[Feature], caps: bool) -> Ve
|
||||
let mut fresh: Vec<(Box<str>, Arc<[ShapedGlyph]>)> = Vec::new();
|
||||
for (k, f) in found.iter_mut().enumerate() {
|
||||
if f.is_none() {
|
||||
let g: Arc<[ShapedGlyph]> = designcraft_fonts::shape(face, pieces[k], feats, map).into();
|
||||
let g: Arc<[ShapedGlyph]> = designcraft_fonts::shape_with_context(
|
||||
face,
|
||||
pieces[k],
|
||||
feats,
|
||||
map,
|
||||
designcraft_fonts::ShapeContext { language: context.language, ..Default::default() },
|
||||
)
|
||||
.into();
|
||||
fresh.push((pieces[k].into(), g.clone()));
|
||||
*f = Some(g);
|
||||
}
|
||||
@@ -581,8 +732,22 @@ fn control_glyph(face: &Arc<FontFace>, p: &CharProps, auto_leading: TypeEnv, sty
|
||||
size,
|
||||
style,
|
||||
no_break: p.no_break,
|
||||
locked_advance: p.jidori > 0,
|
||||
tcy: p.tate_chu_yoko.then_some([0.0; 3]),
|
||||
tcy_offset: [p.tate_chu_yoko_x_offset, p.tate_chu_yoko_y_offset],
|
||||
space: face.advance(face.glyph_for(' ')) * k * p.h_scale,
|
||||
break_after: None,
|
||||
cjk_hang: 0.0,
|
||||
ideographic_space_elastic: false,
|
||||
character_alignment: p.character_alignment,
|
||||
leading_model: p.leading_model,
|
||||
character_direction: p.character_direction,
|
||||
display_char: ch,
|
||||
bidi_offset: 0,
|
||||
bidi_end: 0,
|
||||
allow_kashidas: p.allow_kashidas,
|
||||
safe_tatweel_before: false,
|
||||
shaping_rtl: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,11 +772,20 @@ fn shape_segment(
|
||||
let src = replacement.unwrap_or(&text[range.clone()]);
|
||||
let caps = p.capitalization == Capitalization::AllCaps;
|
||||
let mut feats = features_for(p);
|
||||
if vertical && designcraft_doc::otf::is_on(&p.otf_features, "hkna") {
|
||||
feats.extend(feature("-hkna"));
|
||||
feats.extend(feature("vkna"));
|
||||
}
|
||||
if vertical {
|
||||
feats.extend(["vert", "vrt2"].iter().filter_map(|t| designcraft_fonts::feature(t)));
|
||||
}
|
||||
let space = face.advance(face.glyph_for(' ')) * k * hs;
|
||||
let shaped: Vec<ShapedGlyph> = shape_cached(face, src, &feats, caps);
|
||||
let context = designcraft_fonts::ShapeContext {
|
||||
before: if replacement.is_none() { text.get(..range.start).unwrap_or("") } else { "" },
|
||||
after: if replacement.is_none() { text.get(range.end..).unwrap_or("") } else { "" },
|
||||
language: &p.language,
|
||||
};
|
||||
let shaped: Vec<ShapedGlyph> = shape_cached(face, src, &feats, caps, context);
|
||||
let n = shaped.len();
|
||||
let fref = FaceRef::of(face);
|
||||
for (gi, sg) in shaped.iter().enumerate() {
|
||||
@@ -631,6 +805,9 @@ fn shape_segment(
|
||||
}
|
||||
// Only the first glyph of a cluster owns the bytes (so ranges partition the text).
|
||||
let first_in_cluster = gi == 0 || shaped[gi - 1].cluster != sg.cluster;
|
||||
let mark = face.glyph_is_mark(sg.gid);
|
||||
let mark_x = if mark { p.diacritic_x_offset.clamp(-1000.0, 1000.0) / 1000.0 * p.size * hs } else { 0.0 };
|
||||
let mark_y = if mark { p.diacritic_y_offset.clamp(-1000.0, 1000.0) / 1000.0 * p.size * p.v_scale } else { 0.0 };
|
||||
out.push(Glyph {
|
||||
face: fref,
|
||||
gid: sg.gid,
|
||||
@@ -638,8 +815,8 @@ fn shape_segment(
|
||||
len: if first_in_cluster || replacement.is_some() { len } else { 0 },
|
||||
ch,
|
||||
adv,
|
||||
dx: sg.x_offset as f64 * k * hs,
|
||||
dy: -(sg.y_offset as f64) * k * p.v_scale,
|
||||
dx: sg.x_offset as f64 * k * hs + mark_x,
|
||||
dy: -(sg.y_offset as f64) * k * p.v_scale - mark_y,
|
||||
sx: k * hs,
|
||||
sy: k * p.v_scale,
|
||||
shift,
|
||||
@@ -651,8 +828,26 @@ fn shape_segment(
|
||||
size,
|
||||
style,
|
||||
no_break: p.no_break,
|
||||
locked_advance: p.jidori > 0,
|
||||
tcy: p.tate_chu_yoko.then_some([0.0; 3]),
|
||||
tcy_offset: [p.tate_chu_yoko_x_offset, p.tate_chu_yoko_y_offset],
|
||||
space,
|
||||
break_after: None,
|
||||
cjk_hang: 0.0,
|
||||
ideographic_space_elastic: false,
|
||||
character_alignment: p.character_alignment,
|
||||
leading_model: p.leading_model,
|
||||
character_direction: p.character_direction,
|
||||
display_char: if replacement.is_some() && matches!(ch, '0'..='9' | '\u{0660}'..='\u{0669}' | '\u{06F0}'..='\u{06F9}') {
|
||||
src.chars().next().unwrap_or(ch)
|
||||
} else {
|
||||
ch
|
||||
},
|
||||
bidi_offset: 0,
|
||||
bidi_end: 0,
|
||||
allow_kashidas: p.allow_kashidas,
|
||||
safe_tatweel_before: sg.safe_tatweel_before,
|
||||
shaping_rtl: sg.rtl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,7 +326,11 @@ fn emit(
|
||||
let rs = (cell.row_span.max(1) as usize).min(table.nrows() - r);
|
||||
let last_row = r + rs - 1;
|
||||
let y1 = ry(last_row).map_or(y + heights[r], |ly| ly + heights[last_row]);
|
||||
let rect = Rect::new(x0 + colx[c], y, x0 + colx[c + cs], y1);
|
||||
let rect = if table.options.direction == designcraft_doc::TextDirection::RightToLeft {
|
||||
Rect::new(right - colx[c + cs], y, right - colx[c], y1)
|
||||
} else {
|
||||
Rect::new(x0 + colx[c], y, x0 + colx[c + cs], y1)
|
||||
};
|
||||
let fill = if cell.has_fill() {
|
||||
Some((cell.fill.clone(), cell.fill_tint))
|
||||
} else if body.contains(&r) {
|
||||
@@ -382,11 +386,11 @@ fn emit(
|
||||
}
|
||||
};
|
||||
push(Point::new(rect.x0, rect.y0), Point::new(rect.x1, rect.y0), edge(0, at_top));
|
||||
push(Point::new(rect.x0, rect.y0), Point::new(rect.x0, rect.y1), edge(1, c == 0));
|
||||
push(Point::new(rect.x0, rect.y0), Point::new(rect.x0, rect.y1), edge(1, (rect.x0 - x0).abs() < 1e-6));
|
||||
if at_bottom {
|
||||
push(Point::new(rect.x0, rect.y1), Point::new(rect.x1, rect.y1), edge(2, true));
|
||||
}
|
||||
if c + cs == nc {
|
||||
if (rect.x1 - right).abs() < 1e-6 {
|
||||
push(Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), edge(3, true));
|
||||
}
|
||||
}
|
||||
|
||||
+404
-3
@@ -871,6 +871,19 @@ fn carets_in_right_to_left_text_follow_the_drawing() {
|
||||
|
||||
#[test]
|
||||
fn kashidas_stretch_justified_arabic_before_spaces() {
|
||||
check_kashida_justification(FontDb::global(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kashidas_without_tatweel_fall_back_to_spaces() {
|
||||
// A private database excludes host fonts without mutating the global database
|
||||
// used by parallel tests. Bundled Latin/Japanese fonts have no tatweel.
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
check_kashida_justification(&db, true);
|
||||
}
|
||||
|
||||
fn check_kashida_justification(db: &FontDb, require_fallback: bool) {
|
||||
let mut d = Document::new(&designcraft_doc::build::NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
let text = "بسم الله الرحمن الرحيم الحمد لله رب العالمين الرحمن الرحيم مالك يوم الدين اياك نعبد واياك نستعين";
|
||||
@@ -879,14 +892,28 @@ fn kashidas_stretch_justified_arabic_before_spaces() {
|
||||
..Default::default()
|
||||
};
|
||||
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 260.0, 400.0), lid, text, pf).unwrap();
|
||||
let first = |d: &Document| compose_story(d, sid, &ComposeOptions::default()).frames[0].lines[0].clone();
|
||||
let composed = |d: &Document| compose_with_db(d, d.story(sid).unwrap(), &frame_specs(d, sid), &ComposeOptions::default(), db);
|
||||
let first = |d: &Document| composed(d).frames[0].lines[0].clone();
|
||||
let with = first(&d);
|
||||
assert!(compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines.len() > 1);
|
||||
assert!(composed(&d).frames[0].lines.len() > 1);
|
||||
let lacks_tatweel = with.glyphs.iter().all(|g| g.face.glyph_for('\u{0640}') == 0);
|
||||
if require_fallback {
|
||||
assert!(lacks_tatweel);
|
||||
}
|
||||
// Spaces keep (about) their natural width; the joins took the extra length.
|
||||
let space_w = |l: &Line| l.glyphs.iter().filter(|g| g.len > 0 && text[g.byte..].starts_with(' ')).map(|g| g.adv).fold(0.0, f64::max);
|
||||
d.story_mut(sid).unwrap().paras[0].para.kashidas = Some(false);
|
||||
let without = first(&d);
|
||||
assert!(space_w(&with) < space_w(&without) - 0.5, "{} {}", space_w(&with), space_w(&without));
|
||||
if lacks_tatweel {
|
||||
// No legal elongation can be drawn: enabling kashidas must preserve
|
||||
// the same space justification as disabling them.
|
||||
assert_eq!(with.glyphs.len(), without.glyphs.len());
|
||||
for (a, b) in with.glyphs.iter().zip(&without.glyphs) {
|
||||
assert_eq!((a.byte, a.gid, a.x, a.adv), (b.byte, b.gid, b.x, b.adv));
|
||||
}
|
||||
} else {
|
||||
assert!(space_w(&with) < space_w(&without) - 0.5, "{} {}", space_w(&with), space_w(&without));
|
||||
}
|
||||
// Both fill the measure: the line's (visually last) glyph reaches the left edge either way.
|
||||
let left = |l: &Line| l.glyphs.iter().filter(|g| g.len > 0).map(|g| g.x).fold(f64::MAX, f64::min);
|
||||
assert!((left(&with) - left(&without)).abs() < 1.0, "{} {}", left(&with), left(&without));
|
||||
@@ -1027,3 +1054,377 @@ fn only_english_text_gets_english_hyphenation() {
|
||||
st.format_chars(0..n, |f| f.over.language = Some("French".into()));
|
||||
assert_eq!(hyphenated(&d2, sid), 0, "French isn't hyphenated with English rules");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_aki_adds_space_without_scaling_outlines() {
|
||||
let (mut d, sid, _) = doc_with("AB", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default());
|
||||
let old = compose_story(&d, sid, &ComposeOptions::default());
|
||||
d.story_mut(sid).unwrap().format_chars(0..2, |f| {
|
||||
f.over.leading_aki = Some(Some(0.25));
|
||||
f.over.trailing_aki = Some(Some(0.5));
|
||||
});
|
||||
let new = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let a = &old.frames[0].lines[0];
|
||||
let b = &new.frames[0].lines[0];
|
||||
assert!((b.end_x - a.end_x - 18.0).abs() < 1e-6);
|
||||
assert_eq!(a.glyphs[0].sx, b.glyphs[0].sx);
|
||||
assert!((b.glyphs[0].x - a.glyphs[0].x - 3.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_jidori_sets_group_width_and_survives_narrow_frames() {
|
||||
for composer in [designcraft_doc::Composer::SingleLine, designcraft_doc::Composer::Paragraph] {
|
||||
let (mut d, sid, _) = doc_with(
|
||||
"AB",
|
||||
Rect::new(0.0, 0.0, 30.0, 150.0),
|
||||
ParaAttrs { composer: Some(composer), glyph_scale_desired: Some(1.2), letter_space_desired: Some(0.2), ..Default::default() },
|
||||
);
|
||||
d.story_mut(sid).unwrap().format_chars(0..2, |f| f.over.jidori = Some(4));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let lines = all_lines(&cs);
|
||||
assert_eq!(lines.len(), 1, "an indivisible jidori group must not be emergency-split");
|
||||
assert!((lines[0].end_x - lines[0].x0 - 48.0).abs() < 1e-6);
|
||||
assert_eq!(lines[0].range, 0..2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_tsume_removes_sidebearings_without_collapsing_ink() {
|
||||
let (mut d, sid, _) = doc_with("HH", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default());
|
||||
let old = compose_story(&d, sid, &ComposeOptions::default());
|
||||
d.story_mut(sid).unwrap().format_chars(0..2, |f| f.over.tsume = Some(1.0));
|
||||
let new = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let a = &old.frames[0].lines[0];
|
||||
let b = &new.frames[0].lines[0];
|
||||
let g = &b.glyphs[0];
|
||||
let outline = designcraft_fonts::FontDb::global().outline(&g.face, g.gid);
|
||||
let ink_width = designcraft_geom::Shape::bounding_box(&*outline).width() * g.sx;
|
||||
assert!(ink_width > 0.0);
|
||||
assert!((b.end_x - b.x0 - 2.0 * ink_width).abs() < 1e-6);
|
||||
assert!(b.end_x < a.end_x);
|
||||
assert_eq!(a.glyphs[0].sx, g.sx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_composite_font_selects_entries_before_shaping() {
|
||||
use designcraft_doc::cjk::{CompositeFont, CompositeFontEntry};
|
||||
let (mut d, sid, _) = doc_with("A1B", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default());
|
||||
std::sync::Arc::make_mut(&mut d.styles).composite_fonts.push(CompositeFont {
|
||||
name: "Mixed".into(),
|
||||
entries: vec![
|
||||
CompositeFontEntry { family: "Source Serif 4".into(), ..Default::default() },
|
||||
CompositeFontEntry { characters: "0123456789".into(), family: "Source Sans 3".into(), relative_size: 0.5, ..Default::default() },
|
||||
],
|
||||
});
|
||||
d.story_mut(sid).unwrap().format_chars(0..3, |f| f.over.font_family = Some("CompositeFont/Mixed".into()));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let line = &cs.frames[0].lines[0];
|
||||
let a = line.glyphs.iter().find(|g| g.byte == 0).unwrap();
|
||||
let n = line.glyphs.iter().find(|g| g.byte == 1).unwrap();
|
||||
assert_ne!(a.face.id(), n.face.id());
|
||||
assert_eq!(cs.styles[a.style as usize].size, 12.0);
|
||||
assert_eq!(cs.styles[n.style as usize].size, 6.0);
|
||||
assert!(!cs.styles.iter().any(|s| s.missing_font));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_bunri_does_not_split_double_dash() {
|
||||
for composer in [designcraft_doc::Composer::SingleLine, designcraft_doc::Composer::Paragraph] {
|
||||
let (d, sid, _) =
|
||||
doc_with("--", Rect::new(0.0, 0.0, 5.0, 100.0), ParaAttrs { composer: Some(composer), bunri_kinshi: Some(true), ..Default::default() });
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
assert_eq!(all_lines(&cs).len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_custom_kinsoku_disables_selected_breaks() {
|
||||
let (mut d, sid, _) = doc_with(
|
||||
"甲乙丙丁",
|
||||
Rect::new(0.0, 0.0, 25.0, 200.0),
|
||||
ParaAttrs {
|
||||
composer: Some(designcraft_doc::Composer::SingleLine),
|
||||
kinsoku: Some(Some(designcraft_doc::cjk::Kinsoku { name: "Test".into(), no_start: "乙丙丁".into(), ..Default::default() })),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
d.story_mut(sid).unwrap().format_chars(0.."甲乙丙丁".len(), |f| f.over.size = Some(12.0));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
assert_eq!(all_lines(&cs).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_center_leading_measures_em_centers_across_different_sizes() {
|
||||
let (mut d, sid, _) = doc_with("A\nB", Rect::new(0.0, 0.0, 300.0, 200.0), ParaAttrs::default());
|
||||
d.story_mut(sid).unwrap().format_chars(0..3, |f| {
|
||||
f.over.leading = Some(designcraft_doc::Leading::Points(30.0));
|
||||
f.over.leading_model = Some(designcraft_doc::cjk::LeadingModel::Center);
|
||||
});
|
||||
d.story_mut(sid).unwrap().format_chars(0..1, |f| f.over.size = Some(24.0));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let lines = all_lines(&cs);
|
||||
assert_eq!(lines.len(), 2);
|
||||
let center = |l: &Line| {
|
||||
let g = &l.glyphs[0];
|
||||
let (a, b) = g.face.vertical_metrics();
|
||||
l.baseline + (b - a) / (2.0 * (a + b)) * g.face.units_per_em() * g.sy
|
||||
};
|
||||
assert!((center(lines[1]) - center(lines[0]) - 30.0).abs() < 1e-6);
|
||||
assert!((lines[1].baseline - lines[0].baseline - 30.0).abs() > 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_em_center_alignment_moves_small_characters() {
|
||||
let (mut d, sid, _) = doc_with("AB", Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default());
|
||||
d.story_mut(sid).unwrap().format_chars(0..1, |f| f.over.size = Some(24.0));
|
||||
d.story_mut(sid).unwrap().format_chars(1..2, |f| f.over.character_alignment = Some(designcraft_doc::cjk::CharacterAlignment::EmCenter));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let line = &cs.frames[0].lines[0];
|
||||
let center = |g: &PlacedGlyph| {
|
||||
let (a, b) = g.face.vertical_metrics();
|
||||
g.y + (b - a) / (2.0 * (a + b)) * g.face.units_per_em() * g.sy
|
||||
};
|
||||
assert!((center(&line.glyphs[0]) - center(&line.glyphs[1])).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_hanging_punctuation_uses_frame_edge_without_changing_glyph_size() {
|
||||
let k = designcraft_doc::cjk::Kinsoku { name: "Test".into(), no_start: ",".into(), hanging: ",".into(), ..Default::default() };
|
||||
let (mut d, sid, fid) = doc_with(
|
||||
"AB,",
|
||||
Rect::new(0.0, 0.0, 300.0, 100.0),
|
||||
ParaAttrs {
|
||||
composer: Some(designcraft_doc::Composer::SingleLine),
|
||||
kinsoku: Some(Some(k)),
|
||||
kinsoku_hang: Some(designcraft_doc::cjk::KinsokuHang::Regular),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let initial = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let line = &initial.frames[0].lines[0];
|
||||
let text_w = line.glyphs.iter().take(2).map(|g| g.adv).sum::<f64>();
|
||||
// A fresh frame whose measure fits the letters, but not the comma.
|
||||
let frame = d.item_mut(fid).unwrap();
|
||||
frame.path = designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, text_w + 1.0, 100.0));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
assert_eq!(all_lines(&cs).len(), 1);
|
||||
assert_eq!(cs.frames[0].lines[0].glyphs.last().unwrap().sx, line.glyphs.last().unwrap().sx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_fullwidth_space_obeys_the_paragraph_spacing_switch() {
|
||||
let text = "A B";
|
||||
let (mut d, sid, _) = doc_with(
|
||||
text,
|
||||
Rect::new(0.0, 0.0, 300.0, 100.0),
|
||||
ParaAttrs { word_space_desired: Some(2.0), treat_ideographic_space_as_space: Some(false), ..Default::default() },
|
||||
);
|
||||
let fixed = compose_story(&d, sid, &ComposeOptions::default());
|
||||
d.story_mut(sid).unwrap().paras[0].para.treat_ideographic_space_as_space = Some(true);
|
||||
let elastic = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let space = fixed.frames[0].lines[0].glyphs.iter().find(|g| g.byte == 1).unwrap().adv;
|
||||
assert!((elastic.frames[0].lines[0].end_x - fixed.frames[0].lines[0].end_x - space).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_bidi_keeps_paragraph_context_across_forced_lines() {
|
||||
let text = "مرحبا\u{2028}123 - 456";
|
||||
let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 600.0, 200.0), ParaAttrs::default());
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let lines = all_lines(&cs);
|
||||
assert_eq!(lines.len(), 2);
|
||||
let mut gs: Vec<_> = lines[1].glyphs.iter().filter(|g| g.len > 0).collect();
|
||||
gs.sort_by(|a, b| a.x.total_cmp(&b.x));
|
||||
let actual: String = gs.iter().map(|g| text[g.byte..].chars().next().unwrap()).collect();
|
||||
let info = unicode_bidi::BidiInfo::new(text, Some(unicode_bidi::Level::ltr()));
|
||||
let start = text.find('1').unwrap();
|
||||
let expected = info.reorder_line(&info.paragraphs[0], start..text.len()).to_string();
|
||||
assert_eq!(actual, expected);
|
||||
assert_ne!(actual, "123 - 456", "the previous Arabic strong character affects number ordering");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_character_direction_override_survives_line_layout() {
|
||||
let text = "abc 123 def";
|
||||
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 600.0, 200.0), ParaAttrs::default());
|
||||
d.story_mut(sid).unwrap().format_chars(4..7, |f| f.over.character_direction = Some(designcraft_doc::arabic::CharacterDirection::RightToLeft));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let line = all_lines(&cs)[0];
|
||||
let x = |byte| line.glyphs.iter().find(|g| g.byte == byte).unwrap().x;
|
||||
assert!(x(4) > x(5) && x(5) > x(6));
|
||||
assert!(x(0) < x(6) && x(4) < x(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_fallback_marks_stay_with_bases_and_custom_offsets_move_only_marks() {
|
||||
let db = designcraft_fonts::FontDb::global();
|
||||
let Some(face) = db.fallback_for('ب', db.face(designcraft_fonts::DEFAULT_FAMILY, "Regular").id()) else { return };
|
||||
if !face.covers('ُ') {
|
||||
return;
|
||||
}
|
||||
let text = "بُبَ";
|
||||
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 600.0, 200.0), ParaAttrs::default());
|
||||
let plain = compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].glyphs.clone();
|
||||
assert!(plain.iter().all(|g| g.face.id() == face.id()), "fallback must not split Arabic marks into the Latin face");
|
||||
assert!(plain.iter().all(|g| g.gid != 0));
|
||||
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| {
|
||||
f.over.diacritic_x_offset = Some(200.0);
|
||||
f.over.diacritic_y_offset = Some(-100.0);
|
||||
});
|
||||
let shifted = compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].glyphs.clone();
|
||||
let mut marks = 0;
|
||||
for (a, b) in plain.iter().zip(&shifted) {
|
||||
assert_eq!(a.gid, b.gid);
|
||||
assert_eq!(a.adv, b.adv);
|
||||
if face.glyph_is_mark(a.gid) {
|
||||
marks += 1;
|
||||
assert!((b.x - a.x - 2.4).abs() < 1e-6);
|
||||
assert!((b.y - a.y - 1.2).abs() < 1e-6);
|
||||
} else {
|
||||
assert!((a.x - b.x).abs() < 1e-6 && (a.y - b.y).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
assert!(marks > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_joining_context_crosses_character_style_boundaries() {
|
||||
let text = "ببب";
|
||||
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 600.0, 200.0), ParaAttrs::default());
|
||||
let plain = compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].glyphs.clone();
|
||||
if plain.iter().any(|g| g.gid == 0) {
|
||||
return;
|
||||
}
|
||||
d.story_mut(sid).unwrap().format_chars(2..4, |f| f.over.fill = Some("Paper".into()));
|
||||
let split = compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].glyphs.clone();
|
||||
assert_eq!(plain.iter().map(|g| (g.byte, g.gid)).collect::<Vec<_>>(), split.iter().map(|g| (g.byte, g.gid)).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_character_kashida_switch_prevents_insertion() {
|
||||
let text = "بسم الله الرحمن الرحيم الحمد لله رب العالمين";
|
||||
let (mut d, sid, _) = doc_with(
|
||||
text,
|
||||
Rect::new(0.0, 0.0, 160.0, 300.0),
|
||||
ParaAttrs { direction: Some(designcraft_doc::TextDirection::RightToLeft), align: Some(Align::RightJustified), ..Default::default() },
|
||||
);
|
||||
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.allow_kashidas = Some(false));
|
||||
let off = compose_story(&d, sid, &ComposeOptions::default());
|
||||
assert!(all_lines(&off).iter().all(|l| l.glyphs.iter().all(|g| g.len > 0 || g.face.glyph_for('\u{0640}') != g.gid)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_indic_digit_conversion_retains_original_utf8_ranges() {
|
||||
let text = "١٢٣";
|
||||
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 200.0, 100.0), ParaAttrs::default());
|
||||
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.digits = Some(designcraft_doc::Digits::Arabic));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let gs = &cs.frames[0].lines[0].glyphs;
|
||||
assert_eq!(gs.iter().map(|g| (g.byte, g.len)).collect::<Vec<_>>(), [(0, 2), (2, 2), (4, 2)]);
|
||||
for (g, c) in gs.iter().zip("123".chars()) {
|
||||
assert_eq!(g.gid, g.face.glyph_for(c));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_contextual_digits_follow_strong_text_across_style_changes() {
|
||||
let text = "ب 12 A 34";
|
||||
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default());
|
||||
let arabic_digits = text.find('1').unwrap();
|
||||
d.story_mut(sid).unwrap().format_chars(arabic_digits..arabic_digits + 2, |f| f.over.fill = Some("Paper".into()));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let gs = &cs.frames[0].lines[0].glyphs;
|
||||
let g = gs.iter().find(|g| g.byte == arabic_digits).unwrap();
|
||||
assert_eq!(g.gid, g.face.glyph_for('١'));
|
||||
let g = gs.iter().find(|g| g.byte == text.find('3').unwrap()).unwrap();
|
||||
assert_eq!(g.gid, g.face.glyph_for('3'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_story_direction_flows_right_column_first() {
|
||||
let text = format!("first{}second", designcraft_doc::story::COLUMN_BREAK);
|
||||
let (mut d, sid, fid) = doc_with(&text, Rect::new(0.0, 0.0, 300.0, 200.0), ParaAttrs::default());
|
||||
d.story_mut(sid).unwrap().direction = designcraft_doc::TextDirection::RightToLeft;
|
||||
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2;
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let lines = all_lines(&cs);
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert!(lines[0].x0 > lines[1].x0);
|
||||
assert!(cs.frames[0].columns[0].x0 > cs.frames[0].columns[1].x0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_rtl_table_keeps_logical_indices_and_merged_cell_hit_testing() {
|
||||
let mut t = designcraft_doc::Table::new(987, 2, 3, 0, 0, 240.0);
|
||||
t.options.direction = designcraft_doc::TextDirection::RightToLeft;
|
||||
t.cell_mut(0, 0).unwrap().text = designcraft_doc::Story::with_text(StoryId(0), "Right", ParaFormat::default());
|
||||
t.cell_mut(0, 2).unwrap().text = designcraft_doc::Story::with_text(StoryId(0), "Left", ParaFormat::default());
|
||||
t.merge(designcraft_doc::CellRange::new(1, 0, 1, 1)).unwrap();
|
||||
let (d, sid, _) = table_doc(Rect::new(0.0, 0.0, 400.0, 400.0), t);
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let (_, _, right) = table::find_cell(&cs, 987, 0, 0).unwrap();
|
||||
let (_, _, left) = table::find_cell(&cs, 987, 0, 2).unwrap();
|
||||
assert!(right.rect.x0 > left.rect.x0);
|
||||
let (_, _, merged) = table::find_cell(&cs, 987, 1, 0).unwrap();
|
||||
assert!((merged.rect.width() - 160.0).abs() < 1e-6);
|
||||
assert_eq!(table::hit_cell(&cs, 0, right.rect.center()).map(|(_, r, c, _)| (r, c)), Some((0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_kashida_moves_marks_with_their_cluster_and_respects_nonjoiners() {
|
||||
let text = "بُسْمِ بُسْمِ";
|
||||
let (mut d, sid, _) = doc_with(
|
||||
text,
|
||||
Rect::new(0.0, 0.0, 180.0, 100.0),
|
||||
ParaAttrs { direction: Some(designcraft_doc::TextDirection::RightToLeft), align: Some(Align::FullyJustified), ..Default::default() },
|
||||
);
|
||||
let with = compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].glyphs.clone();
|
||||
if with.iter().any(|g| g.gid == 0) {
|
||||
return;
|
||||
}
|
||||
assert!(with.iter().any(|g| g.len == 0 && g.gid == g.face.glyph_for('\u{0640}')));
|
||||
d.story_mut(sid).unwrap().paras[0].para.kashidas = Some(false);
|
||||
let without = compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines[0].glyphs.clone();
|
||||
for (a, b) in with.iter().zip(&without) {
|
||||
assert_eq!((a.gid, a.byte), (b.gid, b.byte));
|
||||
let first_a = with.iter().find(|g| g.byte == a.byte).unwrap();
|
||||
let first_b = without.iter().find(|g| g.byte == b.byte).unwrap();
|
||||
assert!(((a.x - first_a.x) - (b.x - first_b.x)).abs() < 1e-6, "marks detached from their cluster");
|
||||
}
|
||||
let text = "ب\u{200C}ب ب\u{200C}ب";
|
||||
let (d, sid, _) = doc_with(
|
||||
text,
|
||||
Rect::new(0.0, 0.0, 180.0, 100.0),
|
||||
ParaAttrs { direction: Some(designcraft_doc::TextDirection::RightToLeft), align: Some(Align::FullyJustified), ..Default::default() },
|
||||
);
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
assert!(cs.frames[0].lines[0].glyphs.iter().all(|g| g.gid != g.face.glyph_for('\u{0640}') || g.len > 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_explicit_isolated_forms_disable_contextual_joining() {
|
||||
let text = "بب";
|
||||
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 180.0, 100.0), ParaAttrs::default());
|
||||
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.positional_form = Some("Isolated".into()));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let gs = &cs.frames[0].lines[0].glyphs;
|
||||
for g in gs {
|
||||
assert_eq!(g.gid, g.face.glyph_for('ب'));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_character_overrides_mirror_brackets_using_final_levels() {
|
||||
use designcraft_doc::arabic::CharacterDirection as D;
|
||||
for (text, direction, mirrored) in [("(123)", D::RightToLeft, true), ("(ب)", D::LeftToRight, false)] {
|
||||
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 300.0, 100.0), ParaAttrs::default());
|
||||
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.character_direction = Some(direction));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let gs = &cs.frames[0].lines[0].glyphs;
|
||||
for (byte, ch) in [(0, '('), (text.len() - 1, ')')] {
|
||||
let g = gs.iter().find(|g| g.byte == byte).unwrap();
|
||||
let expected = if mirrored { unicode_bidi_mirroring::get_mirrored(ch).unwrap() } else { ch };
|
||||
assert_eq!(g.gid, g.face.glyph_for(expected), "{text:?} {direction:?} byte {byte}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! Portable character controls for bidirectional and Arabic text.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum CharacterDirection {
|
||||
#[default]
|
||||
Default,
|
||||
LeftToRight,
|
||||
RightToLeft,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DiacriticPosition {
|
||||
#[default]
|
||||
Default,
|
||||
OpenType,
|
||||
OpenTypeFromBaseline,
|
||||
Loose,
|
||||
Medium,
|
||||
Tight,
|
||||
}
|
||||
+56
-5
@@ -128,7 +128,7 @@ pub enum TextDirection {
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Digits {
|
||||
/// As typed.
|
||||
/// Contextual digits; composition selects the preceding strong script/language.
|
||||
#[default]
|
||||
Default,
|
||||
/// European (0123).
|
||||
@@ -144,16 +144,31 @@ pub enum Digits {
|
||||
impl Digits {
|
||||
/// The digit `c` (0–9) is drawn as, for text in `language`.
|
||||
pub fn map(self, c: char, language: &str) -> char {
|
||||
let Some(d) = c.to_digit(10) else { return c };
|
||||
let d = match c {
|
||||
'0'..='9' => c as u32 - '0' as u32,
|
||||
'\u{0660}'..='\u{0669}' => c as u32 - 0x0660,
|
||||
'\u{06F0}'..='\u{06F9}' => c as u32 - 0x06F0,
|
||||
_ => return c,
|
||||
};
|
||||
let zero = match self {
|
||||
Digits::Default | Digits::Arabic => return c,
|
||||
Digits::Default => return c,
|
||||
Digits::Arabic => 0x0030,
|
||||
Digits::Hindi => 0x0660,
|
||||
Digits::Farsi => 0x06F0,
|
||||
Digits::Native => {
|
||||
let l = language.to_ascii_lowercase();
|
||||
match () {
|
||||
_ if l.starts_with("arabic") => 0x0660,
|
||||
_ if l.starts_with("persian") || l.starts_with("farsi") || l.starts_with("urdu") => 0x06F0,
|
||||
_ if l.starts_with("arabic") || l == "ar" || l.starts_with("ar-") => 0x0660,
|
||||
_ if l.starts_with("persian")
|
||||
|| l.starts_with("farsi")
|
||||
|| l.starts_with("urdu")
|
||||
|| l == "fa"
|
||||
|| l.starts_with("fa-")
|
||||
|| l == "ur"
|
||||
|| l.starts_with("ur-") =>
|
||||
{
|
||||
0x06F0
|
||||
}
|
||||
_ if l.starts_with("hindi") || l.starts_with("marathi") || l.starts_with("nepali") || l.starts_with("sanskrit") => 0x0966,
|
||||
_ if l.starts_with("bengali") => 0x09E6,
|
||||
_ if l.starts_with("gujarati") => 0x0AE6,
|
||||
@@ -501,6 +516,17 @@ attr_set! {
|
||||
kerning: Kerning = Kerning::Metrics,
|
||||
/// Tracking in 1/1000 em.
|
||||
tracking: f64 = 0.0,
|
||||
/// Explicit CJK aki, in em; None restores automatic spacing.
|
||||
leading_aki: Option<f64> = None,
|
||||
trailing_aki: Option<f64> = None,
|
||||
/// Proportional character compression, 0..1.
|
||||
tsume: f64 = 0.0,
|
||||
/// Target em cells for the contiguous formatted group (0 disables jidori).
|
||||
jidori: u32 = 0,
|
||||
character_alignment: crate::cjk::CharacterAlignment = crate::cjk::CharacterAlignment::Baseline,
|
||||
leading_model: crate::cjk::LeadingModel = crate::cjk::LeadingModel::Roman,
|
||||
/// Explicit emphasis character; empty uses the traditional sesame dot.
|
||||
kenten_character: String = String::new(),
|
||||
/// Horizontal / vertical scale, 1.0 = 100%.
|
||||
h_scale: f64 = 1.0,
|
||||
v_scale: f64 = 1.0,
|
||||
@@ -527,10 +553,13 @@ attr_set! {
|
||||
strikethrough_offset: Option<f64> = None,
|
||||
strikethrough_color: String = String::new(),
|
||||
strikethrough_tint: f32 = 1.0,
|
||||
glyph_form: String = String::new(),
|
||||
ligatures: bool = true,
|
||||
no_break: bool = false,
|
||||
/// Tate-chu-yoko: in vertical text, the run is set horizontally within one em of the line.
|
||||
tate_chu_yoko: bool = false,
|
||||
tate_chu_yoko_x_offset: f64 = 0.0,
|
||||
tate_chu_yoko_y_offset: f64 = 0.0,
|
||||
/// Ruby: the reading set small above the text (to its right in vertical text), one group
|
||||
/// over each run that has it.
|
||||
ruby: String = String::new(),
|
||||
@@ -538,6 +567,15 @@ attr_set! {
|
||||
kenten: bool = false,
|
||||
/// Digits (World-Ready): how 0–9 are drawn.
|
||||
digits: Digits = Digits::Default,
|
||||
character_direction: crate::arabic::CharacterDirection = crate::arabic::CharacterDirection::Default,
|
||||
/// Character-level permission, additionally gated by paragraph kashidas.
|
||||
allow_kashidas: bool = true,
|
||||
diacritic_position: crate::arabic::DiacriticPosition = crate::arabic::DiacriticPosition::Default,
|
||||
/// Additional mark offsets, in thousandths of an em. Positive y is up.
|
||||
diacritic_x_offset: f64 = 0.0,
|
||||
diacritic_y_offset: f64 = 0.0,
|
||||
/// IDML contextual-form request, separate from general OpenType features.
|
||||
positional_form: String = String::new(),
|
||||
language: String = "English: USA".into(),
|
||||
/// Additional OpenType features, e.g. `["onum", "ss01"]`.
|
||||
otf_features: Vec<String> = Vec::new(),
|
||||
@@ -599,6 +637,9 @@ attr_set! {
|
||||
single_word_justify: Align = Align::FullyJustified,
|
||||
/// Insert Kashidas (World-Ready): justified Arabic lines stretch at joins before spaces.
|
||||
kashidas: bool = true,
|
||||
/// Preserved vendor policy; non-default policies require a dedicated composer.
|
||||
arabic_justification: String = String::new(),
|
||||
paragraph_kashida_width: Option<f64> = None,
|
||||
// Keeps
|
||||
keep_with_next: u32 = 0,
|
||||
keep_lines_together: bool = false,
|
||||
@@ -621,6 +662,16 @@ attr_set! {
|
||||
list_separator: String = "\t".into(),
|
||||
balance_ragged: bool = false,
|
||||
optical_margin: bool = false,
|
||||
/// None uses the legacy built-in rules; an empty set explicitly disables kinsoku.
|
||||
kinsoku: Option<crate::cjk::Kinsoku> = None,
|
||||
/// Preserved spacing-table reference; compatibility audit reports unsupported composition.
|
||||
mojikumi: String = String::new(),
|
||||
/// Preserved priority policy; the current breaker does not implement these priorities.
|
||||
kinsoku_type: String = String::new(),
|
||||
kinsoku_hang: crate::cjk::KinsokuHang = crate::cjk::KinsokuHang::None,
|
||||
bunri_kinshi: bool = false,
|
||||
rensuuji: bool = true,
|
||||
treat_ideographic_space_as_space: bool = false,
|
||||
/// Paragraph shading, and how far it reaches past the text: top, left, bottom, right.
|
||||
shading_on: bool = false,
|
||||
shading_color: String = "[Black]".into(),
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Portable CJK typography data. No platform fonts or UI dependencies.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CompositeFont {
|
||||
pub name: String,
|
||||
pub entries: Vec<CompositeFontEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CompositeFontEntry {
|
||||
pub name: String,
|
||||
/// Empty character set is the base entry, independent of its localized name.
|
||||
pub characters: String,
|
||||
pub family: String,
|
||||
pub style: String,
|
||||
pub relative_size: f64,
|
||||
pub horizontal_scale: f64,
|
||||
pub vertical_scale: f64,
|
||||
/// Percentage of the original em, as a fraction.
|
||||
pub baseline_shift: f64,
|
||||
pub scale_option: bool,
|
||||
}
|
||||
impl Default for CompositeFontEntry {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: String::new(),
|
||||
characters: String::new(),
|
||||
family: String::new(),
|
||||
style: "Regular".into(),
|
||||
relative_size: 1.0,
|
||||
horizontal_scale: 1.0,
|
||||
vertical_scale: 1.0,
|
||||
baseline_shift: 0.0,
|
||||
scale_option: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl CompositeFont {
|
||||
pub fn entry(&self, c: char) -> Option<&CompositeFontEntry> {
|
||||
self.entries
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|e| !e.characters.is_empty() && e.characters.contains(c))
|
||||
.or_else(|| self.entries.iter().find(|e| e.characters.is_empty()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Kinsoku {
|
||||
pub name: String,
|
||||
pub no_start: String,
|
||||
pub no_end: String,
|
||||
pub inseparable: String,
|
||||
pub hanging: String,
|
||||
}
|
||||
impl Kinsoku {
|
||||
/// Explicitly supplied character sets take precedence over the fallback table.
|
||||
pub fn allows(&self, a: char, b: char) -> bool {
|
||||
!(self.no_end.contains(a) || self.no_start.contains(b) || (a == b && self.inseparable.contains(a)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LeadingModel {
|
||||
#[default]
|
||||
Roman,
|
||||
AkiBelow,
|
||||
AkiAbove,
|
||||
Center,
|
||||
CenterDown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum CharacterAlignment {
|
||||
#[default]
|
||||
Baseline,
|
||||
EmTop,
|
||||
EmCenter,
|
||||
EmBottom,
|
||||
IcfTop,
|
||||
IcfBottom,
|
||||
}
|
||||
|
||||
impl Kinsoku {
|
||||
/// Clean-room Unicode rules for the named CJK sets. Explicit document tables
|
||||
/// are imported separately and always take precedence over these defaults.
|
||||
pub fn named(name: &str) -> Option<Self> {
|
||||
let name = name.trim_start_matches("KinsokuTable/").trim_start_matches("$ID/");
|
||||
let hard = match name {
|
||||
"HardKinsoku" | "kHardKinsokuName" => true,
|
||||
"SoftKinsoku"
|
||||
| "kSoftKinsokuName"
|
||||
| "SimplifiedChineseKinsoku"
|
||||
| "kSimpChineseKinsokuName"
|
||||
| "TraditionalChineseKinsoku"
|
||||
| "kTradChineseKinsokuName"
|
||||
| "KoreanKinsoku"
|
||||
| "kKoreanKinsokuName" => false,
|
||||
_ => return None,
|
||||
};
|
||||
let mut no_start = "、。,.・:;?!)」』】〕〉》}]〙〗’”!),.:;?]}".to_string();
|
||||
if hard {
|
||||
no_start.push_str("ーぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮヵヶ々〻ゝゞヽヾ");
|
||||
}
|
||||
Some(Self {
|
||||
name: name.into(),
|
||||
no_start,
|
||||
no_end: "(「『【〔〈《{[〘〖‘“([{".into(),
|
||||
hanging: "、。,.,.".into(),
|
||||
inseparable: String::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum KinsokuHang {
|
||||
#[default]
|
||||
None,
|
||||
Regular,
|
||||
Force,
|
||||
}
|
||||
|
||||
/// Preserved IDML spacing tables. Keeping these separate from the supported
|
||||
/// character spacing is essential: a stored table is not an applied rule.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct MojikumiTable {
|
||||
pub name: String,
|
||||
pub based_on: String,
|
||||
pub overrides: Vec<MojikumiAki>,
|
||||
}
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct MojikumiAki {
|
||||
pub target_class: i16,
|
||||
pub side_class: i16,
|
||||
pub after: bool,
|
||||
pub minimum: f64,
|
||||
pub desired: f64,
|
||||
pub maximum: f64,
|
||||
pub priority: i16,
|
||||
pub does_not_float: bool,
|
||||
}
|
||||
@@ -345,6 +345,7 @@ impl Document {
|
||||
Some(serde_json::json!({
|
||||
"id": sid.0,
|
||||
"length": st.text.len(),
|
||||
"direction": st.direction,
|
||||
"paragraphs": st.paras.len(),
|
||||
"frames": st.frames.iter().map(|f| f.0).collect::<Vec<_>>(),
|
||||
"text": st.text,
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod anchored;
|
||||
pub mod arabic;
|
||||
pub mod arrow;
|
||||
pub mod attrs;
|
||||
pub mod build;
|
||||
pub mod cjk;
|
||||
mod edit;
|
||||
pub mod endnotes;
|
||||
pub mod ids;
|
||||
@@ -675,6 +677,18 @@ impl Document {
|
||||
|
||||
/// Validate structural invariants (ids unique, threads consistent, stories well-formed).
|
||||
pub fn check(&self) -> Result<()> {
|
||||
for f in &self.styles.composite_fonts {
|
||||
if f.entries.iter().any(|e| {
|
||||
![e.relative_size, e.horizontal_scale, e.vertical_scale].iter().all(|v| v.is_finite() && *v > 0.0) || !e.baseline_shift.is_finite()
|
||||
}) {
|
||||
return Err(DocError::Invalid(format!("invalid composite font metrics: {}", f.name)));
|
||||
}
|
||||
}
|
||||
for t in &self.styles.mojikumi_tables {
|
||||
if t.overrides.iter().any(|r| ![r.minimum, r.desired, r.maximum].iter().all(|v| v.is_finite())) {
|
||||
return Err(DocError::Invalid(format!("non-finite mojikumi spacing: {}", t.name)));
|
||||
}
|
||||
}
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
for sp in self.spreads.iter().chain(self.parents.iter()) {
|
||||
for it in &sp.items {
|
||||
|
||||
@@ -10,6 +10,14 @@ pub const TOGGLES: &[(&str, &str, &str)] = &[
|
||||
("titl", "Titling Alternates", "OTFTitling"),
|
||||
("calt", "Contextual Alternates", "OTFContextualAlternate"),
|
||||
("zero", "Slashed Zero", "OTFSlashedZero"),
|
||||
("palt", "Proportional Metrics", "OTFProportionalMetrics"),
|
||||
("hkna", "Horizontal Kana", "OTFHVKana"),
|
||||
("locl", "Localized Forms", "OTFLocale"),
|
||||
("mark", "Mark Positioning", "OTFMark"),
|
||||
("hist", "Historical Forms", "OTFHistorical"),
|
||||
("salt", "Stylistic Alternates", "OTFStylisticAlternate"),
|
||||
("jalt", "Justification Alternates", "OTFJustificationAlternate"),
|
||||
("ital", "Roman Italics", "OTFRomanItalics"),
|
||||
];
|
||||
|
||||
/// Figure styles: (id, menu label, tags, IDML value).
|
||||
@@ -23,7 +31,7 @@ pub const FIGURES: &[(&str, &str, &[&str], &str)] = &[
|
||||
|
||||
/// Features shaping turns on by itself: "off" is an explicit `-tag`.
|
||||
fn default_on(tag: &str) -> bool {
|
||||
tag == "calt"
|
||||
matches!(tag, "calt" | "locl" | "mark")
|
||||
}
|
||||
|
||||
pub fn is_on(list: &[String], tag: &str) -> bool {
|
||||
|
||||
@@ -15,6 +15,7 @@ impl Story {
|
||||
let a = crate::story::floor_char_boundary(&self.text, range.start.min(self.len()));
|
||||
let b = crate::story::floor_char_boundary(&self.text, range.end.min(self.len())).max(a);
|
||||
let mut out = Story::new(StoryId(0));
|
||||
out.direction = self.direction;
|
||||
out.text = self.text[a..b].to_string();
|
||||
let (p0, p1) = (self.para_at(a), self.para_at(b));
|
||||
out.paras = self.paras[p0..=p1].to_vec();
|
||||
|
||||
@@ -92,6 +92,9 @@ pub struct Story {
|
||||
pub chars: Vec<CharRun>,
|
||||
/// Text frames in thread order.
|
||||
pub frames: Vec<ItemId>,
|
||||
/// Column progression, independent of paragraph bidi direction and frame threading.
|
||||
#[serde(default)]
|
||||
pub direction: crate::TextDirection,
|
||||
/// Bumped on every edit (composition cache key).
|
||||
#[serde(default)]
|
||||
pub rev: u64,
|
||||
@@ -132,6 +135,7 @@ impl Story {
|
||||
paras: vec![ParaFormat::default()],
|
||||
chars: vec![CharRun { len: 0, format: CharFormat::default() }],
|
||||
frames: vec![],
|
||||
direction: crate::TextDirection::LeftToRight,
|
||||
rev: 0,
|
||||
tables: BTreeMap::new(),
|
||||
notes: Vec::new(),
|
||||
|
||||
@@ -60,6 +60,10 @@ pub struct ObjectStyle {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Styles {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub composite_fonts: Vec<crate::cjk::CompositeFont>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub mojikumi_tables: Vec<crate::cjk::MojikumiTable>,
|
||||
/// Export Tagging per style (`p:Name` or `c:Name`): the HTML tag and class for EPUB/HTML.
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub export_tags: std::collections::BTreeMap<String, ExportTag>,
|
||||
@@ -99,6 +103,8 @@ impl Styles {
|
||||
impl Default for Styles {
|
||||
fn default() -> Self {
|
||||
Styles {
|
||||
composite_fonts: Vec::new(),
|
||||
mojikumi_tables: Vec::new(),
|
||||
export_tags: Default::default(),
|
||||
paragraph: vec![
|
||||
ParagraphStyle {
|
||||
@@ -236,11 +242,12 @@ impl TableStyle {
|
||||
for r in 0..nr {
|
||||
let kind = t.rows[r].kind;
|
||||
for c in 0..nc {
|
||||
let visual_column = if t.options.direction == crate::TextDirection::RightToLeft { nc - 1 - c } else { c };
|
||||
let region = match kind {
|
||||
crate::table::RowKind::Header => self.header.as_ref(),
|
||||
crate::table::RowKind::Footer => self.footer.as_ref(),
|
||||
_ if c == 0 && self.left_column.is_some() => self.left_column.as_ref(),
|
||||
_ if c + 1 == nc && self.right_column.is_some() => self.right_column.as_ref(),
|
||||
_ if visual_column == 0 && self.left_column.is_some() => self.left_column.as_ref(),
|
||||
_ if visual_column + 1 == nc && self.right_column.is_some() => self.right_column.as_ref(),
|
||||
_ => self.body.as_ref(),
|
||||
};
|
||||
if let (Some(name), Some(cell)) = (region, t.cells.get_mut(r * nc + c))
|
||||
|
||||
@@ -213,6 +213,8 @@ impl AltFills {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct TableOptions {
|
||||
/// Logical column zero is drawn at the right edge for RTL tables.
|
||||
pub direction: crate::TextDirection,
|
||||
/// Outer border (overrides the outer cell edges).
|
||||
pub border: CellStroke,
|
||||
pub space_before: f64,
|
||||
@@ -229,6 +231,7 @@ pub struct TableOptions {
|
||||
impl Default for TableOptions {
|
||||
fn default() -> Self {
|
||||
TableOptions {
|
||||
direction: crate::TextDirection::LeftToRight,
|
||||
border: CellStroke::default(),
|
||||
space_before: 4.0,
|
||||
space_after: -4.0,
|
||||
|
||||
@@ -55,7 +55,16 @@ fn list(d: &Document) -> Vec<Value> {
|
||||
continue;
|
||||
}
|
||||
let p = d.styles.resolve_char(&base, fmt);
|
||||
*used.entry((p.font_family, p.font_style)).or_default() += n;
|
||||
if let Some(f) = d.styles.composite_fonts.iter().find(|f| f.name == p.font_family.trim_start_matches("CompositeFont/")) {
|
||||
let text = st.text.get(rr.start.max(r.start)..rr.end.min(r.end).max(rr.start.max(r.start))).unwrap_or("");
|
||||
for c in text.chars() {
|
||||
if let Some(e) = f.entry(c) {
|
||||
*used.entry((e.family.clone(), e.style.clone())).or_default() += 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
*used.entry((p.font_family, p.font_style)).or_default() += n;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -101,6 +110,17 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for cs in &mut st.character {
|
||||
changed += swap(&mut cs.chars, &family, style.as_deref(), &to_family, to_style.as_deref()) as usize;
|
||||
}
|
||||
for f in &mut st.composite_fonts {
|
||||
for e in &mut f.entries {
|
||||
if e.family.eq_ignore_ascii_case(&family) && style.as_ref().is_none_or(|s| e.style.eq_ignore_ascii_case(s)) {
|
||||
e.family.clone_from(&to_family);
|
||||
if let Some(s) = &to_style {
|
||||
e.style.clone_from(s);
|
||||
}
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Local formatting in every story (cells and footnotes too).
|
||||
let fix = |st: &mut Story, changed: &mut usize| {
|
||||
|
||||
@@ -39,6 +39,7 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
|
||||
// Stories: overset and fonts.
|
||||
let db = designcraft_fonts::FontDb::global();
|
||||
let mut missing_fonts: Vec<String> = Vec::new();
|
||||
let mut unsupported_typography = std::collections::BTreeSet::new();
|
||||
for story in d.stories.values() {
|
||||
let cs = s.cache.get(d, story.id, None);
|
||||
if cs.is_overset() {
|
||||
@@ -47,16 +48,56 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
|
||||
let n = story.text[cs.overset_at.unwrap_or(0).min(story.len())..].chars().count();
|
||||
out.push(Issue { severity: "error", kind: "overset", message: format!("Overset text: {n} characters"), item: last.map(|i| i.0), page });
|
||||
}
|
||||
let ranges = story.para_ranges();
|
||||
for (pi, pf) in story.paras.iter().enumerate() {
|
||||
let (_, base) = d.styles.resolve_para(pf);
|
||||
let _ = pi;
|
||||
let (para, base) = d.styles.resolve_para(pf);
|
||||
let Some(range) = ranges.get(pi) else { continue };
|
||||
let has_rtl = story.text.get(range.clone()).is_some_and(|text| text.chars().any(designcraft_fonts::is_rtl));
|
||||
if has_rtl {
|
||||
if !matches!(para.arabic_justification.as_str(), "" | "DefaultJustification") {
|
||||
unsupported_typography.insert(format!(
|
||||
"Arabic justification policy `{}` is preserved; vendor-specific Naskh/Arabic algorithms are not implemented",
|
||||
para.arabic_justification
|
||||
));
|
||||
}
|
||||
if para.paragraph_kashida_width.is_some() && para.arabic_justification != "DefaultJustification" {
|
||||
unsupported_typography
|
||||
.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 !para.kinsoku_type.is_empty() {
|
||||
unsupported_typography
|
||||
.insert(format!("Kinsoku priority `{}` is preserved, but push-in/push-out priority is not applied", para.kinsoku_type));
|
||||
}
|
||||
let mut fams = vec![base.font_family.clone()];
|
||||
for (_, f) in story.runs() {
|
||||
fams.push(d.styles.resolve_char(&base, f).font_family);
|
||||
for (run, f) in story.runs().filter(|(run, _)| run.start < range.end && run.end > range.start) {
|
||||
let props = d.styles.resolve_char(&base, f);
|
||||
if has_rtl
|
||||
&& story.text.get(run.start.max(range.start)..run.end.min(range.end)).is_some_and(|t| t.chars().any(designcraft_fonts::is_rtl))
|
||||
{
|
||||
use designcraft_doc::arabic::DiacriticPosition as P;
|
||||
if !matches!(props.diacritic_position, P::Default | P::OpenType) {
|
||||
unsupported_typography.insert(format!(
|
||||
"Diacritic preset `{:?}` is preserved; font OpenType anchors and explicit offsets are used",
|
||||
props.diacritic_position
|
||||
));
|
||||
}
|
||||
if !matches!(props.positional_form.as_str(), "" | "None" | "Calculate" | "Initial" | "Medial" | "Final" | "Isolated") {
|
||||
unsupported_typography.insert(format!("Unknown positional form `{}` is preserved but not applied", props.positional_form));
|
||||
}
|
||||
}
|
||||
fams.push(props.font_family);
|
||||
}
|
||||
for fam in fams {
|
||||
if !db.has_family(&fam) && !missing_fonts.contains(&fam) {
|
||||
missing_fonts.push(fam);
|
||||
let composite = d.styles.composite_fonts.iter().find(|f| f.name == fam.trim_start_matches("CompositeFont/"));
|
||||
let families: Vec<&str> = composite.map_or_else(|| vec![fam.as_str()], |f| f.entries.iter().map(|e| e.family.as_str()).collect());
|
||||
for family in families {
|
||||
if !db.has_family(family) && !missing_fonts.iter().any(|f| f == family) {
|
||||
missing_fonts.push(family.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +105,9 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
|
||||
for f in missing_fonts {
|
||||
out.push(Issue { severity: "error", kind: "missingFont", message: format!("Missing font: {f}"), item: None, page: None });
|
||||
}
|
||||
for message in unsupported_typography {
|
||||
out.push(Issue { severity: "warning", kind: "unsupportedTypography", message, item: None, page: None });
|
||||
}
|
||||
// Items.
|
||||
for (si, sp) in d.spreads.iter().enumerate() {
|
||||
for top in &sp.items {
|
||||
@@ -159,3 +203,25 @@ mod placed_vector_tests {
|
||||
assert_eq!(low, ["photo.png: effective 10 ppi (< 150)"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod arabic_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn arabic_unsupported_presets_are_reported_without_flagging_supported_offsets() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let text = "بُسْمِ";
|
||||
let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": text})).unwrap();
|
||||
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": text.len()})).unwrap();
|
||||
s.execute("type.char", &json!({"attrs": {"diacriticPosition": "openType", "diacriticXOffset": 150}})).unwrap();
|
||||
assert!(!check(&s, 150.0).iter().any(|i| i.kind == "unsupportedTypography"));
|
||||
s.execute("type.para", &json!({"attrs": {"arabicJustification": "NaskhJustification", "paragraphKashidaWidth": 2}})).unwrap();
|
||||
s.execute("type.char", &json!({"attrs": {"diacriticPosition": "tight"}})).unwrap();
|
||||
let issues = check(&s, 150.0);
|
||||
let typography: Vec<_> = issues.iter().filter(|i| i.kind == "unsupportedTypography").collect();
|
||||
assert_eq!(typography.len(), 3, "{typography:?}");
|
||||
assert!(typography.iter().all(|i| i.severity == "warning"));
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,43 @@ use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "style.compositeFont.list", "List Composite Fonts", [], None, "{} → composite font definitions", has_doc, |s, _| {
|
||||
serde_json::to_value(&s.doc()?.doc.styles.composite_fonts).map_err(|e| bad("style.compositeFont.list", e.to_string()))
|
||||
}),
|
||||
cmd!(
|
||||
"style.compositeFont.set",
|
||||
"Set Composite Font",
|
||||
[],
|
||||
None,
|
||||
"{name, entries: [{name?, characters?, family, style?, relativeSize?, horizontalScale?, verticalScale?, baselineShift?, scaleOption?}]} — replaces a composite font definition; empty characters selects the base entry",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let f: designcraft_doc::cjk::CompositeFont =
|
||||
serde_json::from_value(p.clone()).map_err(|e| bad("style.compositeFont.set", e.to_string()))?;
|
||||
if f.name.trim().is_empty()
|
||||
|| f.entries.is_empty()
|
||||
|| f.entries.len() > 1024
|
||||
|| !f.entries.iter().any(|e| e.characters.is_empty())
|
||||
|| f.entries.iter().any(|e| {
|
||||
e.family.trim().is_empty()
|
||||
|| ![e.relative_size, e.horizontal_scale, e.vertical_scale].iter().all(|v| v.is_finite() && *v > 0.0 && *v <= 100.0)
|
||||
|| !e.baseline_shift.is_finite()
|
||||
|| e.baseline_shift.abs() > 100.0
|
||||
})
|
||||
{
|
||||
return Err(bad("style.compositeFont.set", "invalid name, base entry, font, scale or baseline shift"));
|
||||
}
|
||||
s.edit(|d, _| {
|
||||
let fonts = &mut d.styles_mut().composite_fonts;
|
||||
if let Some(old) = fonts.iter_mut().find(|old| old.name == f.name) {
|
||||
*old = f.clone();
|
||||
} else {
|
||||
fonts.push(f.clone());
|
||||
}
|
||||
Ok(json!({"name": f.name}))
|
||||
})
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"style.exportTag",
|
||||
"Export Tagging",
|
||||
@@ -1105,3 +1142,28 @@ mod export_tag_tests {
|
||||
assert!(designcraft_render::pdf_page_count(&pdf) == Some(1));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cjk_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn cjk_composite_font_command_invalidates_layout_and_undo_restores_it() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 150], "content": "text", "text": "AB"})).unwrap();
|
||||
let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
|
||||
let definition = |scale| json!({"name": "Mixed", "entries": [{"family": "Source Serif 4", "relativeSize": scale}]});
|
||||
s.execute("style.compositeFont.set", &definition(1.0)).unwrap();
|
||||
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 2})).unwrap();
|
||||
s.execute("type.char", &json!({"fontFamily": "Mixed", "leadingAki": 0.25, "jidori": 4})).unwrap();
|
||||
let old = s.cache.get(&s.doc().unwrap().doc, sid, None);
|
||||
s.execute("style.compositeFont.set", &definition(2.0)).unwrap();
|
||||
let new = s.cache.get(&s.doc().unwrap().doc, sid, None);
|
||||
assert!(!std::sync::Arc::ptr_eq(&old, &new));
|
||||
assert!(new.frames[0].lines[0].end_x > old.frames[0].lines[0].end_x);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let restored = s.cache.get(&s.doc().unwrap().doc, sid, None);
|
||||
assert_eq!(old.frames[0].lines[0].end_x, restored.frames[0].lines[0].end_x);
|
||||
assert!(s.execute("style.compositeFont.set", &definition(-1.0)).is_err());
|
||||
}
|
||||
}
|
||||
@@ -208,7 +208,7 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
"Table Options",
|
||||
[],
|
||||
None,
|
||||
"{border?: {weight?, color?, tint?, type?}, spaceBefore?, spaceAfter?, headerRows?, footerRows?, repeatHeader?, repeatFooter?, altRows?: {first, firstColor, firstTint, next, nextColor, nextTint, skipFirst, skipLast} | null, altCols?: … | null}",
|
||||
"{direction?: leftToRight|rightToLeft, border?: {weight?, color?, tint?, type?}, spaceBefore?, spaceAfter?, headerRows?, footerRows?, repeatHeader?, repeatFooter?, altRows?: {first, firstColor, firstTint, next, nextColor, nextTint, skipFirst, skipLast} | null, altCols?: … | null}",
|
||||
in_table,
|
||||
options
|
||||
),
|
||||
@@ -506,8 +506,14 @@ fn shift_selection(s: &mut Session, table: u64, at: usize, n: usize, rows: bool)
|
||||
fn insert_col(s: &mut Session, p: &Value, left: Option<bool>) -> Result<Value> {
|
||||
let g = target(s, p, "table.insertColumn")?;
|
||||
let left = left.unwrap_or(str_param(p, "where") == Some("left"));
|
||||
let rtl = s
|
||||
.doc()?
|
||||
.doc
|
||||
.story(g.story)
|
||||
.and_then(|st| st.tables.get(&g.table))
|
||||
.is_some_and(|t| t.options.direction == designcraft_doc::TextDirection::RightToLeft);
|
||||
let n = count(p);
|
||||
let at = if left { g.range.c0 } else { g.range.c1 + 1 };
|
||||
let at = if left != rtl { g.range.c0 } else { g.range.c1 + 1 };
|
||||
let width = p.get("width").and_then(Value::as_f64);
|
||||
let r = edit_table(s, &g, "table.insertColumn", |t| {
|
||||
let w = width.unwrap_or_else(|| t.columns[g.range.c0.min(t.ncols() - 1)].width);
|
||||
@@ -758,6 +764,9 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let p = p.clone();
|
||||
edit_table(s, &g, "table.options", |t| {
|
||||
let o = &mut t.options;
|
||||
if let Some(v) = p.get("direction") {
|
||||
o.direction = serde_json::from_value(v.clone()).map_err(|e| bad("table.options", e.to_string()))?;
|
||||
}
|
||||
if let Some(b) = p.get("border") {
|
||||
o.border = parse_stroke(b, o.border.clone());
|
||||
}
|
||||
@@ -1402,3 +1411,28 @@ mod sort_tests {
|
||||
assert_eq!((cell(&s, 2, 0), cell(&s, 2, 1)), ("x".to_string(), "a".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod arabic_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn arabic_table_direction_and_physical_left_insertion_support_undo() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [0, 0, 400, 300], "content": "text", "text": ""})).unwrap();
|
||||
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
|
||||
s.execute("table.insert", &json!({"rows": 1, "cols": 2})).unwrap();
|
||||
s.execute("table.setCell", &json!({"rows": [0, 0], "cols": [0, 0], "text": "Right"})).unwrap();
|
||||
s.execute("table.options", &json!({"direction": "rightToLeft"})).unwrap();
|
||||
s.execute("table.select", &json!({"rows": [0, 0], "cols": [0, 0]})).unwrap();
|
||||
s.execute("table.insertColumnLeft", &json!({})).unwrap();
|
||||
let t = s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap();
|
||||
assert_eq!(t.ncols(), 3);
|
||||
assert_eq!(t.cell(0, 0).unwrap().text.text, "Right");
|
||||
assert!(t.cell(0, 1).unwrap().text.text.is_empty());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let t = s.doc().unwrap().doc.stories.values().flat_map(|st| st.tables.values()).next().unwrap();
|
||||
assert_eq!(t.ncols(), 2);
|
||||
assert_eq!(t.options.direction, designcraft_doc::TextDirection::RightToLeft);
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,26 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
st.revision += 1;
|
||||
ok()
|
||||
}),
|
||||
cmd!(
|
||||
"story.setDirection",
|
||||
"Story Column Direction",
|
||||
["Type", "Story Column Direction"],
|
||||
None,
|
||||
"{story?, direction: leftToRight|rightToLeft} — column progression, independent of paragraph direction",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let sid = story_of(s, p).ok_or_else(|| bad("story.setDirection", "no story"))?;
|
||||
let direction: designcraft_doc::TextDirection =
|
||||
serde_json::from_value(p.get("direction").cloned().ok_or_else(|| bad("story.setDirection", "missing direction"))?)
|
||||
.map_err(|e| bad("story.setDirection", e.to_string()))?;
|
||||
s.edit(|d, _| {
|
||||
let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
|
||||
st.direction = direction;
|
||||
st.rev = st.rev.wrapping_add(1);
|
||||
ok()
|
||||
})
|
||||
}
|
||||
),
|
||||
cmd!("story.setText", "Set Story Text", [], None, "{story, text} — replace a story's whole text", has_doc, |s, p| {
|
||||
let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.setText", "missing story"))?);
|
||||
let text = str_param(p, "text").unwrap_or("").to_string();
|
||||
@@ -1323,3 +1343,23 @@ mod bidi_caret_tests {
|
||||
assert!(p >= "سلام".len() as u64, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod arabic_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn arabic_story_direction_command_validates_and_supports_undo() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "abc"})).unwrap();
|
||||
let sid = StoryId(r["story"].as_u64().unwrap());
|
||||
let before = s.cache.get(&s.doc().unwrap().doc, sid, None);
|
||||
s.execute("story.setDirection", &json!({"story": sid.0, "direction": "rightToLeft"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().direction, designcraft_doc::TextDirection::RightToLeft);
|
||||
let after = s.cache.get(&s.doc().unwrap().doc, sid, None);
|
||||
assert!(!std::sync::Arc::ptr_eq(&before, &after));
|
||||
assert!(s.execute("story.setDirection", &json!({"story": sid.0, "direction": "unknown"})).is_err());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().direction, designcraft_doc::TextDirection::LeftToRight);
|
||||
}
|
||||
}
|
||||
@@ -13,4 +13,5 @@ kurbo = { workspace = true }
|
||||
skrifa = { workspace = true }
|
||||
harfrust = { workspace = true }
|
||||
unicode-bidi = { workspace = true }
|
||||
unicode-script = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -204,6 +204,15 @@ impl FontFace {
|
||||
pub fn glyph_for(&self, c: char) -> u32 {
|
||||
self.skrifa().and_then(|f| f.charmap().map(c)).map(|g| g.to_u32()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// GDEF identifies marks even when shaping merges their source cluster with a base.
|
||||
pub fn glyph_is_mark(&self, gid: u32) -> bool {
|
||||
use skrifa::raw::TableProvider;
|
||||
self.skrifa()
|
||||
.and_then(|f| f.gdef().ok())
|
||||
.and_then(|gdef| gdef.glyph_class_def()?.ok())
|
||||
.is_some_and(|classes| classes.get(GlyphId::new(gid)) == 3)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
+102
-26
@@ -61,6 +61,9 @@ pub struct ShapedGlyph {
|
||||
pub x_advance: i32,
|
||||
pub x_offset: i32,
|
||||
pub y_offset: i32,
|
||||
pub safe_tatweel_before: bool,
|
||||
/// Direction used for shaping, including Unicode mirroring.
|
||||
pub rtl: bool,
|
||||
}
|
||||
|
||||
/// An OpenType feature setting: `"liga"`, `"-kern"`, `"ss01"`.
|
||||
@@ -82,44 +85,76 @@ pub fn is_rtl(c: char) -> bool {
|
||||
matches!(unicode_bidi::bidi_class(c), R | AL)
|
||||
}
|
||||
|
||||
/// Maximal runs of one direction (neutrals join the run they're in): (byte range, right-to-left).
|
||||
fn direction_runs(text: &str) -> Vec<(std::ops::Range<usize>, bool)> {
|
||||
let mut runs: Vec<(std::ops::Range<usize>, bool)> = Vec::new();
|
||||
let mut cur: Option<bool> = None;
|
||||
/// Resolve directional and script runs before choosing the OpenType shaper.
|
||||
fn direction_runs(text: &str) -> Vec<(std::ops::Range<usize>, bool, unicode_script::Script)> {
|
||||
use unicode_script::{Script, UnicodeScript};
|
||||
let info = unicode_bidi::BidiInfo::new(text, None);
|
||||
let mut runs = Vec::new();
|
||||
let mut script =
|
||||
text.chars().map(|c| c.script()).find(|s| !matches!(s, Script::Common | Script::Inherited | Script::Unknown)).unwrap_or(Script::Common);
|
||||
let mut start = 0;
|
||||
let mut rtl = info.levels.first().is_some_and(|l| l.is_rtl());
|
||||
for (i, c) in text.char_indices() {
|
||||
let strong = if is_rtl(c) {
|
||||
Some(true)
|
||||
} else if unicode_bidi::bidi_class(c) == unicode_bidi::BidiClass::L {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
let next_rtl = info.levels.get(i).is_some_and(|l| l.is_rtl());
|
||||
let next_script = match c.script() {
|
||||
Script::Common | Script::Inherited | Script::Unknown => script,
|
||||
s => s,
|
||||
};
|
||||
match (cur, strong) {
|
||||
(None, Some(d)) => cur = Some(d),
|
||||
(Some(a), Some(d)) if a != d => {
|
||||
runs.push((start..i, a));
|
||||
start = i;
|
||||
cur = Some(d);
|
||||
}
|
||||
_ => {}
|
||||
if i > start && (next_rtl != rtl || next_script != script) {
|
||||
runs.push((start..i, rtl, script));
|
||||
start = i;
|
||||
}
|
||||
rtl = next_rtl;
|
||||
script = next_script;
|
||||
}
|
||||
if start < text.len() {
|
||||
runs.push((start..text.len(), rtl, script));
|
||||
}
|
||||
runs.push((start..text.len(), cur.unwrap_or(false)));
|
||||
runs
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct ShapeContext<'a> {
|
||||
pub before: &'a str,
|
||||
pub after: &'a str,
|
||||
pub language: &'a str,
|
||||
}
|
||||
|
||||
/// Normalize the supported application language names while also accepting BCP 47 tags.
|
||||
pub fn language_tag(language: &str) -> &str {
|
||||
match language.split(':').next().unwrap_or(language).trim() {
|
||||
"Arabic" => "ar",
|
||||
"Persian" | "Farsi" => "fa",
|
||||
"Urdu" => "ur",
|
||||
"Hebrew" => "he",
|
||||
"English" => "en",
|
||||
_ => language,
|
||||
}
|
||||
}
|
||||
|
||||
/// Shape `text` with `face`. `chars` lets callers substitute characters (e.g. uppercase for All
|
||||
/// Caps) while keeping clusters pointing into the original string. Glyphs come in logical
|
||||
/// order: right-to-left runs are shaped right to left, then their clusters put back in text
|
||||
/// order (each cluster's glyphs keep the shaper's order); line layout reorders them visually.
|
||||
pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
|
||||
if !text.chars().any(is_rtl) {
|
||||
return shape_dir(face, text, features, &map, false);
|
||||
}
|
||||
shape_with_context(face, text, features, map, ShapeContext::default())
|
||||
}
|
||||
|
||||
pub fn shape_with_context(
|
||||
face: &FontFace,
|
||||
text: &str,
|
||||
features: &[Feature],
|
||||
map: impl Fn(char) -> char,
|
||||
context: ShapeContext<'_>,
|
||||
) -> Vec<ShapedGlyph> {
|
||||
let mut out = Vec::with_capacity(text.len());
|
||||
for (r, rtl) in direction_runs(text) {
|
||||
let mut g = shape_dir(face, &text[r.clone()], features, &map, rtl);
|
||||
for (r, rtl, script) in direction_runs(text) {
|
||||
let local = ShapeContext {
|
||||
before: if r.start == 0 { context.before } else { &text[..r.start] },
|
||||
after: if r.end == text.len() { context.after } else { &text[r.end..] },
|
||||
language: context.language,
|
||||
};
|
||||
let mut g = shape_dir(face, &text[r.clone()], features, &map, rtl, script, local);
|
||||
for x in &mut g {
|
||||
x.cluster += r.start;
|
||||
}
|
||||
@@ -140,7 +175,15 @@ pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(cha
|
||||
out
|
||||
}
|
||||
|
||||
fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(char) -> char, rtl: bool) -> Vec<ShapedGlyph> {
|
||||
fn shape_dir(
|
||||
face: &FontFace,
|
||||
text: &str,
|
||||
features: &[Feature],
|
||||
map: &impl Fn(char) -> char,
|
||||
rtl: bool,
|
||||
script: unicode_script::Script,
|
||||
context: ShapeContext<'_>,
|
||||
) -> Vec<ShapedGlyph> {
|
||||
let mut out = Vec::with_capacity(text.len());
|
||||
let shaped = face.hb().map(|hb| {
|
||||
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
||||
@@ -148,6 +191,15 @@ fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(ch
|
||||
for (i, c) in text.char_indices() {
|
||||
buf.add(map(c), i as u32);
|
||||
}
|
||||
buf.set_pre_context(context.before);
|
||||
buf.set_post_context(context.after);
|
||||
if let Ok(script) = script.short_name().parse() {
|
||||
buf.set_script(script);
|
||||
}
|
||||
if let Ok(language) = language_tag(context.language).parse() {
|
||||
buf.set_language(language);
|
||||
}
|
||||
buf.set_flags(harfrust::BufferFlags::PRODUCE_SAFE_TO_INSERT_TATWEEL);
|
||||
buf.guess_segment_properties();
|
||||
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(features));
|
||||
@@ -158,6 +210,8 @@ fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(ch
|
||||
x_advance: pos.x_advance,
|
||||
x_offset: pos.x_offset,
|
||||
y_offset: pos.y_offset,
|
||||
safe_tatweel_before: info.safe_to_insert_tatweel(),
|
||||
rtl,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -169,7 +223,15 @@ fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(ch
|
||||
for (i, c) in text.char_indices() {
|
||||
let g = cmap.map(map(c)).unwrap_or_default();
|
||||
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
|
||||
out.push(ShapedGlyph { gid: g.to_u32(), cluster: i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 });
|
||||
out.push(ShapedGlyph {
|
||||
gid: g.to_u32(),
|
||||
cluster: i,
|
||||
x_advance: adv.round() as i32,
|
||||
x_offset: 0,
|
||||
y_offset: 0,
|
||||
safe_tatweel_before: false,
|
||||
rtl: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -184,6 +246,20 @@ pub fn first_glyph(face: &FontFace, chars: &[char]) -> u32 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn arabic_script_runs_do_not_inherit_the_hebrew_shaper() {
|
||||
use unicode_script::Script;
|
||||
let text = "אב بب";
|
||||
let runs = direction_runs(text);
|
||||
assert_eq!(runs.len(), 2);
|
||||
assert_eq!(runs[0], (0..5, true, Script::Hebrew));
|
||||
assert_eq!(runs[1], (5..text.len(), true, Script::Arabic));
|
||||
assert_eq!(language_tag("Arabic"), "ar");
|
||||
assert_eq!(language_tag("Farsi"), "fa");
|
||||
assert_eq!(language_tag("Urdu"), "ur");
|
||||
assert_eq!(language_tag("ar-SA"), "ar-SA");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_resort_face_parses() {
|
||||
let f = fontdb::last_resort_face();
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
use designcraft_doc::arabic::{CharacterDirection as D, DiacriticPosition as P};
|
||||
|
||||
pub fn direction_in(s: &str) -> Option<D> {
|
||||
match s.trim() {
|
||||
"DefaultDirection" => Some(D::Default),
|
||||
"LeftToRightDirection" => Some(D::LeftToRight),
|
||||
"RightToLeftDirection" => Some(D::RightToLeft),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn direction_out(d: D) -> &'static str {
|
||||
match d {
|
||||
D::Default => "DefaultDirection",
|
||||
D::LeftToRight => "LeftToRightDirection",
|
||||
D::RightToLeft => "RightToLeftDirection",
|
||||
}
|
||||
}
|
||||
pub fn diacritic_in(s: &str) -> Option<P> {
|
||||
match s.trim() {
|
||||
"DefaultPosition" => Some(P::Default),
|
||||
"OpentypePosition" => Some(P::OpenType),
|
||||
"OpentypePositionFromBaseline" => Some(P::OpenTypeFromBaseline),
|
||||
"LoosePosition" => Some(P::Loose),
|
||||
"MediumPosition" => Some(P::Medium),
|
||||
"TightPosition" => Some(P::Tight),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn diacritic_out(p: P) -> &'static str {
|
||||
match p {
|
||||
P::Default => "DefaultPosition",
|
||||
P::OpenType => "OpentypePosition",
|
||||
P::OpenTypeFromBaseline => "OpentypePositionFromBaseline",
|
||||
P::Loose => "LoosePosition",
|
||||
P::Medium => "MediumPosition",
|
||||
P::Tight => "TightPosition",
|
||||
}
|
||||
}
|
||||
pub fn kashidas_in(s: &str) -> Option<bool> {
|
||||
match s.trim() {
|
||||
"DefaultKashidas" => Some(true),
|
||||
"KashidasOff" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! IDML enum mappings for CJK character formatting.
|
||||
use designcraft_doc::cjk::{CharacterAlignment as A, LeadingModel as L};
|
||||
pub(crate) fn leading_in(v: &str) -> Option<L> {
|
||||
Some(match v {
|
||||
"LeadingModelRoman" => L::Roman,
|
||||
"LeadingModelAkiBelow" => L::AkiBelow,
|
||||
"LeadingModelAkiAbove" => L::AkiAbove,
|
||||
"LeadingModelCenter" => L::Center,
|
||||
"LeadingModelCenterDown" => L::CenterDown,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
pub(crate) fn leading_out(v: L) -> &'static str {
|
||||
match v {
|
||||
L::Roman => "LeadingModelRoman",
|
||||
L::AkiBelow => "LeadingModelAkiBelow",
|
||||
L::AkiAbove => "LeadingModelAkiAbove",
|
||||
L::Center => "LeadingModelCenter",
|
||||
L::CenterDown => "LeadingModelCenterDown",
|
||||
}
|
||||
}
|
||||
pub(crate) fn alignment_in(v: &str) -> Option<A> {
|
||||
Some(match v {
|
||||
"AlignBaseline" => A::Baseline,
|
||||
"AlignEmTop" => A::EmTop,
|
||||
"AlignEmCenter" => A::EmCenter,
|
||||
"AlignEmBottom" => A::EmBottom,
|
||||
"AlignIcfTop" => A::IcfTop,
|
||||
"AlignIcfBottom" => A::IcfBottom,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
pub(crate) fn alignment_out(v: A) -> &'static str {
|
||||
match v {
|
||||
A::Baseline => "AlignBaseline",
|
||||
A::EmTop => "AlignEmTop",
|
||||
A::EmCenter => "AlignEmCenter",
|
||||
A::EmBottom => "AlignEmBottom",
|
||||
A::IcfTop => "AlignIcfTop",
|
||||
A::IcfBottom => "AlignIcfBottom",
|
||||
}
|
||||
}
|
||||
pub(crate) fn kenten_character(v: &str) -> Option<&'static str> {
|
||||
Some(match v {
|
||||
"None" => "",
|
||||
"KentenSesameDot" => "﹅",
|
||||
"KentenWhiteSesameDot" => "﹆",
|
||||
"KentenBlackCircle" => "●",
|
||||
"KentenWhiteCircle" => "○",
|
||||
"KentenBlackTriangle" => "▲",
|
||||
"KentenWhiteTriangle" => "△",
|
||||
"KentenBlackBullseye" => "◉",
|
||||
"KentenWhiteBullseye" => "◎",
|
||||
"KentenFisheye" => "◉",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
+165
-4
@@ -276,6 +276,49 @@ impl<'a> Ex<'a> {
|
||||
.attr("HyphenationVendor", "Hunspell")
|
||||
.attr("SpellingVendor", "Hunspell"),
|
||||
);
|
||||
let mut sets = std::collections::BTreeMap::new();
|
||||
for a in d.styles.paragraph.iter().map(|p| &p.para).chain(d.stories.values().flat_map(|s| s.paras.iter().map(|p| &p.para))) {
|
||||
if let Some(Some(k)) = &a.kinsoku
|
||||
&& !k.name.is_empty()
|
||||
{
|
||||
sets.insert(&k.name, k);
|
||||
}
|
||||
}
|
||||
for k in sets.values() {
|
||||
root.push(
|
||||
El::new("KinsokuTable")
|
||||
.attr("Self", format!("KinsokuTable/{}", escape_id(&k.name)))
|
||||
.attr("Name", &k.name)
|
||||
.attr("CantBeginLineChars", &k.no_start)
|
||||
.attr("CantEndLineChars", &k.no_end)
|
||||
.attr("CantBeSeparatedChars", &k.inseparable)
|
||||
.attr("HangingPunctuationChars", &k.hanging),
|
||||
);
|
||||
}
|
||||
for t in &d.styles.mojikumi_tables {
|
||||
let mut e = El::new("MojikumiTable").attr("Self", format!("MojikumiTable/{}", escape_id(&t.name))).attr("Name", &t.name);
|
||||
if !t.based_on.is_empty() {
|
||||
e.set("BasedOnMojikumiSet", &t.based_on);
|
||||
}
|
||||
if !t.overrides.is_empty() {
|
||||
let mut list = El::new("OverrideMojikumiAkiList");
|
||||
for r in &t.overrides {
|
||||
list.push(
|
||||
El::new("OverrideMojikumiAkiType")
|
||||
.attr("TargetMojikumiClass", r.target_class)
|
||||
.attr("SideMojikumiClass", r.side_class)
|
||||
.attr("SideIsAfterTarget", bool_s(r.after))
|
||||
.attr("Minimum", num(r.minimum))
|
||||
.attr("Desired", num(r.desired))
|
||||
.attr("Maximum", num(r.maximum))
|
||||
.attr("CompressionPriority", r.priority)
|
||||
.attr("AkiDoesNotFloat", bool_s(r.does_not_float)),
|
||||
);
|
||||
}
|
||||
e.push(El::new("Properties").child(list));
|
||||
}
|
||||
root.push(e);
|
||||
}
|
||||
root.push(El::new("idPkg:Graphic").attr("src", "Resources/Graphic.xml"));
|
||||
root.push(El::new("idPkg:Fonts").attr("src", "Resources/Fonts.xml"));
|
||||
root.push(El::new("idPkg:Styles").attr("src", "Resources/Styles.xml"));
|
||||
@@ -619,6 +662,26 @@ impl<'a> Ex<'a> {
|
||||
|
||||
fn fonts_part(&mut self) -> Vec<u8> {
|
||||
let mut root = pkg_root("Fonts");
|
||||
for f in &self.d.styles.composite_fonts {
|
||||
let mut el = El::new("CompositeFont").attr("Self", format!("CompositeFont/{}", escape_id(&f.name))).attr("Name", &f.name);
|
||||
for (i, e) in f.entries.iter().enumerate() {
|
||||
self.note_font(&e.family, &e.style);
|
||||
el.push(
|
||||
El::new("CompositeFontEntry")
|
||||
.attr("Self", format!("dcComposite{}_{i}", escape_id(&f.name)))
|
||||
.attr("Name", &e.name)
|
||||
.attr("CustomCharacters", &e.characters)
|
||||
.attr("FontStyle", &e.style)
|
||||
.attr("RelativeSize", num(e.relative_size * 100.0))
|
||||
.attr("HorizontalScale", num(e.horizontal_scale * 100.0))
|
||||
.attr("VerticalScale", num(e.vertical_scale * 100.0))
|
||||
.attr("BaselineShift", num(e.baseline_shift * 100.0))
|
||||
.attr("ScaleOption", bool_s(e.scale_option))
|
||||
.child(El::new("Properties").child(p("AppliedFont", "string", &e.family))),
|
||||
);
|
||||
}
|
||||
root.push(el);
|
||||
}
|
||||
let fonts = std::mem::take(&mut self.fonts);
|
||||
for (fam, styles) in &fonts {
|
||||
let fid = format!("di{:x}", {
|
||||
@@ -939,6 +1002,24 @@ impl<'a> Ex<'a> {
|
||||
}
|
||||
|
||||
fn char_attrs(&mut self, el: &mut El, props: &mut Vec<El>, a: &CharAttrs) {
|
||||
if let Some(v) = a.leading_aki {
|
||||
el.set("LeadingAki", num(v.unwrap_or(-1.0)));
|
||||
}
|
||||
if let Some(v) = a.trailing_aki {
|
||||
el.set("TrailingAki", num(v.unwrap_or(-1.0)));
|
||||
}
|
||||
if let Some(v) = a.tsume {
|
||||
el.set("Tsume", num(v * 100.0));
|
||||
}
|
||||
if let Some(v) = a.jidori {
|
||||
el.set("Jidori", v);
|
||||
}
|
||||
if let Some(v) = a.leading_model {
|
||||
el.set("LeadingModel", crate::cjk::leading_out(v));
|
||||
}
|
||||
if let Some(v) = a.character_alignment {
|
||||
el.set("CharacterAlignment", crate::cjk::alignment_out(v));
|
||||
}
|
||||
if let Some(f) = &a.font_family {
|
||||
props.push(p("AppliedFont", "string", f.clone()));
|
||||
let style = a.font_style.clone().unwrap_or_else(|| "Regular".into());
|
||||
@@ -1034,19 +1115,52 @@ impl<'a> Ex<'a> {
|
||||
}
|
||||
el.set("OTFStylisticSets", designcraft_doc::otf::stylistic_sets(list).to_string());
|
||||
}
|
||||
if let Some(v) = &a.glyph_form {
|
||||
el.set("GlyphForm", v);
|
||||
}
|
||||
if let Some(v) = a.no_break {
|
||||
el.set("NoBreak", bool_s(v));
|
||||
}
|
||||
if let Some(v) = a.digits {
|
||||
el.set("DigitsType", names::digits_out(v));
|
||||
}
|
||||
if let Some(r) = a.ruby.as_ref().filter(|r| !r.is_empty()) {
|
||||
el.set("RubyFlag", "true");
|
||||
if let Some(v) = a.character_direction {
|
||||
el.set("CharacterDirection", crate::arabic::direction_out(v));
|
||||
}
|
||||
if let Some(v) = a.allow_kashidas {
|
||||
el.set("Kashidas", if v { "DefaultKashidas" } else { "KashidasOff" });
|
||||
}
|
||||
if let Some(v) = a.diacritic_position {
|
||||
el.set("DiacriticPosition", crate::arabic::diacritic_out(v));
|
||||
}
|
||||
if let Some(v) = a.diacritic_x_offset {
|
||||
el.set("XOffsetDiacritic", num(v));
|
||||
}
|
||||
if let Some(v) = a.diacritic_y_offset {
|
||||
el.set("YOffsetDiacritic", num(v));
|
||||
}
|
||||
if let Some(v) = &a.positional_form {
|
||||
el.set("PositionalForm", v);
|
||||
}
|
||||
if let Some(r) = a.ruby.as_ref() {
|
||||
el.set("RubyFlag", bool_s(!r.is_empty()));
|
||||
el.set("RubyString", r.as_str());
|
||||
}
|
||||
if let Some(k) = a.kenten {
|
||||
el.set("KentenKind", if k { "KentenSesameDot" } else { "None" });
|
||||
}
|
||||
if let Some(c) = &a.kenten_character
|
||||
&& !c.is_empty()
|
||||
{
|
||||
el.set("KentenKind", "Custom");
|
||||
el.set("KentenCustomCharacter", c);
|
||||
}
|
||||
if let Some(v) = a.tate_chu_yoko_x_offset {
|
||||
el.set("TatechuyokoXOffset", num(v));
|
||||
}
|
||||
if let Some(v) = a.tate_chu_yoko_y_offset {
|
||||
el.set("TatechuyokoYOffset", num(v));
|
||||
}
|
||||
if let Some(v) = a.tate_chu_yoko {
|
||||
el.set("Tatechuyoko", bool_s(v));
|
||||
}
|
||||
@@ -1059,6 +1173,38 @@ impl<'a> Ex<'a> {
|
||||
}
|
||||
|
||||
fn para_attrs(&mut self, el: &mut El, props: &mut Vec<El>, a: &ParaAttrs) {
|
||||
if let Some(v) = &a.mojikumi {
|
||||
props.push(p("Mojikumi", if v.starts_with("MojikumiTable/") { "object" } else { "enumeration" }, v));
|
||||
}
|
||||
if let Some(v) = &a.kinsoku_type {
|
||||
el.set("KinsokuType", v);
|
||||
}
|
||||
if let Some(v) = a.kinsoku_hang {
|
||||
el.set(
|
||||
"KinsokuHangType",
|
||||
match v {
|
||||
designcraft_doc::cjk::KinsokuHang::None => "None",
|
||||
designcraft_doc::cjk::KinsokuHang::Regular => "KinsokuHangRegular",
|
||||
designcraft_doc::cjk::KinsokuHang::Force => "KinsokuHangForce",
|
||||
},
|
||||
);
|
||||
}
|
||||
if let Some(v) = a.bunri_kinshi {
|
||||
el.set("BunriKinshi", bool_s(v));
|
||||
}
|
||||
if let Some(v) = a.rensuuji {
|
||||
el.set("Rensuuji", bool_s(v));
|
||||
}
|
||||
if let Some(v) = a.treat_ideographic_space_as_space {
|
||||
el.set("TreatIdeographicSpaceAsSpace", bool_s(v));
|
||||
}
|
||||
if let Some(Some(k)) = &a.kinsoku {
|
||||
props.push(p(
|
||||
"KinsokuSet",
|
||||
if k.name.is_empty() { "enumeration" } else { "object" },
|
||||
if k.name.is_empty() { "Nothing".into() } else { format!("KinsokuTable/{}", escape_id(&k.name)) },
|
||||
));
|
||||
}
|
||||
if let Some(n) = &a.list_name {
|
||||
el.set(
|
||||
"AppliedNumberingList",
|
||||
@@ -1142,6 +1288,15 @@ impl<'a> Ex<'a> {
|
||||
if v == designcraft_doc::TextDirection::RightToLeft { "RightToLeftDirection" } else { "LeftToRightDirection" },
|
||||
);
|
||||
}
|
||||
if let Some(v) = a.kashidas {
|
||||
el.set("Kashidas", if v { "DefaultKashidas" } else { "KashidasOff" });
|
||||
}
|
||||
if let Some(v) = &a.arabic_justification {
|
||||
el.set("ParagraphJustification", v);
|
||||
}
|
||||
if let Some(Some(v)) = a.paragraph_kashida_width {
|
||||
el.set("ParagraphKashidaWidth", num(v));
|
||||
}
|
||||
n!(left_indent, "LeftIndent");
|
||||
n!(right_indent, "RightIndent");
|
||||
n!(first_line_indent, "FirstLineIndent");
|
||||
@@ -1690,7 +1845,10 @@ impl<'a> Ex<'a> {
|
||||
.attr("OpticalMarginSize", "12")
|
||||
.attr("FrameType", "TextFrameType")
|
||||
.attr("StoryOrientation", if vertical { "Vertical" } else { "Horizontal" })
|
||||
.attr("StoryDirection", "LeftToRightDirection"),
|
||||
.attr(
|
||||
"StoryDirection",
|
||||
if s.direction == designcraft_doc::TextDirection::RightToLeft { "RightToLeftDirection" } else { "LeftToRightDirection" },
|
||||
),
|
||||
);
|
||||
for psr in self.story_paras(s) {
|
||||
el.push(psr);
|
||||
@@ -1968,7 +2126,10 @@ impl<'a> Ex<'a> {
|
||||
"AppliedTableStyle",
|
||||
names::style_self("TableStyle", names::TABLE_BUILTINS, if t.style.is_empty() { designcraft_doc::BASIC_TABLE } else { &t.style }),
|
||||
)
|
||||
.attr("TableDirection", "LeftToRightDirection")
|
||||
.attr(
|
||||
"TableDirection",
|
||||
if t.options.direction == designcraft_doc::TextDirection::RightToLeft { "RightToLeftDirection" } else { "LeftToRightDirection" },
|
||||
)
|
||||
.attr("SpaceBefore", num(t.options.space_before))
|
||||
.attr("SpaceAfter", num(t.options.space_after))
|
||||
.attr("HeaderBehavior", if t.options.repeat_header { "RepeatOnEachTextColumn" } else { "RepeatOnce" })
|
||||
|
||||
+122
-11
@@ -104,6 +104,7 @@ struct Importer<'r> {
|
||||
inks: designcraft_doc::InkManager,
|
||||
stroke_styles: Vec<designcraft_doc::StrokeStyleDef>,
|
||||
styles: Styles,
|
||||
kinsoku: HashMap<String, designcraft_doc::cjk::Kinsoku>,
|
||||
para_names: HashMap<String, String>,
|
||||
char_names: HashMap<String, String>,
|
||||
lists: Vec<designcraft_doc::NumberedList>,
|
||||
@@ -189,6 +190,7 @@ impl<'r> Importer<'r> {
|
||||
inks: Default::default(),
|
||||
stroke_styles: Vec::new(),
|
||||
styles,
|
||||
kinsoku: HashMap::new(),
|
||||
para_names: HashMap::new(),
|
||||
char_names: HashMap::new(),
|
||||
lists: Vec::new(),
|
||||
@@ -273,6 +275,61 @@ impl<'r> Importer<'r> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for e in top.iter().filter(|e| e.local() == "CompositeFont") {
|
||||
let entries = e
|
||||
.find_all("CompositeFontEntry")
|
||||
.map(|c| designcraft_doc::cjk::CompositeFontEntry {
|
||||
name: c.get("Name").unwrap_or("").into(),
|
||||
characters: c.get("CustomCharacters").unwrap_or("").into(),
|
||||
family: c.prop("AppliedFont").unwrap_or_default(),
|
||||
style: c.get("FontStyle").unwrap_or("Regular").trim_start_matches("$ID/").into(),
|
||||
relative_size: c.num("RelativeSize").unwrap_or(100.0) / 100.0,
|
||||
horizontal_scale: c.num("HorizontalScale").unwrap_or(100.0) / 100.0,
|
||||
vertical_scale: c.num("VerticalScale").unwrap_or(100.0) / 100.0,
|
||||
baseline_shift: c.num("BaselineShift").unwrap_or(0.0) / 100.0,
|
||||
scale_option: c.boolean("ScaleOption").unwrap_or(true),
|
||||
})
|
||||
.collect();
|
||||
self.styles.composite_fonts.push(designcraft_doc::cjk::CompositeFont { name: unescape_id(e.get("Name").unwrap_or("")), entries });
|
||||
}
|
||||
for e in top.iter().filter(|e| e.local() == "KinsokuTable") {
|
||||
if e.get("CantBeginLineChars").is_some() || e.get("CantEndLineChars").is_some() {
|
||||
self.kinsoku.insert(
|
||||
e.get("Self").unwrap_or("").into(),
|
||||
designcraft_doc::cjk::Kinsoku {
|
||||
name: e.get("Name").unwrap_or("").into(),
|
||||
no_start: e.get("CantBeginLineChars").unwrap_or("").into(),
|
||||
no_end: e.get("CantEndLineChars").unwrap_or("").into(),
|
||||
inseparable: e.get("CantBeSeparatedChars").unwrap_or("").into(),
|
||||
hanging: e.get("HangingPunctuationChars").unwrap_or("").into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
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();
|
||||
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(),
|
||||
overrides,
|
||||
});
|
||||
}
|
||||
self.graphics(top);
|
||||
self.styles(top);
|
||||
if let Some(e) = top.iter().find(|e| e.local() == "FootnoteOption") {
|
||||
@@ -1005,16 +1062,24 @@ impl<'r> Importer<'r> {
|
||||
a.font_style = e.prop("FontStyle");
|
||||
a.size = e.num("PointSize");
|
||||
a.leading = e.prop("Leading").and_then(|l| names::leading_in(l.trim()));
|
||||
if let Some(v) = e.num("KerningValue") {
|
||||
a.kerning = Some(Kerning::Manual(v));
|
||||
} else if let Some(k) = e.prop("KerningMethod") {
|
||||
a.kerning = Some(match k.trim().trim_start_matches("$ID/") {
|
||||
"Optical" => Kerning::Optical,
|
||||
"None" => Kerning::None,
|
||||
_ => Kerning::Metrics,
|
||||
});
|
||||
// Automatic kerning may carry an inactive numeric value (including the
|
||||
// 1e11 IDML sentinel). It must not become a multi-million-em advance.
|
||||
a.kerning = e.prop("KerningMethod").and_then(|k| match k.trim().trim_start_matches("$ID/") {
|
||||
"Optical" => Some(Kerning::Optical),
|
||||
"Metrics" | "Metrics - Roman Only" => Some(Kerning::Metrics),
|
||||
"None" => Some(Kerning::None),
|
||||
_ => None,
|
||||
});
|
||||
if a.kerning.is_none() {
|
||||
a.kerning = e.num("KerningValue").filter(|v| v.is_finite() && v.abs() < 1e10).map(Kerning::Manual);
|
||||
}
|
||||
a.tracking = e.num("Tracking");
|
||||
a.leading_aki = e.num("LeadingAki").map(|v| (v >= 0.0).then_some(v));
|
||||
a.trailing_aki = e.num("TrailingAki").map(|v| (v >= 0.0).then_some(v));
|
||||
a.tsume = e.num("Tsume").map(|v| (v / 100.0).clamp(0.0, 1.0));
|
||||
a.jidori = e.num("Jidori").map(|v| v.clamp(0.0, 10000.0) as u32);
|
||||
a.leading_model = e.prop("LeadingModel").and_then(|v| crate::cjk::leading_in(&v));
|
||||
a.character_alignment = e.prop("CharacterAlignment").and_then(|v| crate::cjk::alignment_in(&v));
|
||||
a.h_scale = e.num("HorizontalScale").map(|v| v / 100.0);
|
||||
a.v_scale = e.num("VerticalScale").map(|v| v / 100.0);
|
||||
a.baseline_shift = e.num("BaselineShift");
|
||||
@@ -1072,15 +1137,28 @@ impl<'r> Importer<'r> {
|
||||
if any {
|
||||
a.otf_features = Some(otf_list);
|
||||
}
|
||||
a.glyph_form = e.prop("GlyphForm");
|
||||
a.no_break = e.boolean("NoBreak");
|
||||
a.tate_chu_yoko = e.boolean("Tatechuyoko");
|
||||
if e.boolean("RubyFlag") == Some(true) {
|
||||
a.ruby = e.get("RubyString").map(str::to_string);
|
||||
a.tate_chu_yoko_x_offset = e.num("TatechuyokoXOffset");
|
||||
a.tate_chu_yoko_y_offset = e.num("TatechuyokoYOffset");
|
||||
if let Some(on) = e.boolean("RubyFlag") {
|
||||
a.ruby = Some(if on { e.prop("RubyString").unwrap_or_default() } else { String::new() });
|
||||
}
|
||||
if let Some(k) = e.get("KentenKind") {
|
||||
a.kenten = Some(k != "None");
|
||||
a.kenten_character = crate::cjk::kenten_character(k).map(str::to_string);
|
||||
if k == "Custom" {
|
||||
a.kenten_character = e.prop("KentenCustomCharacter");
|
||||
}
|
||||
}
|
||||
a.digits = e.get("DigitsType").and_then(names::digits_in);
|
||||
a.digits = e.prop("DigitsType").and_then(|s| names::digits_in(s.trim()));
|
||||
a.character_direction = e.prop("CharacterDirection").and_then(|s| crate::arabic::direction_in(&s));
|
||||
a.allow_kashidas = e.prop("Kashidas").and_then(|s| crate::arabic::kashidas_in(&s));
|
||||
a.diacritic_position = e.prop("DiacriticPosition").and_then(|s| crate::arabic::diacritic_in(&s));
|
||||
a.diacritic_x_offset = e.num("XOffsetDiacritic").filter(|v| v.is_finite());
|
||||
a.diacritic_y_offset = e.num("YOffsetDiacritic").filter(|v| v.is_finite());
|
||||
a.positional_form = e.prop("PositionalForm");
|
||||
if let Some(v) = e.get("AppliedConditions").filter(|v| !v.trim().is_empty()) {
|
||||
let names: Vec<String> = v.split_whitespace().filter_map(|r| self.condition_names.get(r).cloned()).collect();
|
||||
if !names.is_empty() {
|
||||
@@ -1096,9 +1174,32 @@ impl<'r> Importer<'r> {
|
||||
let u = |k: &str| e.num(k).map(|v| v.max(0.0) as u32);
|
||||
let frac = |k: &str| e.num(k).map(|v| v / 100.0);
|
||||
a.align = e.prop("Justification").and_then(|v| names::align_in(v.trim()));
|
||||
a.kinsoku_hang = e.prop("KinsokuHangType").and_then(|v| match v.as_str() {
|
||||
"None" => Some(designcraft_doc::cjk::KinsokuHang::None),
|
||||
"KinsokuHangRegular" => Some(designcraft_doc::cjk::KinsokuHang::Regular),
|
||||
"KinsokuHangForce" => Some(designcraft_doc::cjk::KinsokuHang::Force),
|
||||
_ => None,
|
||||
});
|
||||
a.mojikumi = e.prop("Mojikumi");
|
||||
a.kinsoku_type = e.prop("KinsokuType");
|
||||
a.bunri_kinshi = e.boolean("BunriKinshi");
|
||||
a.rensuuji = e.boolean("Rensuuji");
|
||||
a.treat_ideographic_space_as_space = e.boolean("TreatIdeographicSpaceAsSpace");
|
||||
if let Some(k) = e.prop("KinsokuSet") {
|
||||
if k == "Nothing" || k == "None" {
|
||||
a.kinsoku = Some(Some(Default::default()));
|
||||
} else if let Some(set) = self.kinsoku.get(&k) {
|
||||
a.kinsoku = Some(Some(set.clone()));
|
||||
} else if let Some(set) = designcraft_doc::cjk::Kinsoku::named(&k) {
|
||||
a.kinsoku = Some(Some(set));
|
||||
}
|
||||
}
|
||||
a.direction = e.prop("ParagraphDirection").map(|v| {
|
||||
if v.trim() == "RightToLeftDirection" { designcraft_doc::TextDirection::RightToLeft } else { designcraft_doc::TextDirection::LeftToRight }
|
||||
});
|
||||
a.kashidas = e.prop("Kashidas").and_then(|s| crate::arabic::kashidas_in(&s));
|
||||
a.arabic_justification = e.prop("ParagraphJustification");
|
||||
a.paragraph_kashida_width = e.num("ParagraphKashidaWidth").filter(|v| v.is_finite()).map(Some);
|
||||
a.left_indent = e.num("LeftIndent");
|
||||
a.right_indent = e.num("RightIndent");
|
||||
a.first_line_indent = e.num("FirstLineIndent");
|
||||
@@ -1317,6 +1418,11 @@ impl<'r> Importer<'r> {
|
||||
paras,
|
||||
chars,
|
||||
frames: vec![],
|
||||
direction: if e.find("StoryPreference").and_then(|p| p.get("StoryDirection")) == Some("RightToLeftDirection") {
|
||||
designcraft_doc::TextDirection::RightToLeft
|
||||
} else {
|
||||
designcraft_doc::TextDirection::LeftToRight
|
||||
},
|
||||
rev: 0,
|
||||
tables,
|
||||
notes,
|
||||
@@ -1348,6 +1454,11 @@ impl<'r> Importer<'r> {
|
||||
let nr = rows.len().max(1);
|
||||
let nc = cols.len().max(1);
|
||||
let mut t = Table::new(id, nr, nc, 0, 0, 0.0);
|
||||
t.options.direction = if e.get("TableDirection") == Some("RightToLeftDirection") {
|
||||
designcraft_doc::TextDirection::RightToLeft
|
||||
} else {
|
||||
designcraft_doc::TextDirection::LeftToRight
|
||||
};
|
||||
for (r, re) in rows.iter().enumerate() {
|
||||
let auto = re.boolean("AutoGrow").unwrap_or(true);
|
||||
t.rows[r].mode = if auto { RowHeightMode::AtLeast } else { RowHeightMode::Exactly };
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
//! - Unknown elements are ignored; nothing is preserved opaquely for round-trip yet.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod arabic;
|
||||
mod cjk;
|
||||
mod export;
|
||||
mod import;
|
||||
mod names;
|
||||
|
||||
@@ -707,3 +707,145 @@ fn vertical_story_orientation_round_trips() {
|
||||
let frame = back.spreads[0].items.iter().find_map(|i| i.text_frame()).unwrap();
|
||||
assert!(frame.options.vertical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_character_attributes_import_from_independent_xml_and_round_trip() {
|
||||
let story = r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Story Self="s1">
|
||||
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/[No paragraph style]" BunriKinshi="true" Rensuuji="false" TreatIdeographicSpaceAsSpace="true">
|
||||
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]" LeadingAki="0.25" TrailingAki="-1" Tsume="20" Jidori="4" LeadingModel="LeadingModelCenter" CharacterAlignment="AlignEmCenter" KentenKind="KentenWhiteCircle"><Content>甲乙</Content></CharacterStyleRange>
|
||||
</ParagraphStyleRange></Story></idPkg:Story>"#;
|
||||
let doc = import_idml(&fixture_with_story(story)).unwrap();
|
||||
let st = doc.stories.values().find(|s| s.text.contains("甲乙")).unwrap();
|
||||
let a = &st.runs().next().unwrap().1.over;
|
||||
assert_eq!(a.leading_aki, Some(Some(0.25)));
|
||||
assert_eq!(a.trailing_aki, Some(None));
|
||||
assert_eq!(a.jidori, Some(4));
|
||||
assert_eq!(a.tsume, Some(0.2));
|
||||
assert_eq!(a.kenten_character.as_deref(), Some("○"));
|
||||
assert_eq!(a.leading_model, Some(designcraft_doc::cjk::LeadingModel::Center));
|
||||
assert_eq!(st.paras[0].para.bunri_kinshi, Some(true));
|
||||
assert_eq!(st.paras[0].para.rensuuji, Some(false));
|
||||
let back = import_idml(&export_idml(&doc)).unwrap();
|
||||
let st2 = back.stories.values().find(|s| s.text.contains("甲乙")).unwrap();
|
||||
let b = &st2.runs().next().unwrap().1.over;
|
||||
assert_eq!(a.leading_aki, b.leading_aki);
|
||||
assert_eq!(a.trailing_aki, b.trailing_aki);
|
||||
assert_eq!(a.kenten_character, b.kenten_character);
|
||||
assert_eq!(a.leading_model, b.leading_model);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_kerning_does_not_import_inactive_numeric_values() {
|
||||
for (attributes, expected) in [
|
||||
(r#"KerningMethod="$ID/Optical" KerningValue="1e+11""#, Some(designcraft_doc::Kerning::Optical)),
|
||||
(r#"KerningMethod="$ID/Metrics" KerningValue="0""#, Some(designcraft_doc::Kerning::Metrics)),
|
||||
(r#"KerningValue="-40""#, Some(designcraft_doc::Kerning::Manual(-40.0))),
|
||||
(r#"KerningValue="1e+11""#, None),
|
||||
] {
|
||||
let story = format!(
|
||||
r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Story Self="s1">
|
||||
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/[No paragraph style]">
|
||||
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]" {attributes}><Content>甲乙</Content></CharacterStyleRange>
|
||||
</ParagraphStyleRange></Story></idPkg:Story>"#
|
||||
);
|
||||
let d = import_idml(&fixture_with_story(&story)).unwrap();
|
||||
let s = d.stories.values().find(|s| s.text.contains("甲乙")).unwrap();
|
||||
assert_eq!(s.runs().next().unwrap().1.over.kerning, expected, "{attributes}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_composite_fonts_and_custom_kinsoku_are_document_resources() {
|
||||
let map = DESIGNMAP.replace("</Document>", r#"
|
||||
<KinsokuTable Self="KinsokuTable/Test" Name="Test" CantBeginLineChars="乙" CantEndLineChars="甲" CantBeSeparatedChars="—" HangingPunctuationChars="。"/>
|
||||
<CompositeFont Self="CompositeFont/Mixed" Name="Mixed"><CompositeFontEntry Self="cf1" Name="Base" FontStyle="Regular"><Properties><AppliedFont type="string">Source Serif 4</AppliedFont></Properties></CompositeFontEntry>
|
||||
<CompositeFontEntry Self="cf2" Name="Digits" CustomCharacters="0123456789" FontStyle="Regular" RelativeSize="80" BaselineShift="10"><Properties><AppliedFont type="string">Source Sans 3</AppliedFont></Properties></CompositeFontEntry></CompositeFont>
|
||||
</Document>"#);
|
||||
let story = STORY.replace("<ParagraphStyleRange ", "<ParagraphStyleRange KinsokuSet=\"KinsokuTable/Test\" ");
|
||||
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),
|
||||
]);
|
||||
let d = import_idml(&bytes).unwrap();
|
||||
let f = &d.styles.composite_fonts[0];
|
||||
assert_eq!(f.entry('1').unwrap().family, "Source Sans 3");
|
||||
assert_eq!(f.entry('甲').unwrap().family, "Source Serif 4");
|
||||
assert_eq!(f.entry('1').unwrap().relative_size, 0.8);
|
||||
let back = import_idml(&export_idml(&d)).unwrap();
|
||||
assert_eq!(back.styles.composite_fonts, d.styles.composite_fonts);
|
||||
assert!(
|
||||
back.stories.values().flat_map(|s| &s.paras).any(|p| p.para.kinsoku.as_ref().and_then(Option::as_ref).is_some_and(|k| k.no_start == "乙"))
|
||||
);
|
||||
}
|
||||
|
||||
#[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>
|
||||
<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 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),
|
||||
]);
|
||||
let d = import_idml(&bytes).unwrap();
|
||||
let t = &d.styles.mojikumi_tables[0];
|
||||
assert_eq!(t.overrides[0].minimum, -0.1);
|
||||
assert!(t.overrides[0].does_not_float);
|
||||
let back = import_idml(&export_idml(&d)).unwrap();
|
||||
assert_eq!(back.styles.mojikumi_tables, d.styles.mojikumi_tables);
|
||||
assert!(back.stories.values().flat_map(|s| &s.paras).any(|p| p.para.mojikumi.as_deref() == Some("MojikumiTable/Spacing")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_controls_import_independent_xml_and_round_trip() {
|
||||
let story = r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Story Self="s1">
|
||||
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/[No paragraph style]" ParagraphDirection="RightToLeftDirection" Kashidas="KashidasOff" ParagraphJustification="NaskhJustification" ParagraphKashidaWidth="2">
|
||||
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]" CharacterDirection="LeftToRightDirection" Kashidas="KashidasOff" DiacriticPosition="OpentypePosition" XOffsetDiacritic="150" YOffsetDiacritic="-100" PositionalForm="Medial"><Properties><DigitsType type="enumeration">FarsiDigits</DigitsType></Properties><Content>بَ123</Content></CharacterStyleRange>
|
||||
</ParagraphStyleRange></Story></idPkg:Story>"#;
|
||||
let d = import_idml(&fixture_with_story(story)).unwrap();
|
||||
let st = d.stories.values().find(|s| s.text.contains('ب')).unwrap();
|
||||
assert_eq!(st.paras[0].para.kashidas, Some(false));
|
||||
assert_eq!(st.paras[0].para.arabic_justification.as_deref(), Some("NaskhJustification"));
|
||||
let a = &st.runs().next().unwrap().1.over;
|
||||
assert_eq!(a.character_direction, Some(designcraft_doc::arabic::CharacterDirection::LeftToRight));
|
||||
assert_eq!(a.allow_kashidas, Some(false));
|
||||
assert_eq!(a.diacritic_x_offset, Some(150.0));
|
||||
assert_eq!(a.diacritic_y_offset, Some(-100.0));
|
||||
assert_eq!(a.digits, Some(designcraft_doc::Digits::Farsi));
|
||||
let back = import_idml(&export_idml(&d)).unwrap();
|
||||
let b = back.stories.values().find(|s| s.text.contains('ب')).unwrap();
|
||||
let attrs = &b.runs().next().unwrap().1.over;
|
||||
assert_eq!(attrs.character_direction, a.character_direction);
|
||||
assert_eq!(attrs.diacritic_x_offset, a.diacritic_x_offset);
|
||||
assert_eq!(attrs.diacritic_y_offset, a.diacritic_y_offset);
|
||||
assert_eq!(attrs.allow_kashidas, a.allow_kashidas);
|
||||
assert_eq!(attrs.positional_form, a.positional_form);
|
||||
assert_eq!(b.paras[0].para.paragraph_kashida_width, Some(Some(2.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_story_and_table_directions_survive_idml_export() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 300.0, 300.0), d.default_layer(), "", ParaFormat::default()).unwrap();
|
||||
let st = d.story_mut(sid).unwrap();
|
||||
st.direction = designcraft_doc::TextDirection::RightToLeft;
|
||||
let mut t = designcraft_doc::Table::new(42, 1, 2, 0, 0, 200.0);
|
||||
t.options.direction = designcraft_doc::TextDirection::RightToLeft;
|
||||
st.insert_table(0, t);
|
||||
let back = import_idml(&export_idml(&d)).unwrap();
|
||||
let st = back.stories.values().find(|st| !st.tables.is_empty()).unwrap();
|
||||
assert_eq!(st.direction, designcraft_doc::TextDirection::RightToLeft);
|
||||
assert_eq!(st.tables.values().next().unwrap().options.direction, designcraft_doc::TextDirection::RightToLeft);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
# Arabic import and composition
|
||||
|
||||
Task: AR1. These changes repair verified gaps in the existing typesetting pipeline.
|
||||
They do not claim full InDesign or vendor-specific Naskh composition parity.
|
||||
|
||||
## Architecture
|
||||
|
||||
Portable character controls live in `doc::arabic` and the inherited attribute
|
||||
model. Story column direction and table column direction are independent of
|
||||
paragraph direction. The IDML adapter translates serialized values at the boundary.
|
||||
The font layer resolves script/direction runs and passes language and surrounding
|
||||
text to the OpenType shaper. The composer retains complete cluster text for
|
||||
paragraph-level bidi resolution, then applies line-level ordering and placement.
|
||||
The renderer continues to consume placed glyphs without Arabic-specific policy.
|
||||
|
||||
Existing `type.char`, `type.para` and `table.options` commands edit the controls.
|
||||
`story.setDirection` edits column flow with history and cache invalidation;
|
||||
`story.get` reports it. Table cell indices remain logical even when their visual
|
||||
positions are reversed. Physical left/right insertion commands account for direction.
|
||||
|
||||
## Implemented rules
|
||||
|
||||
| Area | Behavior |
|
||||
| --- | --- |
|
||||
| Mixed scripts | Separate Hebrew/Arabic script runs, resolved shaping direction, language tags and OpenType joining features |
|
||||
| Style boundaries | Pre/post shaping context preserves joining when a character style changes within an Arabic word |
|
||||
| Font fallback | Combining marks and join controls stay with the preceding base face |
|
||||
| Bidirectional layout | Full paragraph context, cluster contents and Unicode controls survive line breaking; per-line UAX #9 ordering keeps marks attached, including lines with tab leaders |
|
||||
| Character direction | Imported default/LTR/RTL overrides participate in bidi resolution; single-character punctuation mirroring is corrected against final paragraph levels |
|
||||
| Digits | European, Arabic-Indic and Persian conversion in both directions, contextual defaults, language-sensitive native digits, original UTF-8 source ranges retained |
|
||||
| Diacritics | Font GDEF classification and OpenType mark anchors; explicit X/Y offsets affect marks only, in thousandths of an em |
|
||||
| Positional forms | Explicit isolated/initial/medial/final OpenType feature selection; automatic forms remain the default |
|
||||
| Kashidas | Paragraph and character switches, actual font coverage, shaper-approved insertion boundaries, nonjoiner protection, whole-cluster movement to keep marks attached |
|
||||
| Story direction | Independent right-to-left column flow, including span-column bounds |
|
||||
| Table direction | Right-to-left cell placement, merged spans, physical edge styles/borders, hit testing and left/right column insertion |
|
||||
| Serialization | Character controls, paragraph policy/width values, story/table directions survive native data and IDML round trips |
|
||||
|
||||
## Remaining limitations
|
||||
|
||||
- Vendor-specific Arabic/Naskh justification policies and Paragraph Kashida Width
|
||||
presets are preserved but not reproduced. Preflight reports their use.
|
||||
Automatic elongation still uses bounded, scaled tatweel insertion rather than
|
||||
full line reshaping or a vendor allocation algorithm.
|
||||
- Loose/Medium/Tight/OpenType-from-baseline diacritic presets are preserved and
|
||||
reported. Composition uses font anchors and explicit offsets instead.
|
||||
- Script-specific stretched alternates, contextual swash policies, complete RTL
|
||||
tab-stop semantics and re-shaping at emergency intra-word line breaks require
|
||||
further compatibility work. Tracking and arbitrary metric changes within a
|
||||
cursive word can still open joining gaps.
|
||||
- A font must contain the required glyphs and appropriate OpenType tables. This
|
||||
change neither embeds proprietary fonts nor makes missing-font errors disappear.
|
||||
- CJK compatibility boundaries remain documented in `cjk-typography.md`.
|
||||
|
||||
## Verification and sources
|
||||
|
||||
Regression tests cover independent IDML XML, round trips, forced line breaks,
|
||||
character overrides, original byte ranges, mark placement, style-boundary joining,
|
||||
kashida controls, story/table flow, merged cells, command validation and undo.
|
||||
Font-dependent tests use available fonts; no proprietary font or user document
|
||||
is committed. Synthetic Arabic documents provide visual smoke checks.
|
||||
|
||||
The AR1 validation run passed all six `cargo xtask ci` gates and 616 tests
|
||||
(with two existing ignored tests). WASM checks used the installed Rust standard
|
||||
library sources via `-Z build-std=std,panic_abort`, because a prebuilt WASM standard
|
||||
library was unavailable. The synthetic Arabic proof was exported to IDML and
|
||||
reopened in the desktop app: preflight reported zero errors and zero warnings.
|
||||
The original Chinese IDML was also reopened; its four Mojikumi/kinsoku-priority
|
||||
compatibility warnings remain, along with separate font/link/overset findings.
|
||||
|
||||
- [Unicode UAX #9](https://www.unicode.org/reports/tr9/) defines paragraph resolution
|
||||
and line-level bidirectional ordering.
|
||||
- [Adobe Middle Eastern scripting guide](https://developer.adobe.com/indesign/uxp/resources/recipes/rtl/)
|
||||
documents the distinct direction, digit, kashida and diacritic controls.
|
||||
- [Diacritic position enumeration](https://developer.adobe.com/indesign/uxp/dom/api/d/diacritic-position-options/)
|
||||
and [positional forms](https://developer.adobe.com/indesign/uxp/dom/api/p/positional-forms/)
|
||||
identify serialized options; their presence alone does not establish visual parity.
|
||||
@@ -0,0 +1,64 @@
|
||||
# CJK import and composition
|
||||
|
||||
Task: CJK1. This is incremental support, not a claim of InDesign composition parity.
|
||||
|
||||
## Data flow
|
||||
|
||||
The IDML adapter resolves font resources and named/custom kinsoku sets into portable
|
||||
document data. `doc::cjk` owns those resources; `CharAttrs` and `ParaAttrs` retain
|
||||
inheritance and local overrides. The composer resolves composite-font character
|
||||
assignments before shaping, applies character metrics, then supplies break
|
||||
constraints to both line breakers. Line placement applies em alignment, leading
|
||||
references and hanging punctuation. The renderer consumes placed glyphs unchanged.
|
||||
|
||||
`style.compositeFont.list` and `style.compositeFont.set` expose resources through the
|
||||
existing command channel. Editing uses normal document history and style cache
|
||||
invalidation. Font listing, replacement and preflight resolve component fonts.
|
||||
|
||||
## Implemented behavior
|
||||
|
||||
| Rule | Behavior and coverage |
|
||||
| --- | --- |
|
||||
| Composite fonts | Character mappings, component family/style, relative size, scales and baseline shift; synthetic selection and command/undo tests |
|
||||
| Leading/trailing aki | Explicit em spacing without outline scaling; inherited resets and IDML round trip |
|
||||
| Kerning import | Automatic methods take precedence over inactive numeric values; the 1e11 sentinel cannot become manual spacing |
|
||||
| Tsume | Compress nonnegative glyph sidebearings without scaling ink; full compression regression |
|
||||
| Jidori | Fixed group width with protected internal boundaries and justification advances; both composers tested |
|
||||
| Kinsoku sets | Custom character sets and independent common punctuation defaults for named sets; explicit disabling retained |
|
||||
| Bunri kinshi / rensuuji | Protect repeated dash/ellipsis pairs and numeric sequences |
|
||||
| Hanging punctuation | Regular/forced edge allowance integrated with line breaking and placement |
|
||||
| Ideographic spaces | Paragraph switch controls whether fullwidth spaces participate in word spacing |
|
||||
| Em alignment / leading | Em top/center/bottom alignment and baseline reference offsets; mixed-size regressions |
|
||||
| Kenten / glyph forms | Preserve emphasis character, shape it with font fallback; map supported OpenType forms |
|
||||
| Tate-chu-yoko offsets | Carry horizontal/vertical offsets through the existing vertical group placement |
|
||||
|
||||
## Unfinished behavior
|
||||
|
||||
- **Mojikumi**: definitions, base-set names, all override rows and paragraph references
|
||||
survive import/export. The numeric character-class spacing tables and their
|
||||
compression priorities are **not executed**. Preflight reports this explicitly.
|
||||
- **Kinsoku push-in/push-out priorities**: retained, but not used in candidate
|
||||
selection. Preflight reports this explicitly. Boundary prohibition alone is not
|
||||
equivalent to implementing this priority policy.
|
||||
- Named kinsoku defaults are clean-room common punctuation rules, not verified
|
||||
reproductions of every vendor preset. Explicit custom tables take precedence.
|
||||
- ICF alignment currently uses font ascender/descender metrics; exact ideographic
|
||||
character-face metrics are not implemented. CenterDown currently shares the
|
||||
center leading reference. These require separate compatibility work.
|
||||
- Jidori groups are scoped to resolved shaping runs. Mixed component-font or style
|
||||
changes inside one intended group need a group model above shaping.
|
||||
- Frame-grid composition and detailed ruby/warichu controls are outside this change.
|
||||
|
||||
Preserving a parameter is not evidence that its visual rule has been implemented.
|
||||
Missing fonts, missing links and overset remain separate preflight findings;
|
||||
removing unsupported-rule warnings to produce a clean report is not acceptable.
|
||||
|
||||
## References
|
||||
|
||||
- [Adobe MojikumiTable API](https://developer.adobe.com/indesign/uxp/dom/api/m/mojikumi-table/)
|
||||
describes the serialized override tuple, but does not define the numeric class mapping.
|
||||
- [Adobe spacing priorities](https://helpx.adobe.com/hk_en/indesign/desktop/language-and-proofing/chinese-japanese-and-korean/set-spacing-priorities-in-mojikumi-character-classes.html)
|
||||
describes ordered compression, which requires more than loading the table.
|
||||
|
||||
Tests use synthetic documents; user IDML files and proprietary application output
|
||||
are not committed as fixtures.
|
||||
Reference in new issue
Block a user