Files
designcraft/crates/compose/src/shape.rs
T

567 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Paragraph → styled, positioned glyphs (before line breaking).
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use designcraft_doc::{Capitalization, CharProps, Kerning, Leading, Position, Story, Styles, story};
use designcraft_fonts::{FaceRef, Feature, FontDb, FontFace, ShapedGlyph, feature};
use crate::RunStyle;
/// One glyph with every character attribute applied, in points.
#[derive(Clone, Debug)]
pub struct Glyph {
pub face: FaceRef,
pub gid: u32,
/// Story byte offset of the cluster and its byte length (0 for generated glyphs).
pub byte: usize,
pub len: usize,
/// First source character of the cluster (special characters keep their code).
pub ch: char,
/// Advance in points (tracking and horizontal scale applied).
pub adv: f64,
pub dx: f64,
pub dy: f64,
/// Font units → points (incl. horizontal / vertical scale).
pub sx: f64,
pub sy: f64,
/// Baseline shift (positive = up), including super/subscript.
pub shift: f64,
pub ascent: f64,
pub descent: f64,
/// The leading this character asks for (absolute, or auto = size × auto %).
pub leading: f64,
pub cap: f64,
pub xh: f64,
pub size: f64,
/// Index into the composed story's [`RunStyle`] table.
pub style: u32,
/// Unbreakable (No Break).
pub no_break: bool,
/// 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,
}
impl Glyph {
pub fn is_space(&self) -> bool {
matches!(self.ch, ' ' | '\u{2002}'..='\u{200A}' | '\u{3000}')
}
pub fn is_letter(&self) -> bool {
self.ch.is_alphabetic() || matches!(self.ch, '\'' | '’')
}
}
pub const SOFT_HYPHEN: char = '\u{AD}';
/// Shaped paragraph.
pub struct ShapedPara {
pub glyphs: Vec<Glyph>,
/// Byte range of the paragraph text (without its `\n`).
pub range: std::ops::Range<usize>,
}
/// Context for substitutions (page number markers).
#[derive(Clone, Debug, Default)]
pub struct SubstCtx {
pub page_name: Option<String>,
pub section_marker: Option<String>,
/// Text variable values by index (on the page being composed).
pub vars: Arc<Vec<String>>,
/// Footnote reference labels by story byte of the reference character.
pub notes: HashMap<usize, String>,
/// Footnote reference formatting: position and character style (`None` = the text's).
pub note_position: designcraft_doc::Position,
pub note_style: Option<String>,
/// Cross-reference texts by story byte of the cross-reference mark.
pub xrefs: HashMap<usize, String>,
/// Anchored objects by story byte of their mark.
pub objects: HashMap<usize, ObjectSpec>,
}
/// Size and placement of an anchored object for line layout.
#[derive(Clone, Copy, Debug, Default)]
pub struct ObjectSpec {
pub index: usize,
pub w: f64,
pub h: f64,
/// Inline: raise above the baseline. Above line: None.
pub y_offset: Option<f64>,
/// Above line: space before + after.
pub space: f64,
}
pub(crate) struct StyleTable<'a> {
pub styles: &'a mut Vec<RunStyle>,
/// Font availability by family (looked up once per composition).
pub missing: &'a mut HashMap<String, bool>,
}
impl StyleTable<'_> {
fn intern(&mut self, p: &CharProps) -> u32 {
let rs = RunStyle {
fill: p.fill.clone(),
fill_tint: p.fill_tint,
stroke: p.stroke.clone(),
stroke_tint: p.stroke_tint,
stroke_weight: p.stroke_weight,
underline: p.underline,
strikethrough: p.strikethrough,
underline_rule: crate::Rule {
offset: p.underline_offset.unwrap_or(p.size * 0.12),
weight: p.underline_weight.unwrap_or(p.size / 14.0),
color: if p.underline_color.is_empty() { p.fill.clone() } else { p.underline_color.clone() },
tint: if p.underline_color.is_empty() { p.fill_tint } else { p.underline_tint },
},
strike_rule: crate::Rule {
offset: -p.strikethrough_offset.unwrap_or(p.size * 0.3),
weight: p.strikethrough_weight.unwrap_or(p.size / 14.0),
color: if p.strikethrough_color.is_empty() { p.fill.clone() } else { p.strikethrough_color.clone() },
tint: if p.strikethrough_color.is_empty() { p.fill_tint } else { p.strikethrough_tint },
},
skew: p.skew,
size: p.size,
missing_font: *self
.missing
.entry(p.font_family.clone())
.or_insert_with(|| !p.font_family.is_empty() && !designcraft_fonts::FontDb::global().has_family(&p.font_family)),
};
if let Some(i) = self.styles.iter().rposition(|s| *s == rs) {
return i as u32;
}
self.styles.push(rs);
(self.styles.len() - 1) as u32
}
}
/// Resolve and shape one paragraph.
pub(crate) fn shape_para(
db: &FontDb,
styles: &Styles,
story: &Story,
pi: usize,
range: std::ops::Range<usize>,
para_chars: &CharProps,
auto_leading: f64,
sub: &SubstCtx,
table: &mut StyleTable<'_>,
nested: &[designcraft_doc::NestedStyle],
grep: &[designcraft_doc::GrepStyle],
) -> ShapedPara {
let _ = pi;
let mut glyphs = Vec::with_capacity(range.len());
let overlays = if nested.is_empty() && grep.is_empty() { Vec::new() } else { crate::overlay::overlays(&story.text, range.clone(), nested, grep) };
// Each run, cut where nested / GREP styles start and end.
let mut segments: Vec<(usize, usize, Option<&str>, &designcraft_doc::CharFormat)> = Vec::new();
for (rr, fmt) in story.runs() {
let a = rr.start.max(range.start);
let b = rr.end.min(range.end);
if a >= b {
continue;
}
if overlays.is_empty() {
segments.push((a, b, None, fmt));
continue;
}
let mut cuts: Vec<usize> = overlays.iter().flat_map(|(r, _)| [r.start, r.end]).filter(|x| *x > a && *x < b).collect();
cuts.push(a);
cuts.push(b);
cuts.sort_unstable();
cuts.dedup();
for w in cuts.windows(2) {
let top = overlays.iter().rev().find(|(r, _)| r.start <= w[0] && w[0] < r.end).map(|(_, s)| s.as_str());
segments.push((w[0], w[1], top, fmt));
}
}
for (a, b, overlay, fmt) in segments {
// A nested / GREP character style sits under the run's own style and overrides.
let base;
let para_chars = match overlay {
Some(st) => {
base = styles.resolve_char(para_chars, &designcraft_doc::CharFormat { style: st.to_string(), over: Default::default() });
&base
}
None => para_chars,
};
let props = styles.resolve_char(para_chars, fmt);
let style = table.intern(&props);
if !story.text[a..b].contains(designcraft_doc::FOOTNOTE_REF) {
shape_run(db, &story.text, a..b, &props, auto_leading, style, sub, &mut glyphs);
continue;
}
// Footnote references take the reference position / character style.
let mut rf = fmt.clone();
if let Some(cs) = &sub.note_style {
rf.style = cs.clone();
}
rf.over.position = Some(sub.note_position);
let rprops = styles.resolve_char(para_chars, &rf);
let rstyle = table.intern(&rprops);
let mut k = a;
for (i, _) in story.text[a..b].match_indices(designcraft_doc::FOOTNOTE_REF) {
let i = a + i;
if k < i {
shape_run(db, &story.text, k..i, &props, auto_leading, style, sub, &mut glyphs);
}
let e = i + designcraft_doc::FOOTNOTE_REF.len_utf8();
shape_run(db, &story.text, i..e, &rprops, auto_leading, rstyle, sub, &mut glyphs);
k = e;
}
if k < b {
shape_run(db, &story.text, k..b, &props, auto_leading, style, sub, &mut glyphs);
}
}
ShapedPara { glyphs, range }
}
fn features_for(p: &CharProps) -> Vec<Feature> {
let mut v = Vec::new();
if !matches!(p.kerning, Kerning::Metrics | Kerning::Optical) {
v.extend(feature("-kern"));
}
if !p.ligatures || p.tracking.abs() > 1e-9 {
v.extend(feature("-liga"));
v.extend(feature("-clig"));
}
if p.capitalization == Capitalization::AllCaps {
v.extend(feature("case"));
}
if matches!(p.capitalization, Capitalization::SmallCaps | Capitalization::OpenTypeAllSmallCaps) {
v.extend(feature("smcp"));
}
if p.capitalization == Capitalization::OpenTypeAllSmallCaps {
v.extend(feature("c2sc"));
}
match p.position {
Position::OtSuperscript => v.extend(feature("sups")),
Position::OtSubscript => v.extend(feature("subs")),
Position::OtNumerator => v.extend(feature("numr")),
Position::OtDenominator => v.extend(feature("dnom")),
_ => {}
}
for f in &p.otf_features {
v.extend(feature(f));
}
v
}
fn is_mark(c: char) -> bool {
matches!(c as u32, 0x0300..=0x036F | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF | 0x20D0..=0x20FF | 0xFE20..=0xFE2F | 0x200D | 0xFE00..=0xFE0F)
}
#[allow(clippy::too_many_arguments)]
fn shape_run(
db: &FontDb,
text: &str,
range: std::ops::Range<usize>,
p: &CharProps,
auto_leading: f64,
style: u32,
sub: &SubstCtx,
out: &mut Vec<Glyph>,
) {
let primary = db.face(&p.font_family, &p.font_style);
// Split into segments: font coverage changes and special characters (markers, tabs, breaks).
let mut seg_start = range.start;
let mut seg_face = primary.clone();
let flush = |a: usize, b: usize, face: &Arc<FontFace>, out: &mut Vec<Glyph>| {
if a < b {
shape_segment(db, text, a..b, None, p, face, auto_leading, style, out);
}
};
for (i, c) in text[range.clone()].char_indices() {
let i = range.start + i;
let special = matches!(
c,
'\t' | story::FORCED_LINE_BREAK
| story::PAGE_NUMBER
| story::NEXT_PAGE_NUMBER
| story::PREV_PAGE_NUMBER
| story::SECTION_MARKER
| story::COLUMN_BREAK
| story::FRAME_BREAK
| story::PAGE_BREAK
| story::INDENT_HERE
| story::RIGHT_INDENT_TAB
| story::TABLE_ANCHOR
| designcraft_doc::FOOTNOTE_REF
| designcraft_doc::XREF_MARK
| designcraft_doc::ANCHOR_MARK
| designcraft_doc::INDEX_MARK
| designcraft_doc::OBJECT_MARK
) || designcraft_doc::vars::var_index(c).is_some();
if special {
flush(seg_start, i, &seg_face, out);
seg_start = i + c.len_utf8();
if let Some(vi) = designcraft_doc::vars::var_index(c) {
match sub.vars.get(vi).filter(|v| !v.is_empty()) {
Some(v) => shape_segment(db, text, i..i + c.len_utf8(), Some(v), p, &primary, auto_leading, style, out),
None => {
let mut g = control_glyph(&primary, p, auto_leading, style, i, c);
g.adv = 0.0;
out.push(g);
}
}
continue;
}
match c {
story::PAGE_NUMBER | story::NEXT_PAGE_NUMBER | story::PREV_PAGE_NUMBER | story::SECTION_MARKER => {
let s = if c == story::SECTION_MARKER {
sub.section_marker.clone().unwrap_or_else(|| "Section".into())
} else {
sub.page_name.clone().unwrap_or_else(|| "#".into())
};
shape_segment(db, text, i..i + c.len_utf8(), Some(&s), p, &primary, auto_leading, style, out);
}
designcraft_doc::OBJECT_MARK => {
let mut g = control_glyph(&primary, p, auto_leading, style, i, c);
if let Some(o) = sub.objects.get(&i) {
let auto = matches!(p.leading, Leading::Auto);
match o.y_offset {
// Sits on the baseline like a (big) character.
Some(y) => {
g.adv = o.w;
g.ascent = g.ascent.max(o.h + y);
g.descent = g.descent.max(-y);
}
// Pushes its line down by its height and spacing.
None => g.ascent += o.h + o.space,
}
if auto || o.y_offset.is_none() {
g.leading = g.leading.max(g.ascent + g.descent);
}
}
out.push(g);
}
designcraft_doc::XREF_MARK => match sub.xrefs.get(&i).filter(|t| !t.is_empty()) {
Some(t) => shape_segment(db, text, i..i + c.len_utf8(), Some(t), p, &primary, auto_leading, style, out),
None => {
let mut g = control_glyph(&primary, p, auto_leading, style, i, c);
g.adv = 0.0;
out.push(g);
}
},
designcraft_doc::FOOTNOTE_REF => {
let s = sub.notes.get(&i).map_or("#", String::as_str);
shape_segment(db, text, i..i + c.len_utf8(), Some(s), p, &primary, auto_leading, style, out);
}
_ => {
// Zero-width control glyph carrying metrics (tabs get their width at line layout).
let mut g = control_glyph(&primary, p, auto_leading, style, i, c);
if c == '\t'
|| c == story::RIGHT_INDENT_TAB
|| c == story::TABLE_ANCHOR
|| c == designcraft_doc::ANCHOR_MARK
|| c == designcraft_doc::INDEX_MARK
{
g.adv = 0.0;
}
out.push(g);
}
}
continue;
}
let covered = c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN || primary.covers(c);
let face = if covered || is_mark(c) {
if is_mark(c) { seg_face.clone() } else { primary.clone() }
} else {
db.fallback_for(c, primary.id()).unwrap_or_else(|| primary.clone())
};
if face.id() != seg_face.id() {
flush(seg_start, i, &seg_face, out);
seg_start = i;
seg_face = face;
}
}
flush(seg_start, range.end, &seg_face, out);
}
type WordMap = HashMap<Box<str>, Arc<[ShapedGlyph]>>;
type WordKey = (u32, bool, String);
/// Shaped words (font units) by (face, caps, features), then text. Lookups take read locks and
/// misses are shaped outside any lock, so parallel composition doesn't serialize here.
static WORD_CACHE: RwLock<Vec<(WordKey, Arc<RwLock<WordMap>>)>> = RwLock::new(Vec::new());
const WORD_CACHE_MAX: usize = 50_000;
fn word_map(key: WordKey) -> Arc<RwLock<WordMap>> {
if let Some((_, m)) = WORD_CACHE.read().unwrap_or_else(|e| e.into_inner()).iter().find(|e| e.0 == key) {
return m.clone();
}
let mut w = WORD_CACHE.write().unwrap_or_else(|e| e.into_inner());
if let Some((_, m)) = w.iter().find(|e| e.0 == key) {
return m.clone();
}
if w.len() > 64 {
w.clear();
}
let m = Arc::new(RwLock::new(HashMap::new()));
w.push((key, m.clone()));
m
}
/// 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> {
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);
}
let words = word_map((face.id(), caps, format!("{feats:?}")));
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());
pieces.iter().map(|p| r.get(*p).cloned()).collect()
};
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();
fresh.push((pieces[k].into(), g.clone()));
*f = Some(g);
}
}
if !fresh.is_empty() {
let mut w = words.write().unwrap_or_else(|e| e.into_inner());
if w.len() + fresh.len() > WORD_CACHE_MAX {
w.clear();
}
w.extend(fresh);
}
let mut out = Vec::with_capacity(src.len());
let mut start = 0;
for (piece, glyphs) in pieces.iter().zip(found) {
let glyphs = glyphs.unwrap_or_else(|| Arc::from(Vec::new()));
out.extend(glyphs.iter().map(|g| ShapedGlyph { cluster: g.cluster + start, ..*g }));
start += piece.len();
}
out
}
fn metrics(face: &FontFace, p: &CharProps, auto_leading: f64) -> (f64, f64, f64, f64, f64, f64, f64, f64) {
let (size, shift) = effective_size(p);
let k = size / face.upem;
let vs = p.v_scale;
let leading = match p.leading {
Leading::Auto => p.size * auto_leading,
Leading::Points(v) => v,
};
(size, k, face.ascent * k * vs, face.descent * k * vs, leading, face.cap_height * k * vs, face.x_height * k * vs, shift)
}
/// Super/subscript use 58.3% size and ±33.3% / −10% shift (InDesign's defaults).
fn effective_size(p: &CharProps) -> (f64, f64) {
match p.position {
Position::Superscript => (p.size * 0.583, p.baseline_shift + p.size * 0.333),
Position::Subscript => (p.size * 0.583, p.baseline_shift - p.size * 0.333 * 0.3),
_ => (p.size, p.baseline_shift),
}
}
fn control_glyph(face: &Arc<FontFace>, p: &CharProps, auto_leading: f64, style: u32, byte: usize, ch: char) -> Glyph {
let (size, k, ascent, descent, leading, cap, xh, shift) = metrics(face, p, auto_leading);
Glyph {
face: FaceRef::of(face),
gid: face.glyph_for(' '),
byte,
len: ch.len_utf8(),
ch,
adv: 0.0,
dx: 0.0,
dy: 0.0,
sx: k * p.h_scale,
sy: k * p.v_scale,
shift,
ascent,
descent,
leading,
cap,
xh,
size,
style,
no_break: p.no_break,
space: face.advance(face.glyph_for(' ')) * k * p.h_scale,
}
}
#[allow(clippy::too_many_arguments)]
fn shape_segment(
db: &FontDb,
text: &str,
range: std::ops::Range<usize>,
replacement: Option<&str>,
p: &CharProps,
face: &Arc<FontFace>,
auto_leading: f64,
style: u32,
out: &mut Vec<Glyph>,
) {
let _ = db;
let (size, k, ascent, descent, leading, cap, xh, shift) = metrics(face, p, auto_leading);
let hs = p.h_scale;
let tracking = p.tracking / 1000.0 * p.size;
let manual = if let Kerning::Manual(v) = p.kerning { v / 1000.0 * p.size } else { 0.0 };
let src = replacement.unwrap_or(&text[range.clone()]);
let caps = p.capitalization == Capitalization::AllCaps;
let feats = features_for(p);
let space = face.advance(face.glyph_for(' ')) * k * hs;
let shaped: Vec<ShapedGlyph> = shape_cached(face, src, &feats, caps);
let n = shaped.len();
let fref = FaceRef::of(face);
for (gi, sg) in shaped.iter().enumerate() {
let (byte, len, ch) = if replacement.is_some() {
(range.start, if gi == 0 { range.len() } else { 0 }, text[range.start..].chars().next().unwrap_or(' '))
} else {
let cl = range.start + sg.cluster;
let end = shaped[gi + 1..].iter().map(|r| range.start + r.cluster).find(|&c| c > cl).unwrap_or(range.end);
(cl, end.saturating_sub(cl), text[cl..].chars().next().unwrap_or(' '))
};
let last_in_cluster = gi + 1 == n || shaped[gi + 1].cluster != sg.cluster;
let mut adv = sg.x_advance as f64 * k * hs;
if ch == SOFT_HYPHEN {
adv = 0.0;
} else if last_in_cluster {
adv += tracking + manual;
}
// 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;
out.push(Glyph {
face: fref,
gid: sg.gid,
byte,
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,
sx: k * hs,
sy: k * p.v_scale,
shift,
ascent,
descent,
leading,
cap,
xh,
size,
style,
no_break: p.no_break,
space,
});
}
}
/// A hyphen glyph in the face/size of `g`, placed after it (zero source length).
pub(crate) fn hyphen_after(g: &Glyph) -> Glyph {
let gid = designcraft_fonts::first_glyph(&g.face, &['-', '\u{2010}']);
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
}