mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 09:33:54 +00:00
Highlight Alternate Glyphs no longer drops a lam-alef cluster's glyphs outright: the ligature is clear when required shaping, or shaping with liga left on (older fonts), gives it, so an optional feature such as salt that changes it is highlighted again. is_lam_alef takes any combining mark by Unicode general category (Arabic Extended-A/B/C included) instead of a partial list. The test font maps the fatha to a mark and skips marks in its ligature, so the mark-spanning test checks the exact ligature glyph and cluster; it gains a salt lookup and test. Also a one-line equivalent nonminimal_bool repair in pathops' pathtools that strict Clippy needs on main.
236 lines
12 KiB
Rust
236 lines
12 KiB
Rust
use super::{Affine, Arc, BezPath, Node, Rect, Shape, TextObject, lru};
|
||
|
||
/// The whole text's glyph outlines as one path, and which of them each run draws.
|
||
pub(crate) struct TextGeom {
|
||
pub(crate) all: BezPath,
|
||
/// Spans of `all`'s elements drawn by a run, in order: (run, start, end). Outlines of runs
|
||
/// the text doesn't have are in `all` only.
|
||
spans: Vec<(usize, usize, usize)>,
|
||
pub(crate) bounds: Rect,
|
||
/// The boxes of characters whose font is missing (drawn in a substitute), and of glyphs the
|
||
/// font lacks (drawn from a fallback font): Highlight Substituted Fonts and Highlight
|
||
/// Substituted Glyphs.
|
||
pub(crate) substituted_fonts: BezPath,
|
||
pub(crate) substituted_glyphs: BezPath,
|
||
/// Glyphs OpenType may have substituted (a ligature, or not the face's cmap glyph for their
|
||
/// character), each with its box: what Preferences › Highlight Alternate Glyphs weighs
|
||
/// ([`TextGeom::alternate_glyphs`]).
|
||
pub(crate) alternate_candidates: Vec<AlternateCandidate>,
|
||
/// The boxes of the candidates that are alternates, measured the first time the preference
|
||
/// shows them.
|
||
alternate_glyphs: std::sync::OnceLock<BezPath>,
|
||
/// Inline graphics placed by the layout.
|
||
pub(crate) inlines: Vec<vectorcraft_text::InlineGlyph>,
|
||
/// Colour bitmap glyphs: (run, image, its pixels → text space), in text order.
|
||
pub(crate) images: Vec<(usize, Arc<vectorcraft_text::GlyphImage>, Affine)>,
|
||
/// COLR colour glyphs: (run, their layers placed in text space), in text order.
|
||
pub(crate) colours: Vec<(usize, Arc<Node>)>,
|
||
}
|
||
|
||
impl TextGeom {
|
||
/// The outlines run `i` draws: the glyphs of `all` it has, in their order. Kept once rather
|
||
/// than per run as well, which held every outline twice.
|
||
pub(crate) fn run(&self, i: usize) -> std::borrow::Cow<'_, BezPath> {
|
||
let els = self.all.elements();
|
||
if let [(r, 0, end)] = self.spans[..]
|
||
&& r == i
|
||
&& end == els.len()
|
||
{
|
||
return std::borrow::Cow::Borrowed(&self.all);
|
||
}
|
||
let mut p = BezPath::new();
|
||
for &(_, start, end) in self.spans.iter().filter(|s| s.0 == i) {
|
||
p.extend(els.get(start..end).unwrap_or_default().iter().copied());
|
||
}
|
||
std::borrow::Cow::Owned(p)
|
||
}
|
||
|
||
/// The boxes of the glyphs of `t` (the text this geometry is of) that optional OpenType features
|
||
/// chose (Preferences › Highlight Alternate Glyphs): candidates the text's required shaping
|
||
/// alone doesn't give ([`required_shaping`]). Arabic letters joining, Indic conjuncts and the
|
||
/// lam-alef ligature are what the script needs, not alternates. A font may file the lam-alef
|
||
/// ligature under `liga`, which required shaping turns off, so a lam-alef cluster
|
||
/// ([`is_lam_alef`]) is also compared with its shaping with `liga` left on: an optional feature
|
||
/// that changes its glyph beyond that (a stylistic alternate…) is still highlighted. Measured
|
||
/// once.
|
||
pub(crate) fn alternate_glyphs(&self, t: &TextObject) -> &BezPath {
|
||
self.alternate_glyphs.get_or_init(|| {
|
||
let mut out = BezPath::new();
|
||
if self.alternate_candidates.is_empty() {
|
||
return out;
|
||
}
|
||
let required = required_shaping(t, &["-liga", "-calt"]);
|
||
let plain = t.plain_text();
|
||
let in_lam_alef = |c: &AlternateCandidate| plain.get(c.byte..c.byte.saturating_add(c.len)).is_some_and(is_lam_alef);
|
||
// Laid out only when the text has a lam-alef candidate.
|
||
let with_liga = self.alternate_candidates.iter().any(in_lam_alef).then(|| required_shaping(t, &["-calt"]));
|
||
let gives = |map: &std::collections::HashMap<(usize, usize), Vec<(u32, u32)>>, c: &AlternateCandidate| {
|
||
map.get(&(c.byte, c.len)).is_some_and(|glyphs| glyphs.contains(&(c.font_id, c.gid)))
|
||
};
|
||
for c in &self.alternate_candidates {
|
||
let lam_alef_ligature = || in_lam_alef(c) && with_liga.as_ref().is_some_and(|m| gives(m, c));
|
||
if !gives(&required, c) && !lam_alef_ligature() {
|
||
out.extend(c.cell.iter());
|
||
}
|
||
}
|
||
out
|
||
})
|
||
}
|
||
|
||
/// Approximate heap bytes, for the cache's budget.
|
||
pub(crate) fn bytes(&self) -> usize {
|
||
size_of::<Self>()
|
||
+ [&self.all, &self.substituted_fonts, &self.substituted_glyphs].into_iter().map(lru::path_bytes).sum::<usize>()
|
||
+ self.alternate_candidates.iter().map(|c| size_of::<AlternateCandidate>() + lru::path_bytes(&c.cell)).sum::<usize>()
|
||
+ self.alternate_glyphs.get().map_or(0, lru::path_bytes)
|
||
+ self.spans.len() * size_of::<(usize, usize, usize)>()
|
||
+ self.inlines.len() * size_of::<vectorcraft_text::InlineGlyph>()
|
||
+ self.images.len() * size_of::<(usize, Arc<vectorcraft_text::GlyphImage>, Affine)>()
|
||
+ self.colours.iter().map(|(_, art)| lru::node_bytes(art)).sum::<usize>()
|
||
}
|
||
}
|
||
|
||
/// OpenType-substituted form: a ligature (several source characters → one glyph) or a glyph whose
|
||
/// id is not the face's cmap mapping for its first character (contextual / stylistic alternate).
|
||
pub(crate) fn is_opentype_alternate(nominal_gid: u32, shaped_gid: u32, cluster_chars: usize) -> bool {
|
||
shaped_gid != 0 && (cluster_chars > 1 || shaped_gid != nominal_gid)
|
||
}
|
||
|
||
/// Whether `cluster` is a lam followed by an alef (plain, with madda, or with hamza above or below),
|
||
/// with any combining marks (Unicode general category M: the Arabic harakat, Quranic marks and
|
||
/// those of Arabic Extended-A, -B and -C) between or after them: the lam-alef ligature, which the
|
||
/// Unicode Standard's Arabic chapter treats as obligatory, not a stylistic choice. Fonts made
|
||
/// before `rlig` was common put it in `liga`, which [`required_shaping`] turns off, so
|
||
/// [`TextGeom::alternate_glyphs`] also accepts its glyph from shaping with `liga` on.
|
||
pub(crate) fn is_lam_alef(cluster: &str) -> bool {
|
||
use unicode_general_category::{GeneralCategory as G, get_general_category};
|
||
let is_mark = |c: &char| matches!(get_general_category(*c), G::NonspacingMark | G::SpacingMark | G::EnclosingMark);
|
||
let mut letters = cluster.chars().filter(|c| !is_mark(c));
|
||
matches!((letters.next(), letters.next(), letters.next()), (Some('\u{0644}'), Some('\u{0622}' | '\u{0623}' | '\u{0625}' | '\u{0627}'), None))
|
||
}
|
||
|
||
/// A glyph [`is_opentype_alternate`] marks, in its cluster of the text (`byte`, `len`), with its
|
||
/// box.
|
||
#[derive(Debug)]
|
||
pub(crate) struct AlternateCandidate {
|
||
byte: usize,
|
||
len: usize,
|
||
font_id: u32,
|
||
gid: u32,
|
||
cell: BezPath,
|
||
}
|
||
|
||
/// The glyphs `t` is shaped to by required shaping alone, by cluster (`byte`, `len`) → `(font,
|
||
/// glyph)`: laid out again with every optional feature off (with `features` `-liga` and `-calt`:
|
||
/// standard and contextual ligatures and alternates, and the features its characters turn on:
|
||
/// stylistic sets, swashes, discretionary ligatures, figure styles…). What is left is what the
|
||
/// script needs: `ccmp`, `locl`, `rlig`, Arabic's `init`, `medi`, `fina` and `isol`, the Indic
|
||
/// and other complex scripts' required sets. With `features` `-calt` alone, standard ligatures
|
||
/// stay on (an older font's lam-alef).
|
||
fn required_shaping(t: &TextObject, features: &[&str]) -> std::collections::HashMap<(usize, usize), Vec<(u32, u32)>> {
|
||
let mut plain = t.clone();
|
||
for run in &mut plain.runs {
|
||
run.style.features = features.iter().map(|f| (*f).into()).collect();
|
||
}
|
||
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &plain);
|
||
let mut glyphs: std::collections::HashMap<(usize, usize), Vec<(u32, u32)>> = std::collections::HashMap::new();
|
||
for g in &layout.glyphs {
|
||
glyphs.entry((g.byte, g.len)).or_default().push((g.font_id, g.gid));
|
||
}
|
||
glyphs
|
||
}
|
||
|
||
pub(crate) fn text_geom(t: &TextObject) -> TextGeom {
|
||
text_geom_snapped(t, None)
|
||
}
|
||
|
||
/// Glyph geometry of `t`; `snap` (text space → device pixels) puts the glyphs on whole pixels.
|
||
pub(crate) fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
|
||
let db = vectorcraft_text::FontDb::global();
|
||
let mut layout = vectorcraft_text::layout(db, t);
|
||
if let Some(xf) = snap {
|
||
layout.snap_to_pixels(xf);
|
||
}
|
||
let mut all = BezPath::new();
|
||
let mut spans: Vec<(usize, usize, usize)> = vec![];
|
||
// Append `outline` to `all`, drawn by `run` when the text has that run.
|
||
let mut push = |all: &mut BezPath, run: usize, outline: &BezPath| {
|
||
let start = all.elements().len();
|
||
all.extend(outline.iter());
|
||
let end = all.elements().len();
|
||
if run >= t.runs.len() || start == end {
|
||
return;
|
||
}
|
||
match spans.last_mut() {
|
||
Some((r, _, e)) if *r == run && *e == start => *e = end,
|
||
_ => spans.push((run, start, end)),
|
||
}
|
||
};
|
||
// Per run: is its family missing, and the face its glyphs should come from.
|
||
let faces: Vec<(bool, Option<u32>)> = t
|
||
.runs
|
||
.iter()
|
||
.map(|r| match db.resolve(&r.style.font_family, &r.style.font_style) {
|
||
// The version the type names, when it's installed (see `FontDb::face_version`), at its
|
||
// Variable Font settings (see `FontDb::style_face`).
|
||
Some((f, m)) => {
|
||
let face = db.style_face(&r.style).unwrap_or(f);
|
||
(m == vectorcraft_text::FontMatch::Missing, Some(face.id()))
|
||
}
|
||
None => (true, None),
|
||
})
|
||
.collect();
|
||
let (mut substituted_fonts, mut substituted_glyphs, mut alternate_candidates) = (BezPath::new(), BezPath::new(), vec![]);
|
||
let plain = t.plain_text();
|
||
let (mut images, mut colours) = (vec![], vec![]);
|
||
for g in &layout.glyphs {
|
||
push(&mut all, g.run, &g.outline);
|
||
if let Some((image, xf)) = g.placed_image() {
|
||
images.push((g.run, image.clone(), xf));
|
||
}
|
||
if let Some(art) = g.placed_colour(Affine::IDENTITY) {
|
||
colours.push((g.run, Arc::new(art)));
|
||
}
|
||
let Some(&(missing, face)) = faces.get(g.run) else { continue };
|
||
let Some(line) = layout.lines.get(g.line) else { continue };
|
||
let mut cell = Rect::new(g.origin.x, g.origin.y - line.ascent, g.origin.x + g.advance, g.origin.y + line.descent).to_path(0.1);
|
||
cell.apply_affine(Affine::rotate_about(g.angle, g.origin));
|
||
if missing {
|
||
substituted_fonts.extend(cell.iter());
|
||
continue;
|
||
}
|
||
if face.is_some_and(|f| f != g.font_id) {
|
||
substituted_glyphs.extend(cell.iter());
|
||
continue;
|
||
}
|
||
// Same face: OpenType may have swapped the glyph (liga, calt, …).
|
||
let Some(cluster) = plain.get(g.byte..g.byte.saturating_add(g.len)) else { continue };
|
||
let cluster_chars = cluster.chars().count();
|
||
let Some(first) = cluster.chars().next() else { continue };
|
||
let nominal = db.face_by_id(g.font_id).map(|f| f.glyph_for(first)).unwrap_or(0);
|
||
if is_opentype_alternate(nominal, g.gid, cluster_chars) {
|
||
alternate_candidates.push(AlternateCandidate { byte: g.byte, len: g.len, font_id: g.font_id, gid: g.gid, cell });
|
||
}
|
||
}
|
||
// Ruby (furigana) glyphs: painted with their base text's run.
|
||
for g in &layout.ruby {
|
||
push(&mut all, g.run, &g.outline);
|
||
}
|
||
// Underline and strikethrough bars, painted as their run's type is (#847).
|
||
for (run, bar) in vectorcraft_text::decorations(&layout, db, t) {
|
||
push(&mut all, run, &bar);
|
||
}
|
||
TextGeom {
|
||
all,
|
||
spans,
|
||
bounds: layout.bounds,
|
||
substituted_fonts,
|
||
substituted_glyphs,
|
||
alternate_candidates,
|
||
alternate_glyphs: std::sync::OnceLock::new(),
|
||
inlines: layout.inlines,
|
||
images,
|
||
colours,
|
||
}
|
||
}
|