mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-29 20:56:47 -06:00
Conflicts: - crates/pdf/src/text.rs run_text: the branch's two generated_text branches first, then #392's kashida branch (no text), then the cmap fallback for other inserted glyphs. - crates/compose/src/lib.rs: next's kashida PlacedGlyph (with dx) gets generated_text: None. - crates/compose/src/shape.rs hyphen_after: next's rendered_char/moji resets and generated_text = None. - crates/compose/src/ruby.rs: next's ruby module (#363); the branch's edit to the old ruby set() has nothing to apply to. - crates/pdf/src/tests.rs: tests from both. Also, so the merge compiles: next's ruby (ruby.rs annotate) and kenten (kenten.rs shape_mark) PlacedGlyph literals take generated_text: None; they map back through the font like other inserted glyphs. Kanji page numbers, composite-font note numbers and fallback-face label segments go through shape_segment's replacement and list labels through prepend_label, so they carry their generated text.
720 lines
29 KiB
Rust
720 lines
29 KiB
Rust
//! Ruby: small glyphs set over laid-out text (to its right in vertical frames, where the frame's
|
|
//! turn carries "above" there), or under it (to its left). Kenten are in [`crate::kenten`].
|
|
//!
|
|
//! Ruby follows JIS X 4051 / JLReq §3.3. Before line breaking, [`reserve`] splits per-character
|
|
//! ruby into one unit per parent character, keeps group ruby on one line, and spaces out parent
|
|
//! characters whose ruby is longer than they are by what overhang onto the neighbours can't take.
|
|
//! After line breaking, [`annotate`] sets the ruby glyphs with the same arithmetic on the placed
|
|
//! line. Ruby doesn't change leading.
|
|
|
|
use designcraft_doc::cjk_settings::AdornmentOverprint;
|
|
use designcraft_doc::ruby::{MAX_RUBY_CHARS, RubyAlignment, RubyOverhang, RubyParentSpacing, RubyPosition, RubySpec, RubyType};
|
|
use designcraft_fonts::{FaceRef, ScopedFonts};
|
|
|
|
use crate::shape::Glyph;
|
|
use crate::{PlacedGlyph, RunStyle, upright_in_vertical};
|
|
|
|
/// `text` cut to the longest reading laid out, at a character boundary.
|
|
pub(crate) fn capped(text: &str) -> &str {
|
|
match text.char_indices().nth(MAX_RUBY_CHARS) {
|
|
Some((i, _)) => text.get(..i).unwrap_or(text),
|
|
None => text,
|
|
}
|
|
}
|
|
|
|
// ---------- ruby text ----------
|
|
|
|
/// A shaped ruby glyph, `x` from the ruby's start.
|
|
#[derive(Clone, Debug)]
|
|
struct RubyGlyph {
|
|
gid: u32,
|
|
ch: char,
|
|
x: f64,
|
|
y: f64,
|
|
adv: f64,
|
|
sx: f64,
|
|
sy: f64,
|
|
tcy: Option<[f64; 3]>,
|
|
/// Part of the tate-chu-yoko group the previous glyph started (spread as one character).
|
|
joined: bool,
|
|
}
|
|
|
|
/// A run's ruby, shaped in its font at its size.
|
|
struct Shaped {
|
|
face: FaceRef,
|
|
glyphs: Vec<RubyGlyph>,
|
|
width: f64,
|
|
size: f64,
|
|
/// Width of one ruby character (the unit of overhang and of 1-aki alignment).
|
|
em: f64,
|
|
}
|
|
|
|
/// The ruby's face: its own font, else the parent glyph's. With `fallback`, a face that lacks a
|
|
/// character of `text` gives way to a fallback font for it.
|
|
fn ruby_face(db: &ScopedFonts<'_>, parent: FaceRef, spec: &RubySpec, text: &str, fallback: bool) -> FaceRef {
|
|
let face = if spec.font_family.is_empty() {
|
|
parent
|
|
} else {
|
|
let style = if spec.font_style.is_empty() { "Regular" } else { spec.font_style.as_str() };
|
|
FaceRef::of(&db.face(&spec.font_family, style))
|
|
};
|
|
if fallback
|
|
&& let Some(c) = text.chars().find(|c| !c.is_whitespace() && !face.covers(*c))
|
|
&& let Some(f) = db.fallback_for(c, face.id(), None)
|
|
{
|
|
return FaceRef::of(&f);
|
|
}
|
|
face
|
|
}
|
|
|
|
fn tcy_char(c: char, roman: bool) -> bool {
|
|
c.is_ascii_digit() || (roman && c.is_ascii_alphabetic())
|
|
}
|
|
|
|
/// Shape `text` as the ruby of parent text `parent_size` points big.
|
|
fn shape_ruby(db: &ScopedFonts<'_>, parent: FaceRef, text: &str, spec: &RubySpec, parent_size: f64, fallback: bool, vertical: bool) -> Shaped {
|
|
let size = spec.size_for(parent_size);
|
|
let face = ruby_face(db, parent, spec, text, fallback);
|
|
let upem = face.units_per_em();
|
|
let k = if upem > 0.0 { size / upem } else { 0.0 };
|
|
let (sx, sy) = (k * spec.x_scale, k * spec.y_scale);
|
|
let features: Vec<designcraft_fonts::Feature> =
|
|
if spec.open_type_pro { designcraft_fonts::feature("ruby").into_iter().collect() } else { Vec::new() };
|
|
let mut glyphs: Vec<RubyGlyph> = designcraft_fonts::shape(&face, text, &features, |c| c)
|
|
.into_iter()
|
|
.map(|sg| RubyGlyph {
|
|
gid: sg.gid,
|
|
ch: text.get(sg.cluster..).and_then(|s| s.chars().next()).unwrap_or(' '),
|
|
x: sg.x_offset as f64 * sx,
|
|
y: -(sg.y_offset as f64) * sy,
|
|
adv: sg.x_advance as f64 * sx,
|
|
sx,
|
|
sy,
|
|
tcy: None,
|
|
joined: false,
|
|
})
|
|
.collect();
|
|
// Tate-chu-yoko: short runs of digits (and Latin letters) take one ruby em along the line.
|
|
if vertical && spec.auto_tcy_digits > 0 {
|
|
let roman = spec.auto_tcy_include_roman;
|
|
let mut i = 0;
|
|
while i < glyphs.len() {
|
|
let j = i + glyphs.get(i..).unwrap_or_default().iter().take_while(|g| tcy_char(g.ch, roman)).count();
|
|
if j == i {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if u32::try_from(j - i).is_ok_and(|n| n <= spec.auto_tcy_digits)
|
|
&& let Some(run) = glyphs.get_mut(i..j)
|
|
{
|
|
let total: f64 = run.iter().map(|g| g.adv).sum();
|
|
let f = if spec.auto_tcy_auto_scale && total > size && total > 0.0 { size / total } else { 1.0 };
|
|
let mut across = -total * f / 2.0;
|
|
for (n, g) in run.iter_mut().enumerate() {
|
|
g.tcy = Some([size / 2.0, across + g.x * f, size]);
|
|
across += g.adv * f;
|
|
g.sx *= f;
|
|
g.x = 0.0;
|
|
g.adv = if n == 0 { size } else { 0.0 };
|
|
g.joined = n > 0;
|
|
}
|
|
}
|
|
i = j;
|
|
}
|
|
}
|
|
// Pen positions: a joined glyph sits at its group's start.
|
|
let mut pen = 0.0;
|
|
let mut start = 0.0;
|
|
for g in &mut glyphs {
|
|
if !g.joined {
|
|
start = pen;
|
|
}
|
|
g.x += start;
|
|
pen += g.adv;
|
|
}
|
|
Shaped { face, glyphs, width: pen, size, em: size * spec.x_scale }
|
|
}
|
|
|
|
// ---------- overhang ----------
|
|
|
|
/// What is next to a ruby's parent text along the line.
|
|
#[derive(Clone, Copy, Debug)]
|
|
enum Side {
|
|
/// The line's (or paragraph's) start or end.
|
|
Edge,
|
|
/// A character with no ruby.
|
|
Char { ch: char, adv: f64 },
|
|
/// A character with ruby: `free` is the room its ruby leaves on this side (only a
|
|
/// neighbouring per-character ruby unit leaves any, as in jukugo ruby).
|
|
Ruby { free: f64 },
|
|
}
|
|
|
|
fn is_kana(c: char) -> bool {
|
|
matches!(c, '\u{3041}'..='\u{309F}' | '\u{30A0}'..='\u{30FA}' | '\u{30FC}'..='\u{30FF}' | '\u{31F0}'..='\u{31FF}' | '\u{FF66}'..='\u{FF9F}')
|
|
}
|
|
|
|
/// Opening brackets (JLReq cl-01): their first half is blank.
|
|
fn is_opening(c: char) -> bool {
|
|
matches!(
|
|
c,
|
|
'\u{2018}'
|
|
| '\u{201C}'
|
|
| '\u{3008}'
|
|
| '\u{300A}'
|
|
| '\u{300C}'
|
|
| '\u{300E}'
|
|
| '\u{3010}'
|
|
| '\u{3014}'
|
|
| '\u{3016}'
|
|
| '\u{3018}'
|
|
| '\u{301D}'
|
|
| '\u{FF08}'
|
|
| '\u{FF3B}'
|
|
| '\u{FF5B}'
|
|
| '\u{FF5F}'
|
|
)
|
|
}
|
|
|
|
/// Closing brackets, commas and full stops (JLReq cl-02, cl-06, cl-07): their second half is
|
|
/// blank.
|
|
fn is_closing(c: char) -> bool {
|
|
matches!(
|
|
c,
|
|
'\u{2019}'
|
|
| '\u{201D}'
|
|
| '\u{3001}'
|
|
| '\u{3002}'
|
|
| '\u{3009}'
|
|
| '\u{300B}'
|
|
| '\u{300D}'
|
|
| '\u{300F}'
|
|
| '\u{3011}'
|
|
| '\u{3015}'
|
|
| '\u{3017}'
|
|
| '\u{3019}'
|
|
| '\u{301F}'
|
|
| '\u{FF09}'
|
|
| '\u{FF0C}'
|
|
| '\u{FF0E}'
|
|
| '\u{FF3D}'
|
|
| '\u{FF5D}'
|
|
| '\u{FF60}'
|
|
)
|
|
}
|
|
|
|
/// Middle dots, colons and semicolons (JLReq cl-05): a quarter blank on each side.
|
|
fn is_middle_dot(c: char) -> bool {
|
|
matches!(c, '\u{30FB}' | '\u{FF1A}' | '\u{FF1B}')
|
|
}
|
|
|
|
/// How far a ruby may extend over `side` (before the parent when `before`), in points. Ruby
|
|
/// goes over kana and the blank part of punctuation, never over kanji or Latin text.
|
|
fn allowance(spec: &RubySpec, ruby_em: f64, parent_em: f64, side: Side, before: bool) -> f64 {
|
|
let amount = match spec.overhang {
|
|
RubyOverhang::None => 0.0,
|
|
RubyOverhang::OneRuby => ruby_em,
|
|
RubyOverhang::HalfRuby => ruby_em / 2.0,
|
|
RubyOverhang::OneChar => parent_em,
|
|
RubyOverhang::HalfChar => parent_em / 2.0,
|
|
RubyOverhang::NoLimit => f64::INFINITY,
|
|
};
|
|
let cap = match side {
|
|
// Auto-align sets a longer ruby flush with the line's edge.
|
|
Side::Edge => {
|
|
if spec.auto_align {
|
|
0.0
|
|
} else {
|
|
f64::INFINITY
|
|
}
|
|
}
|
|
Side::Ruby { free } => free,
|
|
Side::Char { ch, adv } => {
|
|
if is_kana(ch) {
|
|
adv
|
|
} else if (before && is_opening(ch)) || (!before && is_closing(ch)) {
|
|
adv / 2.0
|
|
} else if is_middle_dot(ch) {
|
|
adv / 4.0
|
|
} else {
|
|
0.0
|
|
}
|
|
}
|
|
};
|
|
amount.min(cap).max(0.0)
|
|
}
|
|
|
|
/// The overhang allowances the alignment can use: flush-start ruby only extends after its
|
|
/// parent, flush-end ruby only before it.
|
|
fn usable(align: RubyAlignment, before: f64, after: f64) -> (f64, f64) {
|
|
match align {
|
|
RubyAlignment::Left => (0.0, after),
|
|
RubyAlignment::Right => (before, 0.0),
|
|
_ => (before, after),
|
|
}
|
|
}
|
|
|
|
/// The factor a longer ruby is narrowed by to fit its parent and overhang (1.0 = not at all).
|
|
fn auto_scale(spec: &RubySpec, ruby: f64, parent: f64, room: f64) -> f64 {
|
|
if !spec.auto_scaling || ruby <= 0.0 || ruby <= parent + room {
|
|
return 1.0;
|
|
}
|
|
((parent + room) / ruby).max(spec.scaling_min).min(1.0)
|
|
}
|
|
|
|
/// How far before its parent's start a ruby `excess` longer than the parent starts, given the
|
|
/// overhang allowed `before` and `after`: centred where the neighbours allow it, else pushed
|
|
/// towards the side that takes more.
|
|
fn lead_overhang(align: RubyAlignment, excess: f64, before: f64, after: f64) -> f64 {
|
|
match align {
|
|
RubyAlignment::Left => 0.0,
|
|
RubyAlignment::Right => excess,
|
|
_ => {
|
|
let lo = (excess - after).max(0.0);
|
|
let hi = before.min(excess);
|
|
if lo <= hi { (excess / 2.0).max(lo).min(hi) } else { excess / 2.0 }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Offsets from the parent's start of the ruby's characters (`advs`, one per slot) over a parent
|
|
/// `width` wide that is at least as wide as they are.
|
|
fn spread(align: RubyAlignment, width: f64, advs: &[f64], ruby_em: f64) -> Vec<f64> {
|
|
let n = advs.len();
|
|
if n == 0 {
|
|
return Vec::new();
|
|
}
|
|
let w: f64 = advs.iter().sum();
|
|
let gap = (width - w).max(0.0);
|
|
let nf = n as f64;
|
|
let jis = (gap / (2.0 * nf), gap / nf);
|
|
let (lead, inner) = match align {
|
|
RubyAlignment::Left => (0.0, 0.0),
|
|
RubyAlignment::Center => (gap / 2.0, 0.0),
|
|
RubyAlignment::Right => (gap, 0.0),
|
|
RubyAlignment::FullJustify if n > 1 => (0.0, gap / (nf - 1.0)),
|
|
RubyAlignment::FullJustify => (gap / 2.0, 0.0),
|
|
RubyAlignment::Jis => jis,
|
|
RubyAlignment::EqualAki => (gap / (nf + 1.0), gap / (nf + 1.0)),
|
|
RubyAlignment::OneAki if n == 1 => (gap / 2.0, 0.0),
|
|
RubyAlignment::OneAki if gap >= ruby_em => (ruby_em / 2.0, (gap - ruby_em) / (nf - 1.0)),
|
|
RubyAlignment::OneAki => jis,
|
|
};
|
|
let mut out = Vec::with_capacity(n);
|
|
let mut x = lead;
|
|
for a in advs {
|
|
out.push(x);
|
|
x += a + inner;
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The space to add around and between `n` parent characters to make them `need` wider: (before
|
|
/// the first, between each two, after the last).
|
|
fn parent_spacing(mode: RubyParentSpacing, n: usize, need: f64) -> Option<(f64, f64, f64)> {
|
|
if n == 0 || !need.is_finite() || need <= 1e-9 {
|
|
return None;
|
|
}
|
|
let nf = n as f64;
|
|
Some(match mode {
|
|
RubyParentSpacing::NoAdjustment => return None,
|
|
RubyParentSpacing::BothSides => (need / 2.0, 0.0, need / 2.0),
|
|
RubyParentSpacing::Aki121 => (need / (2.0 * nf), need / nf, need / (2.0 * nf)),
|
|
RubyParentSpacing::EqualAki => (need / (nf + 1.0), need / (nf + 1.0), need / (nf + 1.0)),
|
|
RubyParentSpacing::FullJustify if n > 1 => (0.0, need / (nf - 1.0), 0.0),
|
|
RubyParentSpacing::FullJustify => (need / 2.0, 0.0, need / 2.0),
|
|
})
|
|
}
|
|
|
|
fn intern(styles: &mut Vec<RunStyle>, rs: RunStyle) -> u32 {
|
|
if let Some(i) = styles.iter().rposition(|s| *s == rs) {
|
|
return u32::try_from(i).unwrap_or(u32::MAX);
|
|
}
|
|
styles.push(rs);
|
|
u32::try_from(styles.len().saturating_sub(1)).unwrap_or(u32::MAX)
|
|
}
|
|
|
|
/// What groups glyphs under one ruby: its reading and, for per-character ruby, its unit.
|
|
fn ruby_key(st: &RunStyle) -> Option<(&str, Option<u32>)> {
|
|
st.ruby.as_deref().map(|t| (t, st.ruby_unit))
|
|
}
|
|
|
|
/// The room a per-character ruby unit's reading leaves on each side of its parent `adv` wide.
|
|
fn unit_free(db: &ScopedFonts<'_>, face: FaceRef, adv: f64, st: &RunStyle, mono: bool, fallback: bool, vertical: bool) -> f64 {
|
|
match (mono, st.ruby_unit, st.ruby.as_deref(), st.ruby_spec.as_ref()) {
|
|
(true, Some(_), Some(t), Some(sp)) => {
|
|
let w = shape_ruby(db, face, t, sp, st.size, fallback || st.missing_font, vertical).width;
|
|
((adv - w) / 2.0).max(0.0)
|
|
}
|
|
_ => 0.0,
|
|
}
|
|
}
|
|
|
|
// ---------- before line breaking ----------
|
|
|
|
/// Prepare a shaped paragraph's ruby: one unit per parent character for per-character ruby,
|
|
/// no breaks inside group ruby, and parent spacing where a longer ruby needs it. `fallback`: the
|
|
/// document draws missing glyphs from fallback fonts.
|
|
pub(crate) fn reserve(db: &ScopedFonts<'_>, styles: &mut Vec<RunStyle>, glyphs: &mut [Glyph], fallback: bool, vertical: bool) {
|
|
if !glyphs.iter().any(|g| styles.get(g.style as usize).is_some_and(|s| s.ruby.is_some())) {
|
|
return;
|
|
}
|
|
let in_group = |styles: &[RunStyle], g: &Glyph, key: (&str, Option<u32>)| {
|
|
g.len == 0 || styles.get(g.style as usize).and_then(ruby_key).is_some_and(|k| k == key)
|
|
};
|
|
// Per-character ruby: each parent character gets its own reading.
|
|
let mut i = 0;
|
|
while i < glyphs.len() {
|
|
let Some(st) = glyphs.get(i).and_then(|g| styles.get(g.style as usize)) else {
|
|
i += 1;
|
|
continue;
|
|
};
|
|
let Some(text) = st.ruby.clone() else {
|
|
i += 1;
|
|
continue;
|
|
};
|
|
let mono = st.ruby_unit.is_none() && st.ruby_spec.as_ref().is_some_and(|s| s.kind == RubyType::PerCharacter);
|
|
let mut j = i + 1;
|
|
while glyphs.get(j).is_some_and(|g| in_group(styles.as_slice(), g, (text.as_str(), None))) {
|
|
j += 1;
|
|
}
|
|
if mono {
|
|
let parents: Vec<usize> = (i..j).filter(|&k| glyphs.get(k).is_some_and(|g| g.len > 0)).collect();
|
|
if let Some(readings) = designcraft_doc::ruby::mono_readings(&text, parents.len()) {
|
|
for (u, (&k, reading)) in parents.iter().zip(readings).enumerate() {
|
|
let end = parents.get(u + 1).copied().unwrap_or(j);
|
|
let Some(mut rs) = glyphs.get(k).and_then(|g| styles.get(g.style as usize)).cloned() else { continue };
|
|
rs.ruby = (!reading.is_empty()).then(|| reading.to_string());
|
|
rs.ruby_unit = Some(u32::try_from(u).unwrap_or(u32::MAX));
|
|
let idx = intern(styles, rs);
|
|
for g in glyphs.get_mut(k..end).unwrap_or_default() {
|
|
g.style = idx;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
i = j;
|
|
}
|
|
// Parent spacing and breaks, group by group.
|
|
let mut i = 0;
|
|
while i < glyphs.len() {
|
|
let Some(st) = glyphs.get(i).and_then(|g| styles.get(g.style as usize)) else {
|
|
i += 1;
|
|
continue;
|
|
};
|
|
let (Some((text, unit)), Some(spec)) = (ruby_key(st).map(|(t, u)| (t.to_string(), u)), st.ruby_spec.clone()) else {
|
|
i += 1;
|
|
continue;
|
|
};
|
|
let missing = st.missing_font;
|
|
let mut j = i + 1;
|
|
while glyphs.get(j).is_some_and(|g| in_group(styles.as_slice(), g, (text.as_str(), unit))) {
|
|
j += 1;
|
|
}
|
|
let parents: Vec<usize> = (i..j).filter(|&k| glyphs.get(k).is_some_and(|g| g.len > 0)).collect();
|
|
let (Some(&first), Some(&last)) = (parents.first(), parents.last()) else {
|
|
i = j;
|
|
continue;
|
|
};
|
|
let Some((face, parent_size)) = glyphs.get(first).map(|g| (g.face, g.size)) else {
|
|
i = j;
|
|
continue;
|
|
};
|
|
let shaped = shape_ruby(db, face, &text, &spec, parent_size, fallback || missing, vertical);
|
|
let width: f64 = glyphs.get(i..j).unwrap_or_default().iter().map(|g| g.adv).sum();
|
|
let mono = unit.is_some();
|
|
let side = |k: Option<usize>| -> Side {
|
|
let Some(g) = k.and_then(|k| glyphs.get(k)) else { return Side::Edge };
|
|
if g.ch.is_control() || g.ch == crate::shape::HIDDEN {
|
|
return Side::Edge;
|
|
}
|
|
match styles.get(g.style as usize) {
|
|
Some(s) if s.ruby.is_some() => Side::Ruby { free: unit_free(db, g.face, g.adv, s, mono, fallback, vertical) },
|
|
_ => Side::Char { ch: g.ch, adv: g.adv },
|
|
}
|
|
};
|
|
let before = allowance(&spec, shaped.em, parent_size, side(i.checked_sub(1)), true);
|
|
let after = allowance(&spec, shaped.em, parent_size, side(Some(j)), false);
|
|
let (before, after) = usable(spec.alignment, before, after);
|
|
let w = shaped.width * auto_scale(&spec, shaped.width, width, before + after);
|
|
if let Some((lead, inner, trail)) = parent_spacing(spec.parent_spacing, parents.len(), w - width - before - after) {
|
|
if let Some(g) = glyphs.get_mut(first) {
|
|
g.dx += lead;
|
|
g.adv += lead;
|
|
}
|
|
for pair in parents.windows(2) {
|
|
if let Some(g) = pair.get(1).and_then(|n| n.checked_sub(1)).and_then(|k| glyphs.get_mut(k)) {
|
|
g.adv += inner;
|
|
}
|
|
}
|
|
if let Some(g) = j.checked_sub(1).and_then(|k| glyphs.get_mut(k)) {
|
|
g.adv += trail;
|
|
}
|
|
}
|
|
// The overhang this relies on needs the neighbour on the same line: at a line edge the
|
|
// edge takes none (auto-align) or only the overhang amount, so the group stays with each
|
|
// neighbour its ruby overhangs and the spacing reserved here is what `annotate` places.
|
|
let spaced: f64 = glyphs.get(i..j).unwrap_or_default().iter().map(|g| g.adv).sum();
|
|
let excess = w - spaced;
|
|
if excess > 1e-9 {
|
|
let lead = lead_overhang(spec.alignment, excess, before, after);
|
|
if lead > 1e-9
|
|
&& let Some(g) = i.checked_sub(1).and_then(|k| glyphs.get_mut(k))
|
|
{
|
|
g.break_after = Some(false);
|
|
}
|
|
if excess - lead > 1e-9
|
|
&& let Some(g) = j.checked_sub(1).and_then(|k| glyphs.get_mut(k))
|
|
{
|
|
g.break_after = Some(false);
|
|
}
|
|
}
|
|
// Group ruby keeps its parent on one line (JLReq 3.3).
|
|
if !mono {
|
|
for g in glyphs.get_mut(first..last).unwrap_or_default() {
|
|
g.break_after = Some(false);
|
|
}
|
|
}
|
|
i = j;
|
|
}
|
|
}
|
|
|
|
// ---------- after line breaking ----------
|
|
|
|
/// The run style ruby glyphs are drawn in: the parent's, with the ruby's colours and none of the
|
|
/// parent's decorations.
|
|
fn ruby_style(styles: &mut Vec<RunStyle>, base: &RunStyle, spec: &RubySpec, size: f64, missing: bool) -> u32 {
|
|
let mut rs = base.clone();
|
|
if spec.fill.is_empty() {
|
|
if let Some(t) = spec.fill_tint {
|
|
rs.fill_tint = t;
|
|
}
|
|
} else {
|
|
rs.fill.clone_from(&spec.fill);
|
|
rs.fill_tint = spec.fill_tint.unwrap_or(1.0);
|
|
}
|
|
if spec.stroke.is_empty() {
|
|
if let Some(t) = spec.stroke_tint {
|
|
rs.stroke_tint = t;
|
|
}
|
|
} else {
|
|
rs.stroke.clone_from(&spec.stroke);
|
|
rs.stroke_tint = spec.stroke_tint.unwrap_or(1.0);
|
|
}
|
|
if let Some(w) = spec.stroke_weight {
|
|
rs.stroke_weight = w;
|
|
}
|
|
// Text itself never overprints, so `Auto` (follow the text) is off.
|
|
rs.overprint_fill = spec.overprint_fill == AdornmentOverprint::On;
|
|
rs.overprint_stroke = spec.overprint_stroke == AdornmentOverprint::On;
|
|
rs.size = size;
|
|
rs.missing_font = missing;
|
|
rs.underline = false;
|
|
rs.strikethrough = false;
|
|
rs.custom_tracking = false;
|
|
rs.condition = None;
|
|
rs.xml_tag = None;
|
|
rs.ruby = None;
|
|
rs.ruby_spec = None;
|
|
rs.ruby_unit = None;
|
|
rs.kenten_mark = None;
|
|
rs.warichu = false;
|
|
intern(styles, rs)
|
|
}
|
|
|
|
/// How far the ruby must move away from its parent (down for ruby below, up for ruby above: the
|
|
/// result is signed along y) so that it clears kenten set on the same side of the parent
|
|
/// characters. Kenten sit next to the parent (JIS X 4051, JLReq: emphasis dots go between the
|
|
/// base characters and ruby on the same side); the ruby's em box then starts where the marks' em
|
|
/// box ends. `y` is the ruby's baseline without kenten and `tall` its em height.
|
|
fn kenten_clearance(styles: &[RunStyle], base: &[&PlacedGlyph], shaped: &Shaped, tall: f64, position: RubyPosition, y: f64) -> f64 {
|
|
let below = position == RubyPosition::BelowLeft;
|
|
let (ruby_top, ruby_bottom) = shaped.face.em_box();
|
|
let k = tall / shaped.face.units_per_em().max(1.0);
|
|
let overlap = base
|
|
.iter()
|
|
.filter_map(|b| {
|
|
let m = styles.get(b.style as usize)?.kenten_mark.as_ref().filter(|m| m.below == below)?;
|
|
let (top, bottom) = b.face.em_box();
|
|
let h = m.size * m.y_scale;
|
|
Some(if below {
|
|
(b.y - bottom * b.sy + m.distance + h) - (y - ruby_top * k)
|
|
} else {
|
|
(y - ruby_bottom * k) - (b.y - top * b.sy - m.distance - h)
|
|
})
|
|
})
|
|
.fold(0.0, f64::max);
|
|
if below { overlap } else { -overlap }
|
|
}
|
|
|
|
/// Add the ruby of a laid-out line's glyphs. `text` is the story text the glyphs'
|
|
/// bytes index; `glyph_fallback`: the document draws missing glyphs from fallback fonts (a
|
|
/// missing font's substitute always does); `vertical`: the frame is vertical.
|
|
pub(crate) fn annotate(
|
|
db: &ScopedFonts<'_>,
|
|
styles: &mut Vec<RunStyle>,
|
|
text: &str,
|
|
line: &mut Vec<PlacedGlyph>,
|
|
glyph_fallback: bool,
|
|
vertical: bool,
|
|
) {
|
|
if !line.iter().any(|g| styles.get(g.style as usize).is_some_and(|s| s.ruby.is_some())) {
|
|
return;
|
|
}
|
|
let mut extra = Vec::new();
|
|
let mut i = 0;
|
|
while i < line.len() {
|
|
let Some(g) = line.get(i) else { break };
|
|
let Some(st) = styles.get(g.style as usize).filter(|_| g.len > 0 && g.visible).cloned() else {
|
|
i += 1;
|
|
continue;
|
|
};
|
|
let fallback = glyph_fallback || st.missing_font;
|
|
let (Some(reading), Some(spec)) = (st.ruby.clone(), st.ruby_spec.clone()) else {
|
|
i += 1;
|
|
continue;
|
|
};
|
|
// The group: following glyphs with the same ruby (and unit).
|
|
let key = (reading.as_str(), st.ruby_unit);
|
|
let mut j = i + 1;
|
|
while line.get(j).is_some_and(|b| b.len == 0 || styles.get(b.style as usize).and_then(ruby_key).is_some_and(|k| k == key)) {
|
|
j += 1;
|
|
}
|
|
let group = line.get(i..j).unwrap_or_default();
|
|
let base: Vec<&PlacedGlyph> = group.iter().filter(|b| b.len > 0).collect();
|
|
let Some(&first) = base.first() else {
|
|
i = j;
|
|
continue;
|
|
};
|
|
// The parent's extent from its pen positions (parent spacing lies inside it).
|
|
let x0 = base.iter().map(|b| b.x - b.dx).fold(f64::MAX, f64::min);
|
|
let x1 = base.iter().map(|b| b.x - b.dx + b.adv).fold(f64::MIN, f64::max);
|
|
let width = (x1 - x0).max(0.0);
|
|
let shaped = shape_ruby(db, first.face, &reading, &spec, st.size, fallback, vertical);
|
|
let mono = st.ruby_unit.is_some();
|
|
let side = |b: Option<&PlacedGlyph>| -> Side {
|
|
let Some(b) = b.filter(|b| b.visible) else { return Side::Edge };
|
|
match styles.get(b.style as usize) {
|
|
Some(s) if s.ruby.is_some() => Side::Ruby { free: unit_free(db, b.face, b.adv, s, mono, glyph_fallback, vertical) },
|
|
_ => Side::Char { ch: text.get(b.byte..).and_then(|s| s.chars().next()).unwrap_or(' '), adv: b.adv },
|
|
}
|
|
};
|
|
let prev = line.get(..i).unwrap_or_default().iter().rev().find(|b| b.len > 0);
|
|
let next = line.get(j..).unwrap_or_default().iter().find(|b| b.len > 0);
|
|
let before = allowance(&spec, shaped.em, st.size, side(prev), true);
|
|
let after = allowance(&spec, shaped.em, st.size, side(next), false);
|
|
let (before, after) = usable(spec.alignment, before, after);
|
|
let scale = auto_scale(&spec, shaped.width, width, before + after);
|
|
let w = shaped.width * scale;
|
|
// Each glyph's offset from the parent's start.
|
|
let offsets: Vec<f64> = if w <= width + 1e-9 {
|
|
let mut slots: Vec<f64> = Vec::new();
|
|
for r in &shaped.glyphs {
|
|
match slots.last_mut() {
|
|
Some(a) if r.joined => *a += r.adv,
|
|
_ => slots.push(r.adv),
|
|
}
|
|
}
|
|
let at = spread(spec.alignment, width, &slots, shaped.em);
|
|
let (mut slot, mut slot_x) = (0usize, 0.0);
|
|
let mut out = Vec::with_capacity(shaped.glyphs.len());
|
|
for (n, r) in shaped.glyphs.iter().enumerate() {
|
|
if !r.joined {
|
|
if n > 0 {
|
|
slot += 1;
|
|
}
|
|
slot_x = r.x;
|
|
}
|
|
out.push(at.get(slot).copied().unwrap_or(0.0) + (r.x - slot_x));
|
|
}
|
|
out
|
|
} else {
|
|
let lead = lead_overhang(spec.alignment, w - width, before, after);
|
|
shaped.glyphs.iter().map(|r| r.x * scale - lead).collect()
|
|
};
|
|
let tall = shaped.size * spec.y_scale;
|
|
let y = match spec.position {
|
|
// Its em box sits just clear of the parent's.
|
|
RubyPosition::AboveRight => -(st.size * 0.88 + tall * 0.2) - spec.y_offset,
|
|
RubyPosition::BelowLeft => st.size * 0.12 + tall * 0.96 + spec.y_offset,
|
|
};
|
|
let y = y + kenten_clearance(styles, &base, &shaped, tall, spec.position, y);
|
|
let missing = if spec.font_family.is_empty() { st.missing_font } else { !db.has_family(&spec.font_family) };
|
|
let style = ruby_style(styles, &st, &spec, shaped.size, missing);
|
|
for (r, off) in shaped.glyphs.iter().zip(offsets) {
|
|
extra.push(PlacedGlyph {
|
|
rendered_char: r.ch,
|
|
face: shaped.face,
|
|
gid: r.gid,
|
|
x: x0 + off + spec.x_offset,
|
|
y: y + r.y,
|
|
adv: r.adv * scale,
|
|
sx: r.sx * scale,
|
|
sy: r.sy,
|
|
style,
|
|
byte: first.byte,
|
|
len: 0,
|
|
generated_text: None,
|
|
visible: true,
|
|
upright: r.tcy.is_none() && upright_in_vertical(r.ch),
|
|
tcy: r.tcy,
|
|
rtl: false,
|
|
dx: 0.0,
|
|
});
|
|
}
|
|
i = j;
|
|
}
|
|
line.extend(extra);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn shorter_ruby_spreads_by_its_alignment() {
|
|
// A 24 pt parent and three 6 pt ruby characters: 6 pt to spare.
|
|
assert_eq!(spread(RubyAlignment::Jis, 24.0, &[6.0, 6.0, 6.0], 6.0), [1.0, 9.0, 17.0]);
|
|
assert_eq!(spread(RubyAlignment::EqualAki, 24.0, &[6.0, 6.0, 6.0], 6.0), [1.5, 9.0, 16.5]);
|
|
assert_eq!(spread(RubyAlignment::OneAki, 24.0, &[6.0, 6.0, 6.0], 6.0), [3.0, 9.0, 15.0]);
|
|
assert_eq!(spread(RubyAlignment::FullJustify, 24.0, &[6.0, 6.0, 6.0], 6.0), [0.0, 9.0, 18.0]);
|
|
assert_eq!(spread(RubyAlignment::Right, 24.0, &[6.0, 6.0, 6.0], 6.0), [6.0, 12.0, 18.0]);
|
|
}
|
|
|
|
#[test]
|
|
fn longer_ruby_leans_to_the_side_it_may_overhang() {
|
|
// 6 pt too long; only the following kana takes overhang.
|
|
assert_eq!(lead_overhang(RubyAlignment::Jis, 6.0, 0.0, 6.0), 0.0);
|
|
assert_eq!(lead_overhang(RubyAlignment::Jis, 6.0, 6.0, 6.0), 3.0);
|
|
assert_eq!(lead_overhang(RubyAlignment::Jis, 6.0, 6.0, 0.0), 6.0);
|
|
// Neither side takes enough: centred.
|
|
assert_eq!(lead_overhang(RubyAlignment::Jis, 6.0, 1.0, 1.0), 3.0);
|
|
}
|
|
|
|
#[test]
|
|
fn overhang_goes_over_kana_and_the_blank_half_of_punctuation_only() {
|
|
let spec = RubySpec::of(&designcraft_doc::CharProps::default());
|
|
let kana = Side::Char { ch: 'で', adv: 12.0 };
|
|
let kanji = Side::Char { ch: '字', adv: 12.0 };
|
|
let stop = Side::Char { ch: '。', adv: 12.0 };
|
|
assert_eq!(allowance(&spec, 6.0, 12.0, kana, false), 6.0);
|
|
assert_eq!(allowance(&spec, 6.0, 12.0, kanji, false), 0.0);
|
|
assert_eq!(allowance(&spec, 6.0, 12.0, stop, false), 6.0);
|
|
assert_eq!(allowance(&spec, 6.0, 12.0, stop, true), 0.0);
|
|
assert_eq!(allowance(&spec, 6.0, 12.0, Side::Edge, true), 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn parent_spacing_splits_the_extra_room() {
|
|
assert_eq!(parent_spacing(RubyParentSpacing::Aki121, 2, 8.0), Some((2.0, 4.0, 2.0)));
|
|
assert_eq!(parent_spacing(RubyParentSpacing::FullJustify, 1, 8.0), Some((4.0, 0.0, 4.0)));
|
|
assert_eq!(parent_spacing(RubyParentSpacing::NoAdjustment, 2, 8.0), None);
|
|
assert_eq!(parent_spacing(RubyParentSpacing::Aki121, 2, f64::NAN), None);
|
|
}
|
|
|
|
#[test]
|
|
fn capped_cuts_long_readings_at_a_char_boundary() {
|
|
let long = "あ".repeat(MAX_RUBY_CHARS + 10);
|
|
assert_eq!(capped(&long).chars().count(), MAX_RUBY_CHARS);
|
|
}
|
|
}
|