Files
vectorcraft/crates/pdf/src/import_text.rs
T
Patrick LenzenandClaude Opus 5.5 6903bb7ef8 Fix #3623: a word gap between two sizes reads as a space in PDF import
PDF import measured each gap between two glyphs against the larger of
the line's first glyph size and the next glyph's. A word set beside a
much larger one, apart by positioning and no space glyph (a browser's
margin between two sizes), stayed under a fifth of that em: "≤ 4" at
20 pt and "hours" at 8 pt, 3.4 pt apart, came in as "≤ 4hours".

A gap is now measured against the smaller of the two glyphs beside it.
Lines in one size read their gaps as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:14:35 +07:00

697 lines
32 KiB
Rust

//! Text as editable point type: glyphs drawn one after another on the same baseline make one
//! point text object of their Unicode text, a run per font, size and paint (a gap wider than a
//! fifth of the smaller size beside it reads as a space). Fonts are named from the file's base font name (its
//! subset prefix dropped, the family matched against the fonts available); a font that isn't
//! available keeps its name, and the text shows in the fallback font until it is.
//!
//! Glyphs each turned a little further along a curve (type set on a path: apps write each glyph
//! with its own placement) make one type-on-a-path object, its path through the glyphs' baseline.
use std::collections::{HashMap, HashSet};
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
use vectorcraft_color::Paint;
use vectorcraft_doc::{CharStyle, Node, ParaDirection, TextKind, TextObject, TextRun};
use vectorcraft_text::TextLayout;
/// One glyph's placement: its baseline origin, advance direction, size and horizontal scale.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Placement {
pub origin: Point,
/// Unit vector along the baseline.
pub dir: Vec2,
/// Font size in points (the em's height).
pub size: f64,
/// Width of the em ÷ its height, in percent.
pub h_scale: f64,
/// How far the em leans along the baseline per unit of its height (a text matrix with a
/// shear: faux italic, or type slanted as a whole); 0 when it stands square on the baseline.
pub slant: f64,
}
impl Placement {
/// Where a glyph drawn with `m` (glyph space, 1000 units per em, y up → document) sits;
/// `None` when it is mirrored, degenerate or not finite (those keep their outlines).
pub fn of(m: Affine) -> Option<Self> {
let origin = m * Point::ORIGIN;
let ex = m * Point::new(1000.0, 0.0) - origin;
let ey = m * Point::new(0.0, 1000.0) - origin;
let w = ex.hypot();
// Upright in y-down document space: x to the right of up.
let upright = ex.cross(ey) < 0.0;
if !(w.is_finite() && w > 0.01) {
return None;
}
let dir = ex / w;
// The em's height is across the baseline; what's left of `ey` along it is a slant.
let up = Vec2::new(dir.y, -dir.x);
let size = ey.dot(up);
let slant = ey.dot(dir) / size;
(origin.is_finite() && size.is_finite() && slant.is_finite() && size > 0.01 && size < 1e5 && upright).then(|| Self {
origin,
dir,
size,
h_scale: w / size * 100.0,
slant,
})
}
fn up(&self) -> Vec2 {
Vec2::new(self.dir.y, -self.dir.x)
}
}
/// A glyph set vertically (WMode 1: no horizontal advance) and the top centre of its em box: a
/// column of them is vertical type.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Upright {
pub top: Point,
}
/// What a run of type is drawn with: its font (cache key, family and style), size, horizontal
/// scale, fill and stroke (paint and width).
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Look {
pub font: u128,
pub family: String,
pub style: String,
/// The installed version of the font when it isn't the one family and style resolve to.
pub version: Option<String>,
pub size: f64,
pub h_scale: f64,
pub fill: Option<Paint>,
pub stroke: Option<(Paint, f64)>,
/// The fill, the stroke overprints.
pub overprint_fill: bool,
pub overprint_stroke: bool,
}
impl Look {
/// Can a glyph drawn with `other` join a run drawn with this? A fill joins a run whose
/// glyphs were also stroked over (fill and stroke rendering draws each glyph twice); invisible
/// text (neither fill nor stroke) joins only invisible text.
fn takes(&self, other: &Look) -> bool {
self.font == other.font
&& (self.size - other.size).abs() <= self.size * 0.01
&& (self.h_scale - other.h_scale).abs() < 0.5
&& self.fill == other.fill
&& self.overprint_fill == other.overprint_fill
&& ((other.stroke.is_none() && other.fill.is_some()) || (self.stroke == other.stroke && self.overprint_stroke == other.overprint_stroke))
}
fn style(&self) -> CharStyle {
let (stroke, stroke_width) = self.stroke.clone().unwrap_or((Paint::None, 0.0));
CharStyle {
font_family: self.family.clone(),
font_style: self.style.clone(),
font_version: self.version.clone(),
size: round(self.size),
h_scale: round(self.h_scale),
fill: self.fill.clone().unwrap_or(Paint::None),
stroke,
stroke_width,
overprint_fill: self.overprint_fill,
overprint_stroke: self.overprint_stroke,
..CharStyle::default()
}
}
}
/// A gap between adjacent glyphs on a line.
#[derive(Clone, Copy, Debug)]
struct Gap {
/// Byte offset in the line's text of the glyph after the gap.
at: usize,
/// From the end of the glyph before to the start of the next (points).
width: f64,
/// The smaller of the two glyphs' sizes (points): a word gap beside a much larger glyph is
/// narrow for the large size (#3623).
em: f64,
/// Along the line, not turned along a curve.
on_line: bool,
}
/// The spacing a line's letters share (letter-spaced type), in ems: the gap a third of the way up
/// from the narrowest, of three or more along the line. Never below zero, and none at all past
/// half an em: glyphs set wider apart than that are words or columns, not tracked letters.
fn common_gap(gaps: &[Gap]) -> f64 {
let mut widths: Vec<f64> = gaps.iter().filter(|g| g.on_line).map(|g| g.width / g.em).filter(|w| w.is_finite()).collect();
if widths.len() < 3 {
return 0.0;
}
let pivot = widths.len() / 3;
let (_, common, _) = widths.select_nth_unstable_by(pivot, f64::total_cmp);
if *common > 0.5 { 0.0 } else { common.max(0.0) }
}
/// A line of type being gathered: runs of glyphs on one baseline.
pub(crate) struct TextLine {
/// The first glyph's placement.
at: Placement,
opacity: f32,
/// Where the next glyph would start without spacing.
next: Point,
/// The last glyph's origin.
last: Point,
/// Where the last glyph that isn't a space ends.
visible: Point,
/// The last glyph's baseline direction.
last_dir: Vec2,
/// The last glyph's size (points).
last_size: f64,
/// Each glyph's baseline origin, then where the last one ends.
baseline: Vec<Point>,
runs: Vec<(Look, String)>,
/// The first glyph was set vertically ([`Upright`]).
upright: Option<Upright>,
/// The glyphs make a column (vertical type), each below the last.
column: bool,
/// Each glyph's ink box as drawn (document space), in drawing order; glyphs without ink are
/// left out.
pub ink: Vec<Rect>,
/// The gaps between adjacent glyphs that may read as spaces ([`finish`](Self::finish)
/// decides).
gaps: Vec<Gap>,
/// The spaces the file draws between two glyphs that aren't spaces: their byte offset in the
/// text before the spaces read from gaps go in, and the gaps on both sides of it plus its
/// advance (points).
drawn_spaces: Vec<(usize, f64)>,
/// A space the file drew after a glyph that isn't one, until the next glyph comes: its byte
/// offset, and the gap before it plus its advance (points).
drawn_space: Option<(usize, f64)>,
/// Each glyph's advance in the file's font (points), with [`Self::baseline`].
advances: Vec<f64>,
/// The strokes of its glyphs' outlines drawn between them, one per glyph with ink so far
/// (written right after the line).
pub held: Vec<Node>,
}
/// What a finished line of type tells beyond its text object (see [`crate::import_lines`]).
#[derive(Clone, Debug, Default)]
pub(crate) struct LineFacts {
/// [`TextLine::ink`].
pub ink: Vec<Rect>,
/// Horizontal point type: it ended in a space (as a line an editor wrapped in a frame does).
pub wraps: bool,
/// Horizontal point type: how long the file set it, from its first glyph's origin to the end
/// of its last glyph that isn't a space (points, along the baseline).
pub length: f64,
}
/// The most a glyph on a curve turns from the one before it (about 20°).
const CURVE_TURN_COS: f64 = 0.94;
impl TextLine {
pub fn new(at: Placement, opacity: f32) -> Self {
Self {
at,
opacity,
next: at.origin,
last: at.origin,
visible: at.origin,
last_dir: at.dir,
last_size: at.size,
baseline: vec![],
runs: vec![],
upright: None,
column: false,
ink: vec![],
gaps: vec![],
drawn_spaces: vec![],
drawn_space: None,
advances: vec![],
held: vec![],
}
}
/// The paint the last glyph is filled with.
pub fn last_fill(&self) -> Option<&Paint> {
self.runs.last()?.0.fill.as_ref()
}
/// Add glyph `text` set vertically (`upright`) if it continues this column (or makes this
/// line's single glyph a column): below the last one, in the same column. `false`: it starts
/// another line.
pub fn push_upright(&mut self, look: &Look, at: Placement, opacity: f32, upright: Upright, text: &str) -> bool {
let down = -self.at.up();
let size = self.at.size.max(at.size);
let Some((last, run)) = self.runs.last_mut() else {
self.runs.push((look.clone(), text.to_string()));
(self.upright, self.next, self.last) = (Some(upright), at.origin + down * at.size, at.origin);
return true;
};
let across = (at.origin - self.at.origin).dot(self.at.dir);
let step = (at.origin - self.next).dot(down);
// Only glyphs set vertically (WMode 1) come here, so a second one below the first, one em
// on or letter-spaced up to another em, makes a column.
let first_step = (at.origin - self.last).dot(down);
let starts_column = !self.column && self.upright.is_some() && first_step > size * 0.95 && first_step < size * 2.0;
let in_column = (self.column || starts_column)
&& opacity == self.opacity
&& at.dir.dot(self.at.dir) > 0.9995
&& across.abs() < size * 0.6
&& step > -size * 0.3
&& step < size * 1.2;
if !in_column {
return false;
}
self.column = true;
// Letter-spaced Japanese is common: a gap reads as a space only when a whole em is left
// out (the tracking set in `finish` keeps narrower gaps).
if step > size * 0.9 && !run.ends_with(' ') && !text.starts_with(' ') {
run.push(' ');
}
if last.takes(look) {
run.push_str(text);
} else {
self.runs.push((look.clone(), text.to_string()));
}
self.next = at.origin + down * at.size;
self.last = at.origin;
true
}
/// Add glyph `text` drawn with `look` at `at` (advancing `advance` points) at `opacity` if
/// it continues this line; `false`: it starts another.
pub fn push(&mut self, look: &Look, at: Placement, opacity: f32, advance: f64, text: &str) -> bool {
// A vertical line (even a single upright glyph) takes no horizontal glyphs.
if self.column || self.upright.is_some() {
return false;
}
let bytes: usize = self.runs.iter().map(|(_, t)| t.len()).sum();
if let Some((last, run)) = self.runs.last_mut() {
let size = self.at.size.max(at.size);
let gap = (at.origin - self.next).dot(self.last_dir);
let on_line = opacity == self.opacity
&& at.dir.dot(self.at.dir) > 0.9995
&& (at.slant - self.at.slant).abs() < 1e-3
&& (at.origin - self.at.origin).dot(self.at.up()).abs() < size * 0.15
// Proportional widths close CJK marks up by up to half an em.
&& gap > -size * 0.6
&& gap < size * 3.0;
// Or on a curve: turned a little from the glyph before it, and starting about where
// that one ends.
let on_curve = !on_line
&& opacity == self.opacity
&& (at.slant - self.at.slant).abs() < 1e-3
&& at.dir.dot(self.last_dir) > CURVE_TURN_COS
&& at.dir.dot(self.last_dir) < 0.99999
&& (at.origin - self.next).hypot() < size * 0.6;
if !on_line && !on_curve {
return false;
}
if !run.ends_with(' ') && !text.starts_with(' ') {
self.gaps.push(Gap { at: bytes, width: gap, em: self.last_size.min(at.size), on_line });
}
// A space the file draws between two glyphs spans the gaps on both sides of it.
let drawn = self.drawn_space.take();
if on_line {
if text == " " && !run.ends_with(' ') {
self.drawn_space = Some((bytes, gap + advance));
} else if let Some((b, before)) = drawn.filter(|_| !text.starts_with(' ')) {
self.drawn_spaces.push((b, before + gap));
}
}
if last.takes(look) {
run.push_str(text);
} else {
self.runs.push((look.clone(), text.to_string()));
}
} else {
self.runs.push((look.clone(), text.to_string()));
}
self.next = at.origin + at.dir * advance;
if !text.trim().is_empty() {
self.visible = self.next;
}
self.last = at.origin;
self.last_dir = at.dir;
self.last_size = at.size;
self.baseline.push(at.origin);
self.advances.push(advance);
true
}
/// Has the baseline turned (more than about 3° from the first glyph to the last)?
fn curved(&self) -> bool {
self.last_dir.dot(self.at.dir) < 0.9986
}
/// A smooth path through the glyphs' baseline origins and the end of the last glyph, an em
/// further (Catmull-Rom through the points, as cubic Béziers).
fn baseline_path(&self) -> Option<BezPath> {
let mut pts = self.baseline.clone();
// On past the end of the last glyph by an em, so rounding in the spacing doesn't push it
// off the end of the path (where type on a path stops).
pts.push(self.next);
pts.push(self.next + self.last_dir * self.at.size);
let first = *pts.first()?;
// Finite placements far out can still overflow once an em is added: no path then.
if pts.len() < 3 || !pts.iter().all(|p| p.is_finite()) {
return None;
}
let mut bp = BezPath::new();
bp.move_to(first);
for (i, seg) in pts.windows(2).enumerate() {
let &[p1, p2] = seg else { continue };
let p0 = *pts.get(i.wrapping_sub(1)).unwrap_or(&p1);
let p3 = *pts.get(i + 2).unwrap_or(&p2);
bp.curve_to(p1 + (p2 - p0) / 6.0, p2 - (p3 - p1) / 6.0, p2);
}
Some(bp)
}
/// A stroke (`stroke`: paint and width) over the glyph just drawn at `at` in `font` (fill
/// and stroke rendering), which `overprint`s or not: the run is stroked. `false`: it isn't
/// that glyph.
pub fn stroke_last(&mut self, font: u128, at: Placement, stroke: (Paint, f64), overprint: bool) -> bool {
let Some((look, _)) = self.runs.last_mut().filter(|(l, _)| l.font == font && (at.origin - self.last).hypot() < self.at.size * 1e-3) else {
return false;
};
(look.stroke, look.overprint_stroke) = (Some(stroke), overprint);
true
}
/// The point type object, its opacity and what else the line tells.
pub fn finish(mut self) -> Option<(TextObject, f32, LineFacts)> {
let mut facts = LineFacts { ink: std::mem::take(&mut self.ink), ..LineFacts::default() };
// Turned along a curve: type on a path through the glyphs.
let path = if self.curved() { self.baseline_path() } else { None };
facts.wraps = path.is_none() && self.upright.is_none() && self.runs.last().is_some_and(|(_, t)| t.ends_with(' '));
if let Some((_, last)) = self.runs.last_mut() {
last.truncate(last.trim_end().len());
}
self.runs.retain(|(_, t)| !t.is_empty());
if self.runs.iter().all(|(_, t)| t.trim().is_empty()) {
return None;
}
let mut runs = self.runs.into_iter().map(|(look, text)| TextRun { text, style: look.style(), inline: None });
let first = runs.next()?;
let mut t = TextObject::point(Point::ORIGIN, &first.text, first.style);
t.runs.extend(runs);
// A gap wider than a fifth of an em, past the spacing the line's letters share, reads as a
// space (one set at exactly a fifth still counts, whatever the rounding). Letter spacing is
// read only in text left in the file's order.
let space = |gap: &Gap, common: f64| gap.width / gap.em - common > 0.199;
let directed = vectorcraft_text::logical_order(&t.plain_text()).is_some();
let mut common = if directed { 0.0 } else { common_gap(&self.gaps) };
// Evenly spaced glyphs alone could be letters set apart or words of one letter each: the
// shared spacing is letter spacing only on a line that also shows where its words break,
// with spaces it draws or with gaps wider than the rest.
if self.drawn_spaces.is_empty() && !self.gaps.iter().any(|gap| space(gap, common)) {
common = 0.0;
}
let mut spaces = vec![];
for gap in &self.gaps {
if !space(gap, common) {
continue;
}
// After the earlier spaces, at the end of the run before it (the run it follows).
let at = gap.at + spaces.len();
let mut offset = 0;
let Some(run) = t.runs.iter_mut().find(|run| {
offset += run.text.len();
at <= offset
}) else {
break;
};
let at = at - (offset - run.text.len());
if !run.text.is_char_boundary(at) {
continue;
}
run.text.insert(at, ' ');
spaces.push((gap.at, gap.width, gap.on_line));
}
// Each by its offset in the text with the spaces in.
let mut gaps: Vec<(usize, f64)> = spaces.iter().enumerate().filter(|(_, s)| s.2).map(|(i, s)| (s.0 + i, s.1)).collect();
gaps.extend(self.drawn_spaces.iter().map(|&(b, width)| (b + spaces.iter().filter(|s| s.0 < b).count(), width)));
gaps.sort_by_key(|&(b, _)| b);
if self.upright.is_none() {
to_logical(&mut t);
}
let angle = self.at.dir.atan2();
// The glyphs' tops lean along the baseline (+x), up being -y in the type's own space.
t.xf = Affine::translate(self.at.origin.to_vec2()) * Affine::rotate(angle) * Affine::skew(-self.at.slant, 0.0);
let db = vectorcraft_text::FontDb::global();
// Glyphs spaced each their own way (character tightening, optical kerning) keep their
// place: each character its own tracking.
let own = (self.upright.is_none() && t.para.direction.is_none())
.then(|| own_spacing(&t, &self.baseline, &self.advances, path.as_ref(), self.at.dir, self.at.size))
.flatten();
if let Some(own) = &own {
t.runs = tracked_each(std::mem::take(&mut t.runs), own, self.at.size);
}
if let Some(path) = path {
// Evenly spaced type keeps its length along the path: the path runs through the
// glyphs' origins, one curve each, to the end of the last glyph, and tracking makes up
// the difference from the advances.
if own.is_none() {
let length: f64 = path.segments().take(self.baseline.len()).map(|seg| kurbo::ParamCurveArclen::arclen(&seg, 1e-3)).sum();
let laid = vectorcraft_text::layout(db, &t);
let natural: f64 = laid.glyphs.iter().map(|g| g.advance).sum();
let chars: usize = t.runs.iter().map(|r| r.text.chars().count()).sum();
let size = self.at.size;
if chars > 1 && length.is_finite() && (length - natural).abs() > size * 0.01 {
let (tracking, spaces) = spacing(&laid, &[], length - natural, chars, size);
t.runs = tracked(std::mem::take(&mut t.runs), tracking, &spaces);
}
}
t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0, end: None };
t.xf = Affine::IDENTITY;
t.cached_bounds = Some(vectorcraft_text::layout(db, &t).bounds);
return Some((t, self.opacity, facts));
}
let (start, along) = match self.upright {
// Vertical point type (a column, or a single upright glyph) is anchored at the top
// centre of its first em box.
Some(u) => {
t.vertical = true;
t.xf = Affine::translate(u.top.to_vec2()) * Affine::rotate(angle);
(self.at.origin, -self.at.up())
}
_ => (self.at.origin, self.at.dir),
};
// Tracking that makes the line as long as in the file: the PDF placed each glyph, the
// layout sets them by their advances. Spread over the gaps between characters (tracking
// follows each character, the last one's past the end of the line). A horizontal line is
// measured to its last glyph that isn't a space: the spaces after it were dropped.
let length = (if t.vertical { self.next } else { self.visible } - start).dot(along);
if !t.vertical {
facts.length = length;
}
let laid = vectorcraft_text::layout(db, &t);
let natural: f64 = laid.glyphs.iter().map(|g| g.advance).sum();
let chars: usize = t.runs.iter().map(|r| r.text.chars().count()).sum();
let size = self.at.size;
let mut bounds = laid.bounds;
if own.is_none() && chars > 1 && length.is_finite() && (length - natural).abs() > size * 0.01 {
// The spaces read from gaps or drawn between glyphs keep their own place only in text
// left in the file's order.
let total: usize = t.runs.iter().map(|r| r.text.len()).sum();
let gaps: Vec<(usize, f64)> = if t.para.direction.is_none() { gaps.into_iter().filter(|(b, _)| *b < total).collect() } else { vec![] };
let (tracking, spaces) = spacing(&laid, &gaps, length - natural, chars, size);
t.runs = tracked(std::mem::take(&mut t.runs), tracking, &spaces);
// Tracked: laid out again for its bounds.
bounds = vectorcraft_text::layout(db, &t).bounds;
}
t.cached_bounds = Some(bounds);
Some((t, self.opacity, facts))
}
}
/// The space each glyph of `t` (laid out untracked) leaves after its advance to put the next one
/// where the file did (points; the last glyph's is the one before it), when that varies by more
/// than a fiftieth of an em: one tracking value can't place them. Glyph origins are `baseline`,
/// measured along `path` (type on a path: one curve per glyph) or along `dir`. None when the
/// glyphs can't be matched to the characters one to one (spaces, ligatures), or when the font
/// laid out isn't the file's (its advances differ from `advances`): spacing measured against a
/// stand-in's widths would keep the stand-in's differences once the font is installed.
fn own_spacing(t: &TextObject, baseline: &[Point], advances: &[f64], path: Option<&BezPath>, dir: Vec2, size: f64) -> Option<Vec<f64>> {
let text: String = t.runs.iter().map(|r| r.text.as_str()).collect();
let chars = text.chars().count();
if chars < 3 || text.contains(' ') || baseline.len() != chars || advances.len() != chars {
return None;
}
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let advs: Vec<f64> = laid.glyphs.iter().map(|g| g.advance).collect();
if advs.len() != chars || advs.iter().zip(advances).any(|(a, b)| (a - b).abs() > size * 0.01) {
return None;
}
let dist: Vec<f64> = match path {
Some(bp) => bp.segments().take(chars - 1).map(|seg| kurbo::ParamCurveArclen::arclen(&seg, 1e-3)).collect(),
None => baseline.windows(2).map(|w| (w[1] - w[0]).dot(dir)).collect(),
};
if dist.len() != chars - 1 {
return None;
}
let mut extra: Vec<f64> = dist.iter().zip(&advs).map(|(d, a)| d - a).collect();
let mean = extra.iter().sum::<f64>() / extra.len() as f64;
if extra.iter().all(|e| (e - mean).abs() <= size * 0.02) {
return None;
}
extra.push(*extra.last()?);
Some(extra)
}
/// `runs` with each character's tracking from `own` (points, one per character), characters with
/// the same tracking kept in one run.
fn tracked_each(runs: Vec<TextRun>, own: &[f64], size: f64) -> Vec<TextRun> {
let em = |pts: f64| (pts / size * 1000.0).clamp(-1000.0, 1000.0).round();
let mut out: Vec<TextRun> = vec![];
let mut k = 0;
for r in runs {
for c in r.text.chars() {
let style = CharStyle { tracking: own.get(k).map_or(0.0, |e| em(*e)), ..r.style.clone() };
k += 1;
match out.last_mut() {
Some(last) if last.style == style && last.inline == r.inline => last.text.push(c),
_ => out.push(TextRun { text: c.to_string(), style, inline: r.inline.clone() }),
}
}
}
out
}
/// The tracking (thousandths of an em) that sets a line of `chars` characters, laid out untracked
/// as `laid`, `extra` points longer, as the file set it: the same after every character, except
/// the spaces read from gaps or drawn between glyphs (`gaps`: byte offset, gap in points) where
/// that would put the glyphs after them more than a tenth of an em off (a barcode's digits, set in
/// groups apart). Those take their own tracking, given with their byte offsets.
fn spacing(laid: &TextLayout, gaps: &[(usize, f64)], extra: f64, chars: usize, size: f64) -> (f64, Vec<(usize, f64)>) {
let pairs = chars.saturating_sub(1).max(1) as f64;
// The advance of the first glyph laid out at each byte offset.
let space: HashMap<usize, f64> = laid.glyphs.iter().rev().map(|g| (g.byte, g.advance)).collect();
// The space and the character before it take `need` (the gap less the space) between them;
// tracking gives them twice its share.
let needs: Vec<(usize, f64)> = gaps.iter().filter_map(|&(b, gap)| Some((b, gap - space.get(&b)?))).collect();
let mut own: Vec<(usize, f64)> = vec![];
let mut taken: HashSet<usize> = HashSet::new();
let mut tracking = extra / pairs;
// Each space taken apart changes the others' share: until no more are.
loop {
let more: Vec<(usize, f64)> =
needs.iter().filter(|(b, need)| !taken.contains(b) && (need - 2.0 * tracking).abs() > size * 0.1).copied().collect();
if more.is_empty() {
break;
}
taken.extend(more.iter().map(|(b, _)| *b));
own.extend(more);
let rest = pairs - 2.0 * own.len() as f64;
tracking = if rest >= 1.0 { (extra - own.iter().map(|(_, need)| need).sum::<f64>()) / rest } else { 0.0 };
}
own.sort_by_key(|(b, _)| *b);
let em = |pts: f64| (pts / size * 1000.0).clamp(-1000.0, 10000.0).round();
(em(tracking), own.into_iter().map(|(b, need)| (b, em(need - tracking))).collect())
}
/// `runs` with tracking `tracking`, and each space at a byte offset of `spaces` (in order) a run
/// of its own with the tracking given with it.
fn tracked(runs: Vec<TextRun>, tracking: f64, spaces: &[(usize, f64)]) -> Vec<TextRun> {
let mut out = Vec::with_capacity(runs.len() + 2 * spaces.len());
let mut off = 0;
for r in runs {
let style = CharStyle { tracking, ..r.style };
let end = off + r.text.len();
let mut from = 0;
for &(b, own) in spaces.iter().filter(|(b, _)| (off..end).contains(b)) {
let at = b - off;
let (Some(before), Some(space)) = (r.text.get(from..at), r.text.get(at..at + 1)) else { continue };
if !before.is_empty() {
out.push(TextRun { text: before.to_string(), style: style.clone(), inline: None });
}
out.push(TextRun { text: space.to_string(), style: CharStyle { tracking: own, ..style.clone() }, inline: None });
from = at + 1;
}
if let Some(rest) = r.text.get(from..).filter(|t| !t.is_empty()) {
out.push(TextRun { text: rest.to_string(), style, inline: None });
}
off = end;
}
out
}
fn round(v: f64) -> f64 {
(v * 1000.0).round() / 1000.0
}
/// Lower-case letters and digits only (`"Source Sans 3"` → `"sourcesans3"`).
fn norm(s: &str) -> String {
s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect()
}
/// `"TimesNewRoman"` → `"Times New Roman"`, `"SourceSans3"` → `"Source Sans 3"`.
fn spaced(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 4);
let mut prev: Option<char> = None;
for c in s.chars() {
if let Some(p) = prev
&& ((p.is_ascii_lowercase() && c.is_ascii_uppercase()) || (p.is_ascii_alphabetic() && c.is_ascii_digit()))
{
out.push(' ');
}
out.push(c);
prev = Some(c);
}
out
}
/// `name` without a PostScript name's trailing `PSMT`, `MT` or `PS`.
fn strip_ps(name: &str) -> &str {
["PSMT", "MT", "PS"].iter().find_map(|s| name.strip_suffix(s).filter(|r| !r.is_empty())).unwrap_or(name)
}
/// The families available, by [`norm`]ed name.
pub(crate) struct Families(HashMap<String, String>);
impl Families {
pub fn available() -> Self {
Self(vectorcraft_text::FontDb::global().families().into_iter().map(|f| (norm(&f), f)).collect())
}
/// The family and style of a font with base (PostScript) name `name`, or of weight `weight`
/// and slant `italic` when the name has no style; whether the family is available.
pub fn resolve(&self, name: &str, weight: Option<u32>, italic: bool) -> (String, String, bool) {
// A subset's six-letter tag.
let name = match name.split_once('+') {
Some((tag, rest)) if tag.len() == 6 && tag.chars().all(|c| c.is_ascii_uppercase()) => rest,
_ => name,
};
// An installed face of that exact PostScript name: its own family and style.
if let Some((family, style)) = vectorcraft_text::FontDb::global().by_postscript_name(name) {
return (family, style, true);
}
let (fam, style) = name.split_once(['-', ',']).unwrap_or((name, ""));
let found = [fam, strip_ps(fam)].iter().find_map(|f| self.0.get(&norm(f)).cloned());
let style = match spaced(strip_ps(style)).as_str() {
"" | "Roman" | "Book" | "Normal" | "Plain" => match (weight.is_some_and(|w| w >= 600) || style.contains("Bold"), italic) {
(true, true) => "Bold Italic".to_string(),
(true, false) => "Bold".to_string(),
(false, true) => "Italic".to_string(),
(false, false) => "Regular".to_string(),
},
s => s.to_string(),
};
match found {
Some(f) => (f, style, true),
None => (spaced(strip_ps(fam)).trim().to_string(), style, false),
}
}
}
/// Hebrew or Arabic comes from a PDF in visual order (each glyph where it is drawn): put `t`'s text
/// back in logical order, with the paragraph direction that shows it as drawn.
fn to_logical(t: &mut TextObject) {
let Some((order, rtl)) = vectorcraft_text::logical_order(&t.plain_text()) else { return };
let chars: Vec<(char, usize)> = t.runs.iter().enumerate().flat_map(|(i, r)| r.text.chars().map(move |c| (c, i))).collect();
let mut runs: Vec<(usize, String)> = Vec::with_capacity(t.runs.len());
for &(c, i) in order.iter().filter_map(|&k| chars.get(k)) {
match runs.last_mut() {
Some((run, text)) if *run == i => text.push(c),
_ => runs.push((i, c.to_string())),
}
}
let runs = runs.into_iter().filter_map(|(i, text)| Some(TextRun { text, style: t.runs.get(i)?.style.clone(), inline: None })).collect();
t.runs = runs;
t.para.direction = Some(if rtl { ParaDirection::RightToLeft } else { ParaDirection::LeftToRight });
}