mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
In vertical type every character now sits in its own cell down the column: "10" and "22" in 「10月22日」 stand side by side in one em, centred on the column, and 月/日 start right after them. Justified (Justify All) columns no longer split a tate-chu-yoko block, and tracked or justified upright characters stay on the column's centre line instead of drifting across it. Root cause: in 0.3.1 (the reporter's build) upright glyphs were placed by the font's ascent rather than the ideographic em box, so a font with a tall ascent (Noto Sans CJK JP, 1.16 em) drew each upright character ~0.28 em down into the next cell, onto the sideways digits; #208 (after 0.3.1) replaced that with em-box placement and tate-chu-yoko, which this commit locks in with regression tests. Two defects of the same kind remained: Justify All added its inter-character gap inside a tate-chu-yoko block (the block's second digit slid down the column, away from the first), and upright glyphs turned about the middle of their advance, which tracking and justification lengthen, instead of the middle of their own cell. Tests: the issue's layout with bundled glyphs (cells one after the other, every ink inside its cell and no overlaps, 2-3 digit blocks one em and centred, single digits and 2026 on their side advancing by their length, spaces), the issue's string with craft-fonts' Japanese face (skips without craft-fonts), Justify All with a block, tracking. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
322 lines
12 KiB
Rust
322 lines
12 KiB
Rust
//! Shaping: text runs -> positioned glyphs in points (before line breaking).
|
||
|
||
use std::ops::Range;
|
||
use std::sync::Arc;
|
||
|
||
use harfrust::{Direction, Feature, ShapeOptions, UnicodeBuffer};
|
||
use skrifa::MetadataProvider;
|
||
use skrifa::instance::{LocationRef, Size};
|
||
use vectorcraft_doc::CharStyle;
|
||
|
||
use crate::features::OtFeatures;
|
||
use crate::fontdb::{FontDb, FontFace};
|
||
|
||
/// A shaped glyph with all character-style effects resolved, in points (y down).
|
||
#[derive(Clone, Debug)]
|
||
pub(crate) struct SGlyph {
|
||
pub face: Arc<FontFace>,
|
||
pub gid: u32,
|
||
/// Byte offset of the cluster in the plain text, and its length in bytes.
|
||
pub byte: usize,
|
||
pub len: usize,
|
||
pub run: usize,
|
||
/// Advance in points (tracking, manual kerning and horizontal scale applied).
|
||
pub adv: f64,
|
||
/// Offset from the pen position, y down.
|
||
pub dx: f64,
|
||
pub dy: f64,
|
||
/// Outline scale (font units -> points).
|
||
pub sx: f64,
|
||
pub sy: f64,
|
||
/// Baseline shift in points (positive = up).
|
||
pub bshift: f64,
|
||
/// Character rotation in degrees (counter-clockwise).
|
||
pub rotation: f64,
|
||
pub ascent: f64,
|
||
pub descent: f64,
|
||
pub leading: f64,
|
||
/// Cap height and x height in points.
|
||
pub cap: f64,
|
||
pub xh: f64,
|
||
/// First source character of the cluster.
|
||
pub ch: char,
|
||
/// Vertical type: part of a tate-chu-yoko block (set across the column, upright).
|
||
pub tcy: Option<Tcy>,
|
||
}
|
||
|
||
/// A glyph's place in a tate-chu-yoko block: the block takes one em of the column, its glyphs side
|
||
/// by side across it.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub(crate) struct Tcy {
|
||
/// Laid-out distance from the block's start to this glyph's pen position.
|
||
pub pen: f64,
|
||
/// Distance from the block's start to the glyph in the block's own (horizontal) setting.
|
||
pub ink: f64,
|
||
/// Width of the block's own setting.
|
||
pub width: f64,
|
||
/// Horizontal scale that fits the block into one em (1 when it fits as it is).
|
||
pub squeeze: f64,
|
||
}
|
||
|
||
impl SGlyph {
|
||
pub fn is_space(&self) -> bool {
|
||
matches!(self.ch, ' ' | '\t' | '\u{3000}' | '\u{2002}'..='\u{200B}')
|
||
}
|
||
/// A line may break after this glyph.
|
||
pub fn break_after(&self) -> bool {
|
||
self.is_space() || matches!(self.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/' | SOFT_HYPHEN) || is_cjk(self.ch)
|
||
}
|
||
/// A soft (discretionary) hyphen: invisible unless a line breaks after it.
|
||
pub fn is_soft_hyphen(&self) -> bool {
|
||
self.ch == SOFT_HYPHEN
|
||
}
|
||
/// Part of a word that may be hyphenated (letters and apostrophes).
|
||
pub fn is_letter(&self) -> bool {
|
||
self.ch.is_alphabetic() || matches!(self.ch, '\'' | '’')
|
||
}
|
||
/// Vertical type: a tate-chu-yoko glyph after its block's first, sharing that glyph's cell.
|
||
pub fn continues_tcy(&self) -> bool {
|
||
self.tcy.is_some_and(|t| t.pen > 0.0)
|
||
}
|
||
}
|
||
|
||
pub(crate) const SOFT_HYPHEN: char = '\u{00AD}';
|
||
|
||
/// A visible hyphen in the face and size of `g`, placed at the end of `g`'s cluster (zero source
|
||
/// length) for a line broken inside a word.
|
||
pub(crate) fn hyphen_glyph(g: &SGlyph) -> SGlyph {
|
||
let gid = ['-', '\u{2010}', SOFT_HYPHEN].into_iter().map(|c| g.face.glyph_for(c)).find(|&id| id != 0).unwrap_or(0);
|
||
let mut h = g.clone();
|
||
h.gid = gid;
|
||
h.adv = g.face.advance(gid) * g.sx;
|
||
h.dx = 0.0;
|
||
h.dy = 0.0;
|
||
h.byte = g.byte + g.len;
|
||
h.len = 0;
|
||
h.ch = '-';
|
||
h
|
||
}
|
||
|
||
/// Kinsoku (Japanese line breaking, the strict set): a character that can't start a line —
|
||
/// closing brackets, the Japanese comma and full stop, middle dots, colons, ! and ?, the long
|
||
/// vowel mark, iteration marks and small kana.
|
||
pub(crate) fn no_line_start(c: char) -> bool {
|
||
matches!(c,
|
||
')' | ']' | '}' | ',' | '.' | ':' | ';' | '!' | '?' | '»' | '’' | '”' | '‐' | '–' | '‼' | '⁇' | '⁈' | '⁉'
|
||
| '、' | '。' | '〉' | '》' | '」' | '』' | '】' | '〕' | '〗' | '〙' | '〛' | '〟' | '〜' | '゠' | '・' | '⦆'
|
||
| 'ー' | 'ゝ' | 'ゞ' | 'ヽ' | 'ヾ' | '々' | '〻'
|
||
| 'ぁ' | 'ぃ' | 'ぅ' | 'ぇ' | 'ぉ' | 'っ' | 'ゃ' | 'ゅ' | 'ょ' | 'ゎ' | 'ゕ' | 'ゖ'
|
||
| 'ァ' | 'ィ' | 'ゥ' | 'ェ' | 'ォ' | 'ッ' | 'ャ' | 'ュ' | 'ョ' | 'ヮ' | 'ヵ' | 'ヶ' | 'ㇰ'..='ㇿ'
|
||
| '!' | ')' | ',' | '.' | ':' | ';' | '?' | ']' | '}' | '~' | '。' | '」' | '、' | '・' | 'ー' | 'ァ'..='ッ')
|
||
}
|
||
|
||
/// Kinsoku: a character that can't end a line (opening brackets).
|
||
pub(crate) fn no_line_end(c: char) -> bool {
|
||
matches!(
|
||
c,
|
||
'(' | '['
|
||
| '{'
|
||
| '«'
|
||
| '‘'
|
||
| '“'
|
||
| '〈'
|
||
| '《'
|
||
| '「'
|
||
| '『'
|
||
| '【'
|
||
| '〔'
|
||
| '〖'
|
||
| '〘'
|
||
| '〚'
|
||
| '〝'
|
||
| '('
|
||
| '['
|
||
| '{'
|
||
| '⦅'
|
||
| '「'
|
||
)
|
||
}
|
||
|
||
fn is_cjk(c: char) -> bool {
|
||
matches!(c as u32, 0x2E80..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x2FFFF)
|
||
}
|
||
|
||
/// Vertical metrics (points) of a style's resolved face: (ascent, descent, leading).
|
||
pub(crate) fn style_metrics(db: &FontDb, st: &CharStyle) -> (f64, f64, f64) {
|
||
let vs = st.v_scale / 100.0;
|
||
let Some(face) = db.face(&st.font_family, &st.font_style) else { return (st.size * 0.8 * vs, st.size * 0.2 * vs, st.effective_leading()) };
|
||
let k = st.size / face.upem;
|
||
(face.ascent * k * vs, face.descent * k * vs, st.effective_leading())
|
||
}
|
||
|
||
/// Cap height and x height (points) of a style's resolved face.
|
||
pub(crate) fn cap_x_heights(db: &FontDb, st: &CharStyle) -> (f64, f64) {
|
||
let vs = st.v_scale / 100.0;
|
||
let Some(face) = db.face(&st.font_family, &st.font_style) else { return (st.size * 0.7 * vs, st.size * 0.5 * vs) };
|
||
let k = st.size / face.upem * vs;
|
||
(face.cap_height * k, face.x_height * k)
|
||
}
|
||
|
||
/// Shape `text[range]`, where `runs` gives each run's byte range in `text` and style.
|
||
pub(crate) fn shape_range(
|
||
db: &FontDb,
|
||
text: &str,
|
||
range: Range<usize>,
|
||
runs: &[(Range<usize>, &CharStyle)],
|
||
feats: &OtFeatures,
|
||
out: &mut Vec<SGlyph>,
|
||
) {
|
||
for (ri, (rr, st)) in runs.iter().enumerate() {
|
||
let a = rr.start.max(range.start);
|
||
let b = rr.end.min(range.end);
|
||
if a >= b {
|
||
continue;
|
||
}
|
||
let Some(primary) = db.face(&st.font_family, &st.font_style) else { continue };
|
||
let pmap = primary.skrifa().map(|f| f.charmap());
|
||
// Synthesized Small Caps shape lowercase letters separately (as smaller capitals).
|
||
let small_caps = st.small_caps.is_some() && !st.all_caps;
|
||
// Split into segments by font coverage (and case, for Small Caps).
|
||
let mut seg = Segment { range: a..a, run: ri, st, face: primary.clone(), small: false };
|
||
let mut cache: Vec<(char, Arc<FontFace>)> = Vec::new();
|
||
for (i, c) in text[a..b].char_indices() {
|
||
let i = a + i;
|
||
let covered = c.is_whitespace() || c.is_control() || pmap.as_ref().is_none_or(|m| m.map(c).is_some());
|
||
let face = if covered {
|
||
primary.clone()
|
||
} else if let Some((_, f)) = cache.iter().find(|(k, _)| *k == c) {
|
||
f.clone()
|
||
} else {
|
||
let f = db.fallback_for(c, primary.id()).unwrap_or_else(|| primary.clone());
|
||
cache.push((c, f.clone()));
|
||
f
|
||
};
|
||
let small = small_caps && c.is_lowercase();
|
||
// Combining marks stay with their base.
|
||
if (face.id() != seg.face.id() || small != seg.small) && !is_mark(c) {
|
||
if i > seg.range.start {
|
||
seg.range.end = i;
|
||
shape_segment(text, &seg, feats, out);
|
||
}
|
||
seg = Segment { range: i..i, face, small, ..seg };
|
||
}
|
||
}
|
||
if b > seg.range.start {
|
||
seg.range.end = b;
|
||
shape_segment(text, &seg, feats, out);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A piece of one run shaped in one go: one face and, for Small Caps, one case.
|
||
struct Segment<'a> {
|
||
range: Range<usize>,
|
||
run: usize,
|
||
st: &'a CharStyle,
|
||
face: Arc<FontFace>,
|
||
/// Lowercase letters drawn as synthesized small capitals.
|
||
small: bool,
|
||
}
|
||
|
||
fn is_mark(c: char) -> bool {
|
||
matches!(c as u32, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF | 0xFE20..=0xFE2F | 0x200D | 0xFE00..=0xFE0F)
|
||
}
|
||
|
||
fn shape_segment(text: &str, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SGlyph>) {
|
||
let Segment { range, run, st, face, small } = seg;
|
||
let (range, run, small) = (range.clone(), *run, *small);
|
||
let text_seg = &text[range.clone()];
|
||
let full = st.size.max(0.0);
|
||
// Superscript/subscript and small capitals shrink the glyphs; line metrics keep the full size.
|
||
let (pos_scale, pos_shift) = st.position.scale_shift(full);
|
||
let small_scale = if small { st.small_caps.unwrap_or(100.0) / 100.0 } else { 1.0 };
|
||
let size = full * pos_scale * small_scale;
|
||
let k = size / face.upem;
|
||
let km = full / face.upem;
|
||
let hs = st.h_scale / 100.0;
|
||
let vs = st.v_scale / 100.0;
|
||
let tracking = st.tracking / 1000.0 * size;
|
||
let manual_kern = st.kerning.map(|v| v / 1000.0 * size).unwrap_or(0.0);
|
||
let ascent = face.ascent * km * vs;
|
||
let descent = face.descent * km * vs;
|
||
let leading = st.effective_leading();
|
||
let cap = face.cap_height * km * vs;
|
||
let xh = face.x_height * km * vs;
|
||
let upper = st.all_caps || small;
|
||
let first_char = |byte: usize| text[byte..].chars().next().unwrap_or(' ');
|
||
|
||
let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::with_capacity(text_seg.len()); // gid, cluster, xadv, xoff, yoff
|
||
let shaped = face.hb().map(|hb| {
|
||
let shaper = face.shaper.shaper(&hb).build();
|
||
let mut buf = UnicodeBuffer::new();
|
||
for (i, c) in text_seg.char_indices() {
|
||
let cl = (range.start + i) as u32;
|
||
if upper {
|
||
for u in c.to_uppercase() {
|
||
buf.add(u, cl);
|
||
}
|
||
} else {
|
||
buf.add(c, cl);
|
||
}
|
||
}
|
||
buf.set_direction(Direction::LeftToRight);
|
||
buf.guess_segment_properties();
|
||
let feats: Vec<Feature> = feats.resolve(st);
|
||
let gb = shaper.shape(buf, ShapeOptions::new().features(&feats));
|
||
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
|
||
raw.push((info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset));
|
||
}
|
||
});
|
||
if shaped.is_none() {
|
||
// Fallback: nominal glyphs and hmtx advances, no shaping.
|
||
if let Some(f) = face.skrifa() {
|
||
let cmap = f.charmap();
|
||
let gm = f.glyph_metrics(Size::unscaled(), LocationRef::default());
|
||
for (i, c) in text_seg.char_indices() {
|
||
let cl = (range.start + i) as u32;
|
||
let chars: Vec<char> = if upper { c.to_uppercase().collect() } else { vec![c] };
|
||
for u in chars {
|
||
let g = cmap.map(u).unwrap_or_default();
|
||
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
|
||
raw.push((g.to_u32(), cl, adv.round() as i32, 0, 0));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let n = raw.len();
|
||
for (gi, &(gid, cl, xa, xo, yo)) in raw.iter().enumerate() {
|
||
let cl = cl as usize;
|
||
// Cluster end: the next larger cluster value in the segment, else the segment end.
|
||
let end = raw[gi + 1..].iter().map(|r| r.1 as usize).find(|&c| c > cl).unwrap_or(range.end);
|
||
let last_in_cluster = gi + 1 == n || raw[gi + 1].1 as usize != cl;
|
||
let ch = first_char(cl);
|
||
let mut adv = xa as f64 * k * hs;
|
||
if ch == SOFT_HYPHEN {
|
||
adv = 0.0;
|
||
} else if last_in_cluster {
|
||
adv += tracking + manual_kern;
|
||
}
|
||
out.push(SGlyph {
|
||
face: face.clone(),
|
||
gid,
|
||
byte: cl,
|
||
len: end.saturating_sub(cl).max(1),
|
||
run,
|
||
adv,
|
||
dx: xo as f64 * k * hs,
|
||
dy: -(yo as f64) * k * vs,
|
||
sx: k * hs,
|
||
sy: k * vs,
|
||
bshift: st.baseline_shift + pos_shift,
|
||
rotation: st.rotation,
|
||
ascent,
|
||
descent,
|
||
leading,
|
||
cap,
|
||
xh,
|
||
ch,
|
||
tcy: None,
|
||
});
|
||
}
|
||
}
|