mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Type: a change of paint, baseline shift or rotation on one character keeps the font's kerning
Each style run was shaped on its own, so restyling one character, even only its fill, dropped the font's kerning pairs at the new run boundaries. A fill change on the V of 40 pt "AVAV" moved it 0.56 pt and moved the characters after it 1.12 pt. Manual kerning on a character also dropped the font's kerning with the character before it. Adjacent runs that differ only in fill, stroke, underline, strikethrough, overprint, character style name, leading, baseline shift, rotation, Character Alignment, kerning, or tracking (as long as both values leave standard ligatures on or both turn them off) are now shaped together, and each glyph takes those attributes from its own run. The font's kerning pairs apply after characters with automatic kerning: a manual value replaces the pair after its character, and the pair before it stays, except before a character that All Caps or Small Caps sets as two letters (ß as SS). A change of font, size, scale or OpenType features still shapes the characters apart, and a ligature or a mark still does not join characters in different styles. Docs: docs/target-app-parity.md.
This commit is contained in:
+241
-107
@@ -6,13 +6,13 @@ use std::sync::Arc;
|
||||
use unicode_bidi::Level;
|
||||
use unicode_script::{Script, UnicodeScript};
|
||||
|
||||
use harfrust::{Direction, Feature, ShapeOptions, UnicodeBuffer};
|
||||
use harfrust::{Direction, Feature, ShapeOptions, Tag, UnicodeBuffer};
|
||||
use skrifa::MetadataProvider;
|
||||
use skrifa::instance::Size;
|
||||
use vectorcraft_doc::text::INLINE_CHAR;
|
||||
use vectorcraft_doc::{CharStyle, InlineArt};
|
||||
|
||||
use crate::features::OtFeatures;
|
||||
use crate::features::{OtFeatures, ligatures_suppressed_by};
|
||||
use crate::fontdb::{FontDb, FontFace};
|
||||
|
||||
/// A shaped glyph with all character-style effects resolved, in points (y down).
|
||||
@@ -319,9 +319,11 @@ pub(crate) fn shape_range(
|
||||
let level_at = |i: usize| levels.get(i - range.start).copied().unwrap_or_else(Level::ltr);
|
||||
let output_start = out.len();
|
||||
|
||||
// Text runs also carry non-shaping attributes (fill, stroke, etc.). Coalesce adjacent equal
|
||||
// styles so an editor split does not become an accidental OpenType shaping boundary. Keep
|
||||
// original run spans: each emitted cluster is attributed by its source byte below.
|
||||
// Text runs also carry attributes the shaper does not read (fill, stroke, baseline shift and
|
||||
// others). Adjacent runs that shape alike ([`shapes_alike`]) are shaped together, so that a
|
||||
// change of those attributes is not a shaping boundary: kerning pairs, contextual forms and
|
||||
// joining go on across it. Each glyph takes those attributes from its own source run, found by
|
||||
// its source byte ([`shape_segment`]).
|
||||
/// A run of text shaped together: its byte range, style and source runs, or one inline graphic.
|
||||
struct ShapeSpan<'a> {
|
||||
bytes: Range<usize>,
|
||||
@@ -342,7 +344,7 @@ pub(crate) fn shape_range(
|
||||
&& let Some(previous) = spans.last_mut()
|
||||
&& previous.art.is_none()
|
||||
&& previous.bytes.end == a
|
||||
&& *previous.st == **st
|
||||
&& shapes_alike(previous.st, st)
|
||||
{
|
||||
previous.bytes.end = b;
|
||||
previous.source_runs.end = ri + 1;
|
||||
@@ -365,8 +367,7 @@ pub(crate) fn shape_range(
|
||||
// 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 group_output_start = out.len();
|
||||
let mut seg = Segment { range: a..a, run: source_runs.start, st, face: primary.clone(), small: false, level: level_at(a) };
|
||||
let mut seg = Segment { range: a..a, runs, source_runs, st, face: primary.clone(), small: false, level: level_at(a) };
|
||||
let mut cache: Vec<(char, Arc<FontFace>)> = Vec::new();
|
||||
let mut script = Script::Common;
|
||||
for (i, c) in text[a..b].char_indices() {
|
||||
@@ -407,9 +408,6 @@ pub(crate) fn shape_range(
|
||||
seg.range.end = b;
|
||||
shape_segment(text, &range, &seg, feats, out);
|
||||
}
|
||||
for glyph in &mut out[group_output_start..] {
|
||||
glyph.run = source_run_at(runs, &source_runs, glyph.byte).unwrap_or(source_runs.start);
|
||||
}
|
||||
}
|
||||
// Right-to-left segments come out of the shaper in visual order: back to logical order for
|
||||
// line breaking (the lines are put in visual order once broken).
|
||||
@@ -429,6 +427,61 @@ fn source_run_at(runs: &[(Range<usize>, &CharStyle)], span: &Range<usize>, byte:
|
||||
source.get(i).filter(|(range, _)| range.contains(&byte)).map(|_| span.start + i)
|
||||
}
|
||||
|
||||
/// Do text runs in styles `a` and `b` shape alike, so that they are shaped as one piece of text?
|
||||
/// They do when they differ only in paint and stroke, underline and strikethrough, overprint, the
|
||||
/// character style's name, leading, baseline shift, rotation, Character Alignment, the kerning
|
||||
/// (each glyph takes its run's, see [`shape_segment`]) and the tracking, as long as the tracking
|
||||
/// keeps or drops standard ligatures alike ([`crate::features::ligatures_suppressed_by`]).
|
||||
fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool {
|
||||
// Every field is named, so that a new attribute has to be sorted into one group or the other.
|
||||
let CharStyle {
|
||||
font_family,
|
||||
font_style,
|
||||
font_version,
|
||||
size,
|
||||
leading: _,
|
||||
tracking,
|
||||
kerning: _,
|
||||
baseline_shift: _,
|
||||
h_scale,
|
||||
v_scale,
|
||||
rotation: _,
|
||||
fill: _,
|
||||
stroke: _,
|
||||
stroke_width: _,
|
||||
underline: _,
|
||||
strikethrough: _,
|
||||
all_caps,
|
||||
features,
|
||||
style_name: _,
|
||||
overprint_fill: _,
|
||||
overprint_stroke: _,
|
||||
stroke_cap: _,
|
||||
stroke_join: _,
|
||||
stroke_miter_limit: _,
|
||||
stroke_dash: _,
|
||||
stroke_align: _,
|
||||
position,
|
||||
small_caps,
|
||||
char_align: _,
|
||||
proportional_metrics,
|
||||
digits,
|
||||
} = a;
|
||||
*font_family == b.font_family
|
||||
&& *font_style == b.font_style
|
||||
&& *font_version == b.font_version
|
||||
&& *size == b.size
|
||||
&& *h_scale == b.h_scale
|
||||
&& *v_scale == b.v_scale
|
||||
&& *all_caps == b.all_caps
|
||||
&& *features == b.features
|
||||
&& *position == b.position
|
||||
&& *small_caps == b.small_caps
|
||||
&& *proportional_metrics == b.proportional_metrics
|
||||
&& *digits == b.digits
|
||||
&& ligatures_suppressed_by(*tracking) == ligatures_suppressed_by(b.tracking)
|
||||
}
|
||||
|
||||
/// The script `c` is shaped in: Japanese and Chinese text mixes Han, Hiragana, Katakana and
|
||||
/// Bopomofo, shaped together (splitting them would cost a shaper call per change of script).
|
||||
fn shaping_script(c: char) -> Script {
|
||||
@@ -497,10 +550,15 @@ fn inline_glyph(face: &Arc<FontFace>, st: &CharStyle, art: &InlineArt, run: usiz
|
||||
}
|
||||
}
|
||||
|
||||
/// A piece of one run shaped in one go: one face and, for Small Caps, one case.
|
||||
/// A piece of text shaped in one go: one face and, for Small Caps, one case, in runs that shape
|
||||
/// alike ([`shapes_alike`]).
|
||||
#[derive(Clone)]
|
||||
struct Segment<'a> {
|
||||
range: Range<usize>,
|
||||
run: usize,
|
||||
/// All the runs, and the indices of those the piece's shaping span takes in.
|
||||
runs: &'a [(Range<usize>, &'a CharStyle)],
|
||||
source_runs: Range<usize>,
|
||||
/// The style of the span's first run, which the others shape alike with.
|
||||
st: &'a CharStyle,
|
||||
face: Arc<FontFace>,
|
||||
/// Lowercase letters drawn as synthesized small capitals.
|
||||
@@ -508,15 +566,36 @@ struct Segment<'a> {
|
||||
level: Level,
|
||||
}
|
||||
|
||||
/// A segment shaped in one style: each glyph's id, cluster, x advance and x and y offset (font
|
||||
/// units), whether `palt` gave it another advance, and what `vpal` does to it.
|
||||
#[derive(Default)]
|
||||
struct Shaped {
|
||||
raw: Vec<(u32, u32, i32, i32, i32)>,
|
||||
proportional: Vec<bool>,
|
||||
vpal: Vec<Option<VAdjust>>,
|
||||
}
|
||||
|
||||
fn is_mark(c: char) -> bool {
|
||||
use unicode_general_category::{GeneralCategory, get_general_category};
|
||||
matches!(get_general_category(c), GeneralCategory::NonspacingMark | GeneralCategory::SpacingMark | GeneralCategory::EnclosingMark)
|
||||
|| matches!(c, '\u{200D}' | '\u{FE00}'..='\u{FE0F}')
|
||||
}
|
||||
|
||||
/// Shape `seg` and append its glyphs to `out`. Each glyph takes the tracking, kerning, baseline
|
||||
/// shift, rotation and leading of its own source run. The font's kerning pairs apply after the
|
||||
/// characters with automatic kerning: the manual value replaces the font's pair after its
|
||||
/// character, and a character before it with automatic kerning keeps the font's pair with it
|
||||
/// (except before a character that All Caps or Small Caps sets as several letters, such as ß as
|
||||
/// SS). A cluster that would take in characters of runs with different styles (a ligature, a
|
||||
/// base with its marks) is not formed: the segment is shaped in pieces at those run boundaries.
|
||||
fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFeatures, out: &mut Vec<SGlyph>) {
|
||||
let Segment { range, run, st, face, small, level } = seg;
|
||||
let (range, run, small) = (range.clone(), *run, *small);
|
||||
let Segment { range, runs, source_runs, st, face, small, level } = seg;
|
||||
let (range, small) = (range.clone(), *small);
|
||||
// The source runs the segment's bytes lie in (a span can hold many segments).
|
||||
let span = runs.get(source_runs.clone()).unwrap_or_default();
|
||||
let first = span.partition_point(|(r, _)| r.end <= range.start);
|
||||
let last = span.partition_point(|(r, _)| r.start < range.end).max(first);
|
||||
let source_runs = &(source_runs.start + first..source_runs.start + last);
|
||||
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.
|
||||
@@ -527,115 +606,168 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
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(' ');
|
||||
// The source run of the character at `byte`: its index and style.
|
||||
let run_at = |byte: usize| {
|
||||
let i = source_run_at(runs, source_runs, byte).unwrap_or(source_runs.start);
|
||||
(i, runs.get(i).map_or(*st, |(_, s)| *s))
|
||||
};
|
||||
// The starts of the source runs inside the segment.
|
||||
let boundaries = || runs.get(source_runs.clone()).unwrap_or_default().iter().map(|(r, _)| r.start).filter(|&b| b > range.start && b < range.end);
|
||||
// Shape the pieces of the segment between the bytes `at` (ascending, inside it) on their own.
|
||||
let shape_apart = |at: Vec<usize>, out: &mut Vec<SGlyph>| {
|
||||
let ends = at.iter().copied().chain([range.end]);
|
||||
for (start, end) in std::iter::once(range.start).chain(at.iter().copied()).zip(ends) {
|
||||
shape_segment(text, context, &Segment { range: start..end, ..seg.clone() }, feats, out);
|
||||
}
|
||||
};
|
||||
let manual = |byte: usize| run_at(byte).1.kerning.is_some();
|
||||
let mixed_kerning = boundaries().any(|b| manual(b) != manual(range.start));
|
||||
|
||||
let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::with_capacity(text_seg.len()); // gid, cluster, xadv, xoff, yoff
|
||||
// Proportional Metrics: which glyphs `palt` gave another advance than their full width, and
|
||||
// what `vpal` does to each in vertical type.
|
||||
let mut proportional = vec![];
|
||||
let mut vpal = vec![];
|
||||
let shaped = face.hb().map(|hb| {
|
||||
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
||||
// gid, cluster, x and y advance, x and y offset.
|
||||
let shape = |feats: &[Feature], direction: Direction| {
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
for (i, c) in text_seg.char_indices() {
|
||||
let cl = (range.start + i) as u32;
|
||||
let c = st.digits.map(c);
|
||||
if upper {
|
||||
for u in c.to_uppercase() {
|
||||
buf.add(u, cl);
|
||||
// Shape the segment as if all of it were in `st`, with the features `more` on top.
|
||||
let shape_in = |st: &CharStyle, more: &[Feature]| {
|
||||
let mut shaped = Shaped::default();
|
||||
let Shaped { raw, proportional, vpal } = &mut shaped;
|
||||
let hb_shaped = face.hb().map(|hb| {
|
||||
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
||||
// gid, cluster, x and y advance, x and y offset.
|
||||
let shape = |feats: &[Feature], direction: Direction| {
|
||||
let feats: Vec<Feature> = feats.iter().chain(more).cloned().collect();
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
for (i, c) in text_seg.char_indices() {
|
||||
let cl = (range.start + i) as u32;
|
||||
let c = st.digits.map(c);
|
||||
if upper {
|
||||
for u in c.to_uppercase() {
|
||||
buf.add(u, cl);
|
||||
}
|
||||
} else {
|
||||
buf.add(c, cl);
|
||||
}
|
||||
} else {
|
||||
buf.add(c, cl);
|
||||
}
|
||||
}
|
||||
buf.set_direction(direction);
|
||||
buf.guess_segment_properties();
|
||||
// Harfrust keeps this Unicode context for joining/positional shaping, but does not emit
|
||||
// glyphs for it. This retains context at real style/font segmentation boundaries without
|
||||
// shaping across those boundaries or claiming cross-style ligatures/mark attachment.
|
||||
if let Some(pre) = text.get(context.start..range.start) {
|
||||
buf.set_pre_context(pre);
|
||||
}
|
||||
if let Some(post) = text.get(range.end..context.end) {
|
||||
buf.set_post_context(post);
|
||||
}
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(feats));
|
||||
gb.glyph_infos()
|
||||
.iter()
|
||||
.zip(gb.glyph_positions())
|
||||
.map(|(info, pos)| (info.glyph_id, info.cluster, pos.x_advance, pos.y_advance, pos.x_offset, pos.y_offset))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let along = if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight };
|
||||
raw = shape(&feats.resolve(st), along).into_iter().map(|(g, c, xa, _, xo, yo)| (g, c, xa, xo, yo)).collect();
|
||||
let same_glyphs = |v: &[(u32, u32, i32, i32, i32, i32)]| v.len() == raw.len() && v.iter().zip(&raw).all(|(a, b)| a.0 == b.0);
|
||||
if feats.proportional(st) {
|
||||
// `palt` is positioning only: the same glyphs set on their full widths tell which
|
||||
// advances it changed (kerning and the rest change both alike).
|
||||
let full = shape(&feats.resolve_fixed_width(st), along);
|
||||
if same_glyphs(&full) {
|
||||
proportional = full.iter().zip(&raw).map(|(a, b)| a.2 != b.2).collect();
|
||||
}
|
||||
}
|
||||
if feats.proportional_vertical(st) {
|
||||
// `vpal` adjusts heights, which only top-to-bottom shaping applies: the same glyphs set
|
||||
// top to bottom with it (and the vertical kerning that goes with it, `vkrn` and `vapk`)
|
||||
// and without either tell how it moves each and changes its advance down the column
|
||||
// (y up, advances negative). Which advances `vpal` itself changed comes from setting
|
||||
// them with it alone.
|
||||
let ttb = |proportional, kerning| shape(&feats.resolve_vertical(st, proportional, kerning), Direction::TopToBottom);
|
||||
let (with, without, alone) = (ttb(true, true), ttb(false, false), ttb(true, false));
|
||||
if same_glyphs(&with) && same_glyphs(&without) && same_glyphs(&alone) {
|
||||
vpal = with
|
||||
buf.set_direction(direction);
|
||||
buf.guess_segment_properties();
|
||||
// Harfrust keeps this Unicode context for joining/positional shaping, but does not emit
|
||||
// glyphs for it. This retains context at real style/font segmentation boundaries without
|
||||
// shaping across those boundaries or claiming cross-style ligatures/mark attachment.
|
||||
if let Some(pre) = text.get(context.start..range.start) {
|
||||
buf.set_pre_context(pre);
|
||||
}
|
||||
if let Some(post) = text.get(range.end..context.end) {
|
||||
buf.set_post_context(post);
|
||||
}
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(&feats));
|
||||
gb.glyph_infos()
|
||||
.iter()
|
||||
.zip(&without)
|
||||
.zip(&alone)
|
||||
.map(|((a, b), c)| {
|
||||
let adj = VAdjust {
|
||||
advance: f64::from(b.3 - a.3) * k * vs,
|
||||
dx: f64::from(a.4 - b.4) * k * hs,
|
||||
dy: -f64::from(a.5 - b.5) * k * vs,
|
||||
proportional: c.3 != b.3,
|
||||
};
|
||||
(adj != VAdjust::default()).then_some(adj)
|
||||
})
|
||||
.collect();
|
||||
.zip(gb.glyph_positions())
|
||||
.map(|(info, pos)| (info.glyph_id, info.cluster, pos.x_advance, pos.y_advance, pos.x_offset, pos.y_offset))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let along = if level.is_rtl() { Direction::RightToLeft } else { Direction::LeftToRight };
|
||||
*raw = shape(&feats.resolve(st), along).into_iter().map(|(g, c, xa, _, xo, yo)| (g, c, xa, xo, yo)).collect();
|
||||
let raw = &*raw;
|
||||
let same_glyphs = |v: &[(u32, u32, i32, i32, i32, i32)]| v.len() == raw.len() && v.iter().zip(raw).all(|(a, b)| a.0 == b.0);
|
||||
if feats.proportional(st) {
|
||||
// `palt` is positioning only: the same glyphs set on their full widths tell which
|
||||
// advances it changed (kerning and the rest change both alike).
|
||||
let full = shape(&feats.resolve_fixed_width(st), along);
|
||||
if same_glyphs(&full) {
|
||||
*proportional = full.iter().zip(raw).map(|(a, b)| a.2 != b.2).collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
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(), face.location());
|
||||
for (i, c) in text_seg.char_indices() {
|
||||
let cl = (range.start + i) as u32;
|
||||
let c = st.digits.map(c);
|
||||
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));
|
||||
if feats.proportional_vertical(st) {
|
||||
// `vpal` adjusts heights, which only top-to-bottom shaping applies: the same glyphs set
|
||||
// top to bottom with it (and the vertical kerning that goes with it, `vkrn` and `vapk`)
|
||||
// and without either tell how it moves each and changes its advance down the column
|
||||
// (y up, advances negative). Which advances `vpal` itself changed comes from setting
|
||||
// them with it alone.
|
||||
let ttb = |proportional, kerning| shape(&feats.resolve_vertical(st, proportional, kerning), Direction::TopToBottom);
|
||||
let (with, without, alone) = (ttb(true, true), ttb(false, false), ttb(true, false));
|
||||
if same_glyphs(&with) && same_glyphs(&without) && same_glyphs(&alone) {
|
||||
*vpal = with
|
||||
.iter()
|
||||
.zip(&without)
|
||||
.zip(&alone)
|
||||
.map(|((a, b), c)| {
|
||||
let adj = VAdjust {
|
||||
advance: f64::from(b.3 - a.3) * k * vs,
|
||||
dx: f64::from(a.4 - b.4) * k * hs,
|
||||
dy: -f64::from(a.5 - b.5) * k * vs,
|
||||
proportional: c.3 != b.3,
|
||||
};
|
||||
(adj != VAdjust::default()).then_some(adj)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
});
|
||||
if hb_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(), face.location());
|
||||
for (i, c) in text_seg.char_indices() {
|
||||
let cl = (range.start + i) as u32;
|
||||
let c = st.digits.map(c);
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
shaped
|
||||
};
|
||||
let Shaped { raw, proportional, vpal } = if mixed_kerning {
|
||||
// A font's kerning pair applies after a character with automatic kerning and not after one
|
||||
// with manual kerning. The segment is shaped apart where manual kerning ends, so each piece
|
||||
// is text with automatic kerning followed by text with manual kerning. A piece is shaped
|
||||
// with the font's kerning on, and off (`kern`, and the `apkn`, `vkrn` and `vapk` that
|
||||
// `OtFeatures` turns off with it) from the second character with manual kerning on: a
|
||||
// pair is applied only when both of its glyphs have the feature on.
|
||||
let ends: Vec<usize> = boundaries().filter(|&b| manual(b - 1) && !manual(b)).collect();
|
||||
if !ends.is_empty() {
|
||||
return shape_apart(ends, out);
|
||||
}
|
||||
let first = boundaries().find(|&b| manual(b)).unwrap_or(range.end);
|
||||
// A first character with manual kerning that All Caps or Small Caps sets as several letters
|
||||
// (ß as SS) gives them one cluster, which a feature range cannot split, so the font's
|
||||
// kerning between them would stay on: it is shaped apart from the text before it.
|
||||
if upper && first_char(first).to_uppercase().nth(1).is_some() {
|
||||
return shape_apart(vec![first], out);
|
||||
}
|
||||
let off = text.get(first..range.end).and_then(|s| s.chars().next()).map_or(range.end, |c| first + c.len_utf8());
|
||||
let more: Vec<Feature> = [b"kern", b"apkn", b"vkrn", b"vapk"]
|
||||
.into_iter()
|
||||
.filter(|_| off < range.end)
|
||||
.map(|t| Feature::new(Tag::new(t), 0, off..range.end))
|
||||
.collect();
|
||||
shape_in(&CharStyle { kerning: None, ..(*st).clone() }, &more)
|
||||
} else {
|
||||
// The span's first run may lie outside the segment (a span split by font, case, script
|
||||
// or direction): the kerning mode is that of the segment's own runs.
|
||||
shape_in(run_at(range.start).1, &[])
|
||||
};
|
||||
let mut cluster_starts: Vec<usize> = raw.iter().map(|r| r.1 as usize).collect();
|
||||
cluster_starts.sort_unstable();
|
||||
cluster_starts.dedup();
|
||||
// A glyph is drawn in one style: where a cluster would take in characters of runs in different
|
||||
// styles, the runs are shaped apart, as runs that do not shape alike are.
|
||||
let inside_clusters: Vec<usize> = boundaries().filter(|&b| cluster_starts.binary_search(&b).is_err() && run_at(b - 1).1 != run_at(b).1).collect();
|
||||
if !inside_clusters.is_empty() {
|
||||
return shape_apart(inside_clusters, out);
|
||||
}
|
||||
let n = raw.len();
|
||||
for (gi, &(gid, cl, xa, xo, yo)) in raw.iter().enumerate() {
|
||||
let cl = cl as usize;
|
||||
let (run, gst) = run_at(cl);
|
||||
// Cluster end: the next larger cluster value in the segment, else the segment end.
|
||||
let end = cluster_starts.get(cluster_starts.partition_point(|&c| c <= cl)).copied().unwrap_or(range.end);
|
||||
let last_in_cluster = gi + 1 == n || raw[gi + 1].1 as usize != cl;
|
||||
@@ -644,6 +776,8 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
if ch == SOFT_HYPHEN {
|
||||
adv = 0.0;
|
||||
} else if last_in_cluster {
|
||||
let tracking = gst.tracking / 1000.0 * size;
|
||||
let manual_kern = gst.kerning.map(|v| v / 1000.0 * size).unwrap_or(0.0);
|
||||
adv += tracking + manual_kern;
|
||||
}
|
||||
out.push(SGlyph {
|
||||
@@ -657,11 +791,11 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
|
||||
dy: -(yo as f64) * k * vs,
|
||||
sx: k * hs,
|
||||
sy: k * vs,
|
||||
bshift: st.baseline_shift + pos_shift,
|
||||
rotation: st.rotation,
|
||||
bshift: gst.baseline_shift + pos_shift,
|
||||
rotation: gst.rotation,
|
||||
ascent,
|
||||
descent,
|
||||
leading,
|
||||
leading: gst.effective_leading(),
|
||||
cap,
|
||||
xh,
|
||||
ch,
|
||||
|
||||
@@ -218,6 +218,44 @@ pub fn vertical_punctuation_font(alternates: bool) -> Option<Vec<u8>> {
|
||||
Some(builder.build())
|
||||
}
|
||||
|
||||
/// The family of [`pair_kerning_font`].
|
||||
pub const PAIR_KERNING_FAMILY: &str = "Pairtest Sans";
|
||||
|
||||
/// The bundled Source Sans 3 Regular as family [`PAIR_KERNING_FAMILY`], its `GPOS` replaced by two
|
||||
/// `kern` pairs, A followed by V and S followed by S: each takes 60 units off the first glyph's
|
||||
/// advance (the pair's first value record) and moves the second 100 units left (its second).
|
||||
/// `None` when it can't be built.
|
||||
pub fn pair_kerning_font() -> Option<Vec<u8>> {
|
||||
use write_fonts::tables::gpos::{Gpos, PairPos, PairSet, PairValueRecord, PositionLookup, PositionLookupList, ValueRecord};
|
||||
use write_fonts::tables::layout::{CoverageFormat1, Feature, FeatureList, FeatureRecord, LangSys, Lookup, Script, ScriptList, ScriptRecord};
|
||||
use write_fonts::types::GlyphId16;
|
||||
|
||||
const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
|
||||
let font = skrifa::FontRef::new(BASE).ok()?;
|
||||
let charmap = font.charmap();
|
||||
let gid = |c| Some(GlyphId16::new(u16::try_from(charmap.map(c)?.to_u32()).ok()?));
|
||||
let pair = |second| PairValueRecord::new(second, ValueRecord::new().with_x_advance(-60), ValueRecord::new().with_x_placement(-100));
|
||||
let mut pairs = [(gid('A')?, gid('V')?), (gid('S')?, gid('S')?)];
|
||||
pairs.sort_by_key(|p| p.0);
|
||||
let coverage = CoverageFormat1::new(pairs.iter().map(|p| p.0).collect());
|
||||
let pos = PairPos::format_1(coverage.into(), pairs.iter().map(|p| PairSet::new(vec![pair(p.1)])).collect());
|
||||
let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"DFLT"), Script::new(Some(LangSys::new(vec![0])), vec![]))]);
|
||||
let features = FeatureList::new(vec![FeatureRecord::new(Tag::new(b"kern"), Feature::new(None, vec![0]))]);
|
||||
let lookups = PositionLookupList::new(vec![PositionLookup::Pair(Lookup::new(Default::default(), vec![pos]))]);
|
||||
let family = PAIR_KERNING_FAMILY;
|
||||
let name = name_table(&[(1, family), (2, "Regular"), (4, "Pairtest Sans Regular"), (6, "PairtestSans-Regular")])?;
|
||||
let mut builder = FontBuilder::new();
|
||||
builder.add_table(&Gpos::new(scripts, features, lookups)).ok()?;
|
||||
builder.add_raw(Tag::new(b"name"), name);
|
||||
for r in font.table_directory.table_records() {
|
||||
let tag = Tag::new(&r.tag().to_be_bytes());
|
||||
if tag != Tag::new(b"kern") && !builder.contains(tag) {
|
||||
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
|
||||
}
|
||||
}
|
||||
Some(builder.build())
|
||||
}
|
||||
|
||||
/// The family of [`twin_font`]: two versions of one family and style.
|
||||
pub const TWIN_FAMILY: &str = "Twintest Sans";
|
||||
|
||||
|
||||
@@ -236,6 +236,35 @@ fn different_style_arabic_boundary_receives_joining_context() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_change_of_paint_in_a_mixed_direction_line_moves_no_glyph() {
|
||||
let db = FontDb::global();
|
||||
let text = "AVAV سلام AVAV";
|
||||
if db.face_covering('س').is_none_or(|face| !face.covers('ل')) {
|
||||
eprintln!("SKIPPED: a_change_of_paint_in_a_mixed_direction_line_moves_no_glyph; no font covers Arabic seen and lam");
|
||||
return;
|
||||
}
|
||||
let plain = layout(db, &TextObject::point(Point::ZERO, text, CharStyle::default()));
|
||||
assert!(plain.glyphs.iter().any(|g| g.rtl) && plain.glyphs.iter().any(|g| !g.rtl), "{plain:?}");
|
||||
let seen = text.find('س').unwrap_or_default();
|
||||
let mut restyled = TextObject::point(Point::ZERO, text, CharStyle::default());
|
||||
for byte in [1, seen] {
|
||||
let end = byte + text[byte..].chars().next().map_or(1, char::len_utf8);
|
||||
edit::style_range(&mut restyled.runs, byte, end, |s| s.fill = Default::default());
|
||||
}
|
||||
assert_eq!(restyled.runs.len(), 5);
|
||||
let restyled_layout = layout(db, &restyled);
|
||||
assert_same_shaped_glyphs(&plain, &restyled_layout);
|
||||
for glyph in &restyled_layout.glyphs {
|
||||
let run_ends = restyled.runs.iter().scan(0, |end, r| {
|
||||
*end += r.text.len();
|
||||
Some(*end)
|
||||
});
|
||||
let expected = run_ends.take_while(|end| *end <= glyph.byte).count();
|
||||
assert_eq!(glyph.run, expected, "glyph {glyph:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_range_handles_empty_runs_and_selected_utf8_subranges() {
|
||||
let db = FontDb::global();
|
||||
|
||||
@@ -1072,6 +1072,160 @@ fn uncovered_characters_do_not_break_layout() {
|
||||
assert!(l.glyphs.len() >= 3);
|
||||
}
|
||||
|
||||
// ---------- kerning across style runs ----------
|
||||
|
||||
/// 40 pt "AVAV" in Source Sans 3, which kerns A and V, with the character at `byte` restyled by
|
||||
/// `restyle`.
|
||||
fn avav(byte: usize, restyle: impl FnMut(&mut CharStyle)) -> TextObject {
|
||||
let mut t = TextObject::point(Point::ZERO, "AVAV", style(40.0));
|
||||
edit::style_range(&mut t.runs, byte, byte + 1, restyle);
|
||||
t
|
||||
}
|
||||
|
||||
/// Each glyph's source byte, pen position along the line and advance.
|
||||
fn pens(t: &TextObject) -> Vec<(usize, f64, f64)> {
|
||||
layout(db(), t).glyphs.iter().map(|g| (g.byte, g.origin.x, g.advance)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restyling_one_character_keeps_its_kerning_with_its_neighbors() {
|
||||
let plain = pens(&TextObject::point(Point::ZERO, "AVAV", style(40.0)));
|
||||
let unkerned = pens(&TextObject::point(Point::ZERO, "AVAV", CharStyle { kerning: Some(0.0), ..style(40.0) }));
|
||||
assert!((plain[0].2 - unkerned[0].2).abs() > 0.1, "the font kerns A and V: {plain:?} {unkerned:?}");
|
||||
type Restyle = fn(&mut CharStyle);
|
||||
let restyles: [(&str, Restyle); 10] = [
|
||||
("fill", |s| s.fill = Default::default()),
|
||||
("stroke", |s| {
|
||||
s.stroke = s.fill.clone();
|
||||
s.stroke_width = 2.0;
|
||||
}),
|
||||
("underline", |s| s.underline = true),
|
||||
("strikethrough", |s| s.strikethrough = true),
|
||||
("overprint", |s| s.overprint_fill = true),
|
||||
("character style", |s| s.style_name = Some("Accent".into())),
|
||||
("leading", |s| s.leading = Some(60.0)),
|
||||
("baseline shift", |s| s.baseline_shift = 5.0),
|
||||
("rotation", |s| s.rotation = 15.0),
|
||||
("character alignment", |s| s.char_align = vectorcraft_doc::CharAlign::EmBoxCenter),
|
||||
];
|
||||
for byte in 0..4 {
|
||||
for (what, restyle) in restyles {
|
||||
let t = avav(byte, restyle);
|
||||
assert_eq!(t.runs.len(), if byte == 0 || byte == 3 { 2 } else { 3 }, "{what} on byte {byte}");
|
||||
assert_eq!(pens(&t), plain, "{what} on byte {byte} moves no glyph");
|
||||
let l = layout(db(), &t);
|
||||
for g in &l.glyphs {
|
||||
assert_eq!(g.run, usize::from(byte > 0 && g.byte >= byte) + usize::from(g.byte > byte), "{what} on byte {byte}: glyph {g:?}");
|
||||
let x = g.origin.x + g.advance * 0.25;
|
||||
assert_eq!(hit_byte(&l, Point::new(x, l.lines[0].baseline)), g.byte, "{what} on byte {byte}: a click before glyph {}", g.byte);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The V keeps its own baseline shift and rotation.
|
||||
let l = layout(db(), &avav(1, |s| s.baseline_shift = 5.0));
|
||||
let y: Vec<f64> = l.glyphs.iter().map(|g| g.xf.as_coeffs()[5]).collect();
|
||||
assert!((y[0] - y[1] - 5.0).abs() < 1e-9 && y[0] == y[2] && y[0] == y[3], "{y:?}");
|
||||
let l = layout(db(), &avav(1, |s| s.rotation = 90.0));
|
||||
assert!(l.glyphs[1].xf.as_coeffs()[1].abs() > l.glyphs[1].xf.as_coeffs()[0].abs(), "the V stands rotated: {:?}", l.glyphs[1].xf);
|
||||
assert_eq!(l.glyphs[0].xf.as_coeffs()[1], 0.0, "the A does not");
|
||||
// A V with 60 pt leading on the second line sets that line 60 pt below the first.
|
||||
let mut t = TextObject::point(Point::ZERO, "AVAV\nAVAV", style(40.0));
|
||||
edit::style_range(&mut t.runs, 6, 7, |s| s.leading = Some(60.0));
|
||||
let l = layout(db(), &t);
|
||||
assert!((l.lines[1].baseline - l.lines[0].baseline - 60.0).abs() < 1e-9, "{:?}", l.lines);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracking_or_kerning_on_one_character_keeps_the_kerning_before_it() {
|
||||
let plain = pens(&TextObject::point(Point::ZERO, "AVAV", style(40.0)));
|
||||
let unkerned = pens(&TextObject::point(Point::ZERO, "AVAV", CharStyle { kerning: Some(0.0), ..style(40.0) }));
|
||||
// Tracking of 50/1000 em on the V adds 2 pt after it and leaves the font's kerning on both sides.
|
||||
let tracked = pens(&avav(1, |s| s.tracking = 50.0));
|
||||
assert_eq!(tracked[0], plain[0], "the A keeps its kerning with the V");
|
||||
assert!((tracked[1].2 - (plain[1].2 + 2.0)).abs() < 1e-9, "{tracked:?} {plain:?}");
|
||||
// Manual kerning of 100/1000 em on the V sets the space between the V and the A after it, in
|
||||
// place of the font's; the A before the V keeps the font's kerning with it.
|
||||
let kerned = pens(&avav(1, |s| s.kerning = Some(100.0)));
|
||||
assert_eq!(kerned[0], plain[0], "the A keeps its kerning with the V");
|
||||
assert!((kerned[1].2 - (unkerned[1].2 + 4.0)).abs() < 1e-9, "{kerned:?} {unkerned:?}");
|
||||
assert!((kerned[2].1 - (plain[1].1 + unkerned[1].2 + 4.0)).abs() < 1e-9, "{kerned:?}");
|
||||
assert_eq!((kerned[2].2, kerned[3].2), (plain[2].2, plain[3].2), "the A and V after it keep their kerning");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_kerning_after_a_small_caps_break_replaces_the_fonts_kerning() {
|
||||
// Small Caps shapes the lowercase a apart from "AVA", which has manual kerning while the a has
|
||||
// the font's: "AVA" is set as it is when all of the text has manual kerning.
|
||||
let caps = CharStyle { small_caps: Some(70.0), ..style(40.0) };
|
||||
let mut mixed = TextObject::point(Point::ZERO, "aAVA", caps.clone());
|
||||
edit::style_range(&mut mixed.runs, 1, 4, |s| s.kerning = Some(0.0));
|
||||
let manual = TextObject::point(Point::ZERO, "aAVA", CharStyle { kerning: Some(0.0), ..caps });
|
||||
let advances = |t: &TextObject| pens(t).into_iter().filter(|g| g.0 > 0).map(|g| g.2).collect::<Vec<_>>();
|
||||
assert_eq!(advances(&mixed), advances(&manual));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_kerning_replaces_both_halves_of_the_fonts_pair_after_it() {
|
||||
use crate::test_fonts::{PAIR_KERNING_FAMILY, pair_kerning_font};
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
db.add_font(pair_kerning_font().unwrap());
|
||||
// 100 pt in a 1000-unit font: the pair takes 6 pt off the A's advance and moves the V 10 pt left.
|
||||
let st = CharStyle { font_family: PAIR_KERNING_FAMILY.into(), ..style(100.0) };
|
||||
// Each A's advance and how far the V after it is drawn from its pen position, in `text` with
|
||||
// manual kerning on the bytes `manual`.
|
||||
let set = |text: &str, manual: std::ops::Range<usize>| {
|
||||
let mut t = TextObject::point(Point::ZERO, text, st.clone());
|
||||
edit::style_range(&mut t.runs, manual.start, manual.end, |s| s.kerning = Some(0.0));
|
||||
let l = layout(&db, &t);
|
||||
let g = |c: char| l.glyphs.iter().filter(move |g| text[g.byte..].starts_with(c));
|
||||
g('A').zip(g('V')).map(|(a, v)| (a.advance, v.xf.as_coeffs()[4] - v.origin.x)).collect::<Vec<_>>()
|
||||
};
|
||||
let (kerned, unkerned) = (set("AV", 0..0)[0], set("AV", 0..2)[0]);
|
||||
assert!((unkerned.0 - kerned.0 - 6.0).abs() < 1e-9 && (unkerned.1 - kerned.1 - 10.0).abs() < 1e-9, "{kerned:?} {unkerned:?}");
|
||||
assert_eq!(set("AV", 0..1), [unkerned], "manual kerning on the A turns off the whole A-V pair");
|
||||
assert_eq!(set("AV", 1..2), [kerned], "manual kerning on the V keeps the A-V pair");
|
||||
assert_eq!(set("HAV", 0..1), [kerned], "manual kerning on the H before them keeps the A-V pair");
|
||||
assert_eq!(set("AVAV", 1..4), [kerned, unkerned], "manual kerning from the first V on: the second pair is off");
|
||||
assert_eq!(set("AV\u{301}", 1..4), [kerned], "manual kerning on a V with a combining accent keeps the A-V pair");
|
||||
// An ß in All Caps is set as SS: with manual kerning on it, the font's S-S pair is off.
|
||||
let caps = |manual: std::ops::Range<usize>| {
|
||||
let mut t = TextObject::point(Point::ZERO, "Aß", CharStyle { all_caps: true, ..st.clone() });
|
||||
edit::style_range(&mut t.runs, manual.start, manual.end, |s| s.kerning = Some(0.0));
|
||||
let l = layout(&db, &t);
|
||||
l.glyphs.iter().filter(|g| g.byte == 1).map(|g| (g.advance, g.xf.as_coeffs()[4] - g.origin.x)).collect::<Vec<_>>()
|
||||
};
|
||||
let (auto, manual) = (caps(0..0), caps(0..3));
|
||||
assert_eq!(auto.len(), 2);
|
||||
assert_ne!(auto, manual, "the font kerns S-S");
|
||||
assert_eq!(caps(1..3), manual, "manual kerning on the ß turns off the S-S pair");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_size_change_still_shapes_the_character_apart() {
|
||||
let plain = pens(&TextObject::point(Point::ZERO, "AVAV", style(40.0)));
|
||||
let unkerned = pens(&TextObject::point(Point::ZERO, "AVAV", CharStyle { kerning: Some(0.0), ..style(40.0) }));
|
||||
let larger = pens(&avav(1, |s| s.size = 41.0));
|
||||
assert_eq!(larger[0], unkerned[0], "a V of another size is shaped on its own: {larger:?}");
|
||||
assert_ne!(larger[0], plain[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ligature_does_not_span_a_change_of_paint() {
|
||||
let serif = CharStyle { font_family: "Source Serif 4".into(), ..style(20.0) };
|
||||
let glyphs = |byte: Option<usize>| {
|
||||
let mut t = TextObject::point(Point::ZERO, "ofit", serif.clone());
|
||||
if let Some(byte) = byte {
|
||||
edit::style_range(&mut t.runs, byte, byte + 1, |s| s.fill = Default::default());
|
||||
}
|
||||
layout(db(), &t).glyphs.iter().map(|g| (g.byte, g.len)).collect::<Vec<_>>()
|
||||
};
|
||||
let fi = vec![(0, 1), (1, 2), (3, 1)];
|
||||
assert_eq!(glyphs(None), fi, "fi ligature in unstyled text");
|
||||
assert_eq!(glyphs(Some(0)), fi, "a restyled o leaves the fi ligature");
|
||||
assert_eq!(glyphs(Some(3)), fi, "a restyled t leaves the fi ligature");
|
||||
assert_eq!(glyphs(Some(2)), vec![(0, 1), (1, 1), (2, 1), (3, 1)], "an i in another paint is a glyph of its own");
|
||||
}
|
||||
|
||||
// ---------- performance ----------
|
||||
|
||||
#[test]
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user