mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
PDF/.ai import: glyphs spaced each their own way keep their place
Character tightening and optical kerning space each glyph differently, as Japanese titles often are. The importer evened a line out with one tracking value (type on a path took none), leaving glyphs up to an eighth of an em out of place. When the file's font is installed and one value can't place every glyph, each character now keeps its own tracking, measured along the baseline (along the path for type on a path). Glyphs closed up by up to half an em (the proportional widths of CJK punctuation) also continue their line instead of starting another. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
5e9b7d09a2
commit
aa9f0153a4
@@ -136,6 +136,8 @@ pub(crate) struct TextLine {
|
||||
/// The spaces read from gaps between glyphs: their byte offset in the text, and the gap from
|
||||
/// the end of the glyph before to the start of the next (points).
|
||||
gaps: Vec<(usize, f64)>,
|
||||
/// Each glyph's advance in the file's font (points), with [`Self::baseline`].
|
||||
advances: Vec<f64>,
|
||||
}
|
||||
|
||||
/// What a finished line of type tells beyond its text object (see [`crate::import_lines`]).
|
||||
@@ -168,6 +170,7 @@ impl TextLine {
|
||||
column: false,
|
||||
ink: vec![],
|
||||
gaps: vec![],
|
||||
advances: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +231,8 @@ impl TextLine {
|
||||
&& 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
|
||||
&& gap > -size * 0.3
|
||||
// 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.
|
||||
@@ -263,6 +267,7 @@ impl TextLine {
|
||||
self.last = at.origin;
|
||||
self.last_dir = at.dir;
|
||||
self.baseline.push(at.origin);
|
||||
self.advances.push(advance);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -329,6 +334,14 @@ impl TextLine {
|
||||
// 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 {
|
||||
t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0, end: None };
|
||||
t.xf = Affine::IDENTITY;
|
||||
@@ -358,7 +371,7 @@ impl TextLine {
|
||||
let chars: usize = t.runs.iter().map(|r| r.text.chars().count()).sum();
|
||||
let size = self.at.size;
|
||||
let mut bounds = laid.bounds;
|
||||
if chars > 1 && length.is_finite() && (length - natural).abs() > size * 0.01 {
|
||||
if own.is_none() && chars > 1 && length.is_finite() && (length - natural).abs() > size * 0.01 {
|
||||
// The spaces read from gaps 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)> =
|
||||
@@ -373,6 +386,59 @@ impl TextLine {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 (`gaps`: byte offset, gap in points) where that would put the glyphs
|
||||
|
||||
@@ -464,3 +464,12 @@ fn art_past_the_page_clip_keeps_the_clip() {
|
||||
assert!(!all(&r.document).iter().any(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })));
|
||||
assert!(!r.warnings.iter().any(|w| w == crate::import::PAST_PAGE_NOTE), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// Glyphs closed up by up to half an em (the proportional widths of CJK punctuation) still
|
||||
/// continue their line.
|
||||
#[test]
|
||||
fn glyphs_closed_up_by_half_an_em_stay_one_line() {
|
||||
let t = texts(&open(&one_page("BT /F1 20 Tf 10 40 Td [(T) 400 (i) 450 (g) 0 (h) 0 (t)] TJ ET", HELVETICA, &[])));
|
||||
assert_eq!(t.len(), 1, "one line: {:?}", t.iter().map(|t| t.plain_text()).collect::<Vec<_>>());
|
||||
assert_eq!(t[0].plain_text(), "Tight");
|
||||
}
|
||||
|
||||
@@ -329,3 +329,46 @@ fn type_in_another_installed_version_reopens_in_that_version() {
|
||||
assert_eq!(found, [("Hamburgefonstiv".to_string(), version.map(String::from))], "{version:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The glyph origins of the type objects of `d`, in document space, in order.
|
||||
fn origins(d: &Document) -> Vec<Point> {
|
||||
let mut out = vec![];
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
out.extend(vectorcraft_text::layout(FontDb::global(), t).glyphs.iter().map(|g| t.xf * g.origin));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Type spaced glyph by glyph (character tightening, optical kerning) comes back with each glyph
|
||||
/// in its place, not evened out by one tracking value; evenly spaced type keeps one run.
|
||||
#[test]
|
||||
fn glyphs_spaced_each_their_own_way_keep_their_place() {
|
||||
let runs = [("T", 0.0), ("i", -120.0), ("g", 160.0), ("h", -60.0), ("tl", 0.0), ("y", 0.0)];
|
||||
let mut t = TextObject::point(Point::new(20.0, 100.0), "", style(40.0));
|
||||
t.runs = runs.iter().map(|(s, tr)| TextRun { text: s.to_string(), style: CharStyle { tracking: *tr, ..style(40.0) }, inline: None }).collect();
|
||||
let d = doc(vec![t]);
|
||||
let want = origins(&d);
|
||||
let back = import_as(&pdf(&d, false).bytes, TextAs::default());
|
||||
let got = origins(&back);
|
||||
assert_eq!(got.len(), want.len(), "{:?}", texts(&back));
|
||||
for (g, w) in got.iter().zip(&want) {
|
||||
assert!((g.x - w.x).abs() < 0.1 && (g.y - w.y).abs() < 0.1, "{got:?}\nvs {want:?}");
|
||||
}
|
||||
let mut runs_back = 0;
|
||||
back.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
runs_back += t.runs.len();
|
||||
}
|
||||
});
|
||||
assert!(runs_back > 1, "one tracking can't place them");
|
||||
// Evenly tracked: one run.
|
||||
let even = doc(vec![TextObject::point(Point::new(20.0, 100.0), "Tightly", CharStyle { tracking: -50.0, ..style(40.0) })]);
|
||||
let back = import_as(&pdf(&even, false).bytes, TextAs::default());
|
||||
back.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
assert_eq!(t.runs.len(), 1, "{:?}", t.runs.iter().map(|r| r.style.tracking).collect::<Vec<_>>());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user