Merge pull request #3715 from storytold/land/3356

G11 #722: keep imported PDF text boxes in place
This commit is contained in:
BFlat
2026-10-23 04:48:12 +07:00
committed by GitHub
11 changed files with 1162 additions and 137 deletions
+5
View File
@@ -154,3 +154,8 @@ display_name = "Don Omondi"
[people.alpha-rock]
display_name = "Aditya Sisodiya"
[people.Ronfo2013]
real_name = "Angelo Bernardini"
display_name = "Angelo Bernardini"
sync_github_name = false
+14 -6
View File
@@ -1628,7 +1628,7 @@ impl<'p> Builder<'p> {
return n.clone();
}
let data = o.font_data();
let name = self.fonts.get(&key).map(|(n, _)| n.clone()).or_else(|| data.as_ref().and_then(|d| d.postscript_name.clone()));
let name = self.fonts.get(&key).map(|f| f.name.clone()).or_else(|| data.as_ref().and_then(|d| d.postscript_name.clone()));
let (weight, italic) = data.as_ref().map_or((None, false), |d| (d.weight, d.is_italic));
// A subset's six-letter tag.
let ps = name.as_deref().map(|n| match n.split_once('+') {
@@ -1710,13 +1710,12 @@ impl<'p> Builder<'p> {
return false;
}
let width = o.advance_width().filter(|w| w.is_finite());
// Set vertically (WMode 1): no horizontal advance; its em box is a full em wide.
// Set vertically (WMode 1): no horizontal advance.
let upright = width.is_some_and(|w| w.abs() <= 1.0);
let top = m * kurbo::Point::new(f64::from(width.filter(|w| *w > 1.0).unwrap_or(1000.0)) * 0.5, 880.0);
let mut info = self.font_name(key, o);
// A font the file doesn't carry is drawn with a stand-in: its glyphs can't tell which
// style of the installed family it is, so the name decides.
let embedded = self.fonts.get(&key).is_none_or(|(_, e)| *e);
let embedded = self.fonts.get(&key).is_none_or(|f| f.embedded);
if let Some(face) = &info.face
&& embedded
{
@@ -1772,8 +1771,17 @@ impl<'p> Builder<'p> {
stroke,
};
let advance = width.map_or(at.size * 0.5, |w| w as f64 / 1000.0 * at.size * at.h_scale / 100.0);
let place = |line: &mut TextLine| {
if upright { line.push_upright(&look, at, opacity, Upright { top }, &text) } else { line.push(&look, at, opacity, advance, &text) }
// A glyph set vertically is drawn from its origin, which the file puts the position
// vector's vx (from the font's `W2`, else half its width from `W` or `DW`) to the left of
// the column's centre (ISO 32000-1 §9.7.4.3); vertical type centres each glyph on the
// column. Half an em only for a full-width glyph (#722).
let top = upright.then(|| {
let vx = self.fonts.get(&key).and_then(|f| f.vertical.as_ref()).map_or(500.0, |v| v.centre(glyph));
m * kurbo::Point::new(vx, 880.0)
});
let place = |line: &mut TextLine| match top {
Some(top) => line.push_upright(&look, at, opacity, Upright { top }, &text),
None => line.push(&look, at, opacity, advance, &text),
};
self.flush_glyphs();
// A stroke over the glyph just filled (fill and stroke rendering).
+75 -46
View File
@@ -14,10 +14,10 @@ use std::sync::Arc;
use kurbo::{Point, Rect, Shape};
use vectorcraft_color::BlendMode;
use vectorcraft_doc::{AppearanceItem, Justify, Node, NodeId, NodeKind, TextKind, TextObject, TextRun};
use vectorcraft_doc::{AppearanceItem, CharStyle, Justify, Node, NodeId, NodeKind, TextKind, TextObject, TextRun};
use vectorcraft_geom::shapes;
use crate::import_text::LineFacts;
use crate::import_text::{LineFacts, median};
/// Rebuild the type among `nodes` (and in their groups) from what its lines tell (`lines`).
pub(crate) fn rebuild(nodes: &mut Vec<Arc<Node>>, lines: &HashMap<NodeId, LineFacts>) {
@@ -148,79 +148,108 @@ fn block(nodes: &[Arc<Node>], lines: &HashMap<NodeId, LineFacts>) -> (usize, Opt
(count, (space > -size * 0.01).then_some(Block { leading, space: space.max(0.0), ys }))
}
/// How far a line's characters' tracking may stray (thousandths of an em) and still be one
/// value: placing each glyph where the file put it (#722) tracks characters a little apart where
/// the file's widths are rounded, or it moved a glyph a hair (`TJ`).
const EVEN_TRACKING: f64 = 10.0;
/// A line's spacing: the tracking after its letters, and how much more its spaces take (word
/// spacing, `Tw`; `None` when it has none). `None` when its letters are tracked apart (set by
/// hand), or its spaces more than `slack` apart.
fn line_spacing(t: &TextObject, slack: f64) -> Option<(f64, Option<f64>)> {
// The tracking of the runs with characters that are spaces (`true`) or that aren't.
let tracking = |space: bool| -> Vec<f64> {
t.runs.iter().filter(|r| r.text.chars().any(|c| c.is_whitespace() == space)).map(|r| r.style.tracking).collect()
};
let letters = tracking(false);
let one = median(letters.clone())?;
letters.iter().all(|x| (x - one).abs() <= EVEN_TRACKING).then_some(())?;
let spaces = tracking(true);
let word = match median(spaces.clone()) {
Some(s) => {
spaces.iter().all(|x| (x - s).abs() <= slack).then_some(())?;
Some(s - one)
}
None => None,
};
Some((one, word))
}
/// The one word spacing (thousandths of an em past the letters' tracking) of lines spaced
/// `words` (`None`: no spaces): 0 for lines without.
fn one_word_spacing(words: &[Option<f64>]) -> Option<f64> {
let w: Vec<f64> = words.iter().flatten().copied().collect();
let Some(one) = median(w.clone()) else { return Some(0.0) };
w.iter().all(|x| (x - one).abs() <= EVEN_TRACKING).then_some(if one.abs() <= EVEN_TRACKING { 0.0 } else { one })
}
/// Lines `nodes` of block `b` as one area type object, aligned left or justified, when laying it
/// out breaks its lines where the file does.
fn area_type(nodes: &[Arc<Node>], lines: &HashMap<NodeId, LineFacts>, b: &Block) -> Option<TextObject> {
let texts: Vec<(&TextObject, &LineFacts)> = nodes.iter().map(|n| line(n, lines)).collect::<Option<_>>()?;
let (first, _) = texts.first()?;
let size = first.first_style().size;
// A line's tracking: one for all its runs, or (`spaces` false) one for its runs other than
// lone spaces.
let shared = |t: &TextObject, spaces: bool| {
let mut all = t.runs.iter().filter(|r| spaces || r.text != " ").map(|r| r.style.tracking);
let one = all.next()?;
all.all(|x| x == one).then_some(one)
};
// As justified type, a wrapped line's tracking leaves out its spaces when each space is a run
// of its own, tracked within a fiftieth of an em of the line's first space.
let spread = |t: &TextObject| {
let tracking = shared(t, false)?;
let mut spaces = t.runs.iter().filter(|r| r.text == " ").map(|r| r.style.tracking);
let alike =
spaces.next().is_some_and(|s| spaces.all(|x| (x - s).abs() <= 20.0)) && !t.runs.iter().any(|r| r.text != " " && r.text.contains(' '));
(alike || shared(t, true).is_some()).then_some(tracking)
};
let trackings: Option<Vec<f64>> = texts.iter().map(|(t, _)| shared(t, true)).collect();
let justified: Option<Vec<f64>> = texts.iter().map(|(t, f)| if f.wraps { spread(t) } else { shared(t, true) }).collect();
let median = |mut v: Vec<f64>| {
v.sort_by(f64::total_cmp);
v.get(v.len() / 2).copied()
};
// Each line's tracking and word spacing. A justified line spaces its words out (word spacing,
// `Tw`), which justification does again (#722); word spacing the same on every line is the
// type's own.
// A wrapped line's spaces may each be tracked apart, within a fiftieth of an em of each other.
let spacings: Vec<(f64, Option<f64>)> =
texts.iter().map(|(t, f)| line_spacing(t, if f.wraps { 20.0 } else { EVEN_TRACKING })).collect::<Option<_>>()?;
let trackings: Vec<f64> = spacings.iter().map(|(t, _)| *t).collect();
let words: Vec<Option<f64>> = spacings.iter().map(|(_, w)| *w).collect();
let wrapped: Vec<f64> = texts.iter().filter(|(_, f)| f.wraps).map(|(_, f)| f.length).collect();
let longest = |v: &[f64]| v.iter().copied().fold(0.0, f64::max);
let mut tries = vec![];
// Wrapped lines all as long: justified (a paragraph's last line aligned left), tracked as the
// lines that end paragraphs are (the others are spaced out).
// Wrapped lines all as long: justified (a paragraph's last line aligned left), tracked and
// word spaced as the lines that end paragraphs are (the others are spaced out).
let full = longest(&wrapped);
let ends: Vec<usize> = texts.iter().enumerate().filter(|(_, (_, f))| !f.wraps).map(|(i, _)| i).collect();
let end_words: Vec<Option<f64>> = ends.iter().filter_map(|i| words.get(*i).copied()).collect();
if wrapped.len() >= 2
&& wrapped.iter().all(|l| (full - l).abs() <= size * 0.02)
&& let Some(justified) = justified
&& let Some(word) = one_word_spacing(&end_words)
{
let ends: Vec<f64> = texts.iter().zip(&justified).filter(|((_, f), _)| !f.wraps).map(|(_, t)| *t).collect();
let tracking = median(ends).or_else(|| justified.iter().copied().reduce(f64::min))?;
tries.push((Justify::JustifyLeft, full, tracking));
let tracking =
median(ends.iter().filter_map(|i| trackings.get(*i).copied()).collect()).or_else(|| trackings.iter().copied().reduce(f64::min))?;
tries.push((Justify::JustifyLeft, full, tracking, word));
}
let all: Vec<f64> = texts.iter().map(|(_, f)| f.length).collect();
if let Some(trackings) = trackings {
tries.push((Justify::Left, longest(&all) + size * 0.02, median(trackings)?));
// Every line spaced alike: aligned left.
if let Some(word) = one_word_spacing(&words) {
let all: Vec<f64> = texts.iter().map(|(_, f)| f.length).collect();
tries.push((Justify::Left, longest(&all) + size * 0.02, median(trackings)?, word));
}
tries.into_iter().find_map(|(justify, width, tracking)| laid_out(&texts, b, justify, width, tracking))
tries.into_iter().find_map(|(justify, width, tracking, word)| laid_out(&texts, b, justify, width, tracking, word))
}
/// The area type of lines `texts` (block `b`): `width` wide, aligned `justify`, its characters
/// tracked `tracking`; `None` when it doesn't lay out as the lines did.
fn laid_out(texts: &[(&TextObject, &LineFacts)], b: &Block, justify: Justify, width: f64, tracking: f64) -> Option<TextObject> {
/// tracked `tracking` and its spaces `word` more; `None` when it doesn't lay out as the lines did.
fn laid_out(texts: &[(&TextObject, &LineFacts)], b: &Block, justify: Justify, width: f64, tracking: f64, word: f64) -> Option<TextObject> {
let (first, _) = texts.first()?;
let size = first.first_style().size;
// Leading as set, unless it is Auto's (120% of the size).
let explicit =
(b.leading - size * 1.2).abs() > size * 1e-3 || texts.iter().any(|(t, _)| t.runs.iter().any(|r| (r.style.size - size).abs() > 1e-9));
let mut runs: Vec<TextRun> = vec![];
let mut push = |text: &str, style: CharStyle| match runs.last_mut() {
Some(last) if last.style == style => last.text.push_str(text),
_ => runs.push(TextRun { text: text.to_string(), style, inline: None }),
};
for (k, (t, f)) in texts.iter().enumerate() {
let mut style = CharStyle::default();
for r in &t.runs {
let mut style = r.style.clone();
style.tracking = tracking;
style = r.style.clone();
style.tracking = if r.text.trim().is_empty() { tracking + word } else { tracking };
style.leading = explicit.then_some((b.leading * 1000.0).round() / 1000.0);
match runs.last_mut() {
Some(last) if last.style == style => last.text.push_str(&r.text),
_ => runs.push(TextRun { text: r.text.clone(), style, inline: None }),
}
push(&r.text, style.clone());
}
// A wrapped line's space, or the end of a paragraph.
if k + 1 < texts.len()
&& let Some(last) = runs.last_mut()
{
last.text.push(if f.wraps { ' ' } else { '\n' });
if k + 1 < texts.len() {
if f.wraps {
style.tracking = tracking + word;
push(" ", style);
} else {
push("\n", style);
}
}
}
let mut t = (*first).clone();
+152 -5
View File
@@ -20,7 +20,7 @@ use craft_pdf::interpret::CacheKey;
use craft_pdf::syntax::Pdf;
use craft_pdf::syntax::content::TypedIter;
use craft_pdf::syntax::content::ops::TypedInstruction;
use craft_pdf::syntax::object::{Array, Dict, FromBytes, MaybeRef, Name, Object, ObjectIdentifier, dict_or_stream};
use craft_pdf::syntax::object::{Array, Dict, FromBytes, MaybeRef, Name, Object, ObjectIdentifier, Stream, dict_or_stream};
use craft_pdf::syntax::page::{Page, Resources};
use crate::PdfError;
@@ -617,7 +617,10 @@ impl Walker<'_, '_> {
if let Some(f) = res.fonts.get::<Dict<'_>>(key.as_ref())
&& let Some(name) = f.get::<Name<'_>>(b"BaseFont")
{
self.fonts.entry(f.cache_key()).or_insert_with(|| (String::from_utf8_lossy(name.as_ref()).into_owned(), embeds_glyphs(&f)));
self.fonts.entry(f.cache_key()).or_insert_with(|| {
let embedded = embeds_glyphs(&f);
FontNote { name: String::from_utf8_lossy(name.as_ref()).into_owned(), embedded, vertical: vertical_metrics(&f, embedded) }
});
}
}
}
@@ -841,9 +844,153 @@ impl Walker<'_, '_> {
}
}
/// The fonts of a document by cache key: base font name, and whether the file carries the glyphs
/// ([`embeds_glyphs`]).
pub(crate) type FontNames = HashMap<u128, (String, bool)>;
/// The fonts of a document by cache key.
pub(crate) type FontNames = HashMap<u128, FontNote>;
/// What a document says of one of its fonts.
#[derive(Debug)]
pub(crate) struct FontNote {
/// Its base (PostScript) font name.
pub name: String,
/// The file carries its glyphs ([`embeds_glyphs`]).
pub embedded: bool,
/// A composite font written vertically: its descendant's metrics.
pub vertical: Option<Vertical>,
}
/// The most CIDs a vertical font's widths are read for (the most a CID-keyed font has).
const MAX_CIDS: u32 = 65_536;
/// A vertical composite font's metrics (ISO 32000-1 §9.7.4.3), in glyph space (1000 units an em):
/// where each glyph's position vector puts the column's centre.
#[derive(Debug, Default)]
pub(crate) struct Vertical {
/// The default width (`DW`, w0).
default: f64,
/// Widths by CID (`W`).
widths: HashMap<u32, f64>,
/// The position vector's x by CID (`W2`'s vx).
vx: HashMap<u32, f64>,
/// The CID of each glyph: `None` when they can't be told (a CID-keyed CFF font's charset
/// decides, or a font the file doesn't carry is drawn with a stand-in's glyphs).
cids: Option<Cids>,
}
/// How glyph ids map back to CIDs.
#[derive(Debug)]
enum Cids {
/// The same (`CIDToGIDMap` `/Identity`, or none).
Identity,
/// The first CID of each glyph a `CIDToGIDMap` stream maps.
Map(HashMap<u32, u32>),
}
impl Vertical {
/// How far right of glyph `gid`'s origin (glyph space) the column's centre is: the position
/// vector's vx from `W2`, else half the glyph's width (w0 from `W`, or `DW`).
pub fn centre(&self, gid: u32) -> f64 {
let cid = match &self.cids {
Some(Cids::Identity) => Some(gid),
Some(Cids::Map(m)) => m.get(&gid).copied(),
None => None,
};
let w0 = |cid: Option<u32>| cid.and_then(|c| self.widths.get(&c)).copied().unwrap_or(self.default) * 0.5;
cid.and_then(|c| self.vx.get(&c)).copied().unwrap_or_else(|| w0(cid))
}
}
/// The vertical metrics of font `f` when it is a composite font written vertically (its CMap's
/// writing mode is 1), from its descendant CIDFont. `embedded`: the file carries its glyphs.
pub(crate) fn vertical_metrics(f: &Dict<'_>, embedded: bool) -> Option<Vertical> {
if f.get::<Name<'_>>(b"Subtype")?.as_ref() != b"Type0" {
return None;
}
let vertical = match f.get::<Name<'_>>(b"Encoding") {
Some(n) => n.as_ref().ends_with(b"-V"),
None => f.get::<Stream<'_>>(b"Encoding").is_some_and(|s| s.dict().get::<i32>(b"WMode") == Some(1)),
};
if !vertical {
return None;
}
let d = f.get::<Array<'_>>(b"DescendantFonts").and_then(|a| a.iter::<Dict<'_>>().next())?;
let finite = |v: f64| v.is_finite() && v.abs() < 1e5;
let default = d.get::<f64>(b"DW").filter(|w| finite(*w)).unwrap_or(1000.0);
let mut widths = HashMap::new();
if let Some(w) = d.get::<Array<'_>>(b"W") {
cid_ranges(&w, 1, |cid, v| {
if let [w0] = v {
widths.insert(cid, *w0);
}
});
}
let mut vx = HashMap::new();
if let Some(w2) = d.get::<Array<'_>>(b"W2") {
cid_ranges(&w2, 3, |cid, v| {
if let [_, x, _] = v {
vx.insert(cid, *x);
}
});
}
widths.retain(|_, w| finite(*w));
vx.retain(|_, x| finite(*x));
let true_type = d.get::<Name<'_>>(b"Subtype").is_some_and(|s| s.as_ref() == b"CIDFontType2");
let cids = if !embedded || !true_type {
None
} else if let Some(s) = d.get::<Stream<'_>>(b"CIDToGIDMap") {
// Two bytes a CID, the glyph it maps to.
let data = s.decoded().ok()?;
let mut m = HashMap::new();
for (cid, g) in (0..MAX_CIDS).zip(data.as_chunks::<2>().0) {
m.entry(u32::from(u16::from_be_bytes(*g))).or_insert(cid);
}
Some(Cids::Map(m))
} else {
Some(Cids::Identity)
};
Some(Vertical { default, widths, vx, cids })
}
/// Each CID a `W` or `W2` array (`per` numbers a CID) gives values for, and its values: `c [v…]`
/// gives CIDs from c on their values in turn, `c_first c_last v…` the same values to each of the
/// range. At most [`MAX_CIDS`] CIDs are read.
fn cid_ranges(a: &Array<'_>, per: usize, mut each: impl FnMut(u32, &[f64])) {
let mut it = a.flex_iter();
let mut read = 0;
loop {
if let Some((first, list)) = it.next::<(u32, Array<'_>)>() {
let values: Vec<f64> = list.iter::<f64>().collect();
for (cid, v) in (first..=u32::MAX).zip(values.chunks_exact(per)) {
read += 1;
if read > MAX_CIDS {
return;
}
each(cid, v);
}
} else if per == 1
&& let Some((first, last, w)) = it.next::<(u32, u32, f64)>()
{
for cid in first..=last.min(first.saturating_add(MAX_CIDS)) {
read += 1;
if read > MAX_CIDS {
return;
}
each(cid, &[w]);
}
} else if per == 3
&& let Some((first, last, w, x, y)) = it.next::<(u32, u32, f64, f64, f64)>()
{
for cid in first..=last.min(first.saturating_add(MAX_CIDS)) {
read += 1;
if read > MAX_CIDS {
return;
}
each(cid, &[w, x, y]);
}
} else {
return;
}
}
}
/// Whether font `f` carries its own glyphs: a font program in its descriptor (a composite font's in
/// its descendant's), or a Type 3 font's glyph procedures. A font that doesn't is drawn with a
+296 -74
View File
@@ -1,6 +1,8 @@
//! 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
//! fifth of the smaller size beside it reads as a space; a glyph the file raised or lowered off the
//! baseline, text rise, is baseline shift). Each glyph stays where the file drew it: the kerning
//! and tracking the type takes put it there (#722). 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.
//!
@@ -161,7 +163,13 @@ pub(crate) struct TextLine {
last_size: f64,
/// Each glyph's baseline origin, then where the last one ends.
baseline: Vec<Point>,
runs: Vec<(Look, String)>,
/// Runs of glyphs drawn alike: their look, how far the file raised them off the line's
/// baseline (points, up; text rise, `Ts`), their text.
runs: Vec<(Look, f64, String)>,
/// Where the file put each character of the runs' text, in order: the first character of each
/// glyph has its glyph's [`Slot`]; a space read from a gap and the other characters of a
/// glyph of several have none.
slots: Vec<Option<Slot>>,
/// The first glyph was set vertically ([`Upright`]).
upright: Option<Upright>,
/// The glyphs make a column (vertical type), each below the last.
@@ -179,13 +187,19 @@ pub(crate) struct TextLine {
/// 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>,
}
/// Where the file put a glyph: the glyph's index in the line, and its advance in the file's font
/// (points) when it shows one character (`None` for a ligature's several).
#[derive(Clone, Copy, Debug)]
struct Slot {
glyph: usize,
advance: Option<f64>,
}
/// What a finished line of type tells beyond its text object (see [`crate::import_lines`]).
#[derive(Clone, Debug, Default)]
pub(crate) struct LineFacts {
@@ -213,13 +227,13 @@ impl TextLine {
last_size: at.size,
baseline: vec![],
runs: vec![],
slots: vec![],
upright: None,
column: false,
ink: vec![],
gaps: vec![],
drawn_spaces: vec![],
drawn_space: None,
advances: vec![],
held: vec![],
}
}
@@ -235,8 +249,8 @@ impl TextLine {
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()));
let Some((last, _, run)) = self.runs.last_mut() else {
self.runs.push((look.clone(), 0.0, text.to_string()));
(self.upright, self.next, self.last) = (Some(upright), at.origin + down * at.size, at.origin);
return true;
};
@@ -264,7 +278,7 @@ impl TextLine {
if last.takes(look) {
run.push_str(text);
} else {
self.runs.push((look.clone(), text.to_string()));
self.runs.push((look.clone(), 0.0, text.to_string()));
}
self.next = at.origin + down * at.size;
self.last = at.origin;
@@ -278,17 +292,33 @@ impl TextLine {
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 bytes: usize = self.runs.iter().map(|(_, _, t)| t.len()).sum();
let glyph = self.baseline.len();
let curved = self.curved();
let mut shift = 0.0;
if let Some((last, last_shift, run)) = self.runs.last_mut() {
let size = self.at.size.max(at.size);
let gap = (at.origin - self.next).dot(self.last_dir);
// How far the glyph sits above the line's baseline. The file raises a superscript or
// lowers a subscript (text rise, `Ts`) with the glyphs turned exactly as the rest of
// the line (`level`): it stays on the line, shifted off its baseline. Up to half an em
// when it follows on from the glyph before (no wider gap than a space reads from):
// further apart, it is type of its own (the next cell of a table's row, its baseline a
// little higher).
let above = (at.origin - self.at.origin).dot(self.at.up());
let level = at.dir.dot(self.at.dir) > 1.0 - 1e-9 && !curved;
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
&& (above.abs() < size * 0.15 || (level && above.abs() < size * 0.5 && gap <= size * 0.2))
// Proportional widths close CJK marks up by up to half an em.
&& gap > -size * 0.6
&& gap < size * 3.0;
// Within a hair of the baseline (rounding in the file) is on it; a glyph turned a
// little (type on a path) sits on the curve, not shifted off it.
if on_line && level && above.abs() > size * 0.01 {
shift = above;
}
// Or on a curve: turned a little from the glyph before it, and starting about where
// that one ends.
let on_curve = !on_line
@@ -312,14 +342,17 @@ impl TextLine {
self.drawn_spaces.push((b, before + gap));
}
}
if last.takes(look) {
if last.takes(look) && (*last_shift - shift).abs() < 1e-3 {
run.push_str(text);
} else {
self.runs.push((look.clone(), text.to_string()));
self.runs.push((look.clone(), shift, text.to_string()));
}
} else {
self.runs.push((look.clone(), text.to_string()));
self.runs.push((look.clone(), 0.0, text.to_string()));
}
let one = text.chars().count() == 1;
self.slots.push(Some(Slot { glyph, advance: one.then_some(advance) }));
self.slots.extend(text.chars().skip(1).map(|_| None));
self.next = at.origin + at.dir * advance;
if !text.trim().is_empty() {
self.visible = self.next;
@@ -328,7 +361,6 @@ impl TextLine {
self.last_dir = at.dir;
self.last_size = at.size;
self.baseline.push(at.origin);
self.advances.push(advance);
true
}
@@ -365,7 +397,7 @@ impl TextLine {
/// 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 {
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);
@@ -377,15 +409,46 @@ impl TextLine {
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() {
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()) {
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 });
// The spaces trimmed off the end have no place either.
let chars: usize = self.runs.iter().map(|(.., t)| t.chars().count()).sum();
self.slots.truncate(chars);
// The baseline most of the characters sit on is the line's: the others are shifted off it
// (a line starting with a raised footnote mark keeps the baseline of its text).
let mut on: Vec<(f64, usize)> = vec![];
for (_, shift, text) in &self.runs {
let n = text.chars().count();
match on.iter_mut().find(|(s, _)| (s - shift).abs() < 1e-3) {
Some((_, count)) => *count += n,
None => on.push((*shift, n)),
}
}
// A tie keeps the first glyph's ("m²" sits on the m's baseline).
let base = on.iter().fold(None::<(f64, usize)>, |best, &(s, n)| if best.is_none_or(|(_, b)| n > b) { Some((s, n)) } else { best });
let base = base.map_or(0.0, |(s, _)| s);
if path.is_some() {
// Type on a path sits on its curve: no glyph is shifted off it.
for (_, shift, _) in &mut self.runs {
*shift = 0.0;
}
} else if base != 0.0 {
// Up the slant the type leans by (a sheared text matrix), as the file raises it.
self.at.origin += (self.at.up() + self.at.dir * self.at.slant) * base;
for (_, shift, _) in &mut self.runs {
*shift -= base;
}
}
let mut runs = self.runs.into_iter().map(|(look, shift, text)| {
let style = CharStyle { baseline_shift: round(shift), ..look.style() };
TextRun { text, style, inline: None }
});
let first = runs.next()?;
let mut t = TextObject::point(Point::ORIGIN, &first.text, first.style);
t.runs.extend(runs);
@@ -409,15 +472,21 @@ impl TextLine {
// 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| {
let Some(i) = t.runs.iter().position(|run| {
offset += run.text.len();
at <= offset
}) else {
break;
};
// The characters before it, in the runs before and in its own.
let earlier: usize = t.runs.iter().take(i).map(|r| r.text.chars().count()).sum();
let Some(run) = t.runs.get_mut(i) else { break };
let at = at - (offset - run.text.len());
if !run.text.is_char_boundary(at) {
continue;
let Some(head) = run.text.get(..at) else { continue };
// A space read from a gap has no slot of its own.
let char_at = earlier + head.chars().count();
if char_at <= self.slots.len() {
self.slots.insert(char_at, None);
}
run.text.insert(at, ' ');
spaces.push((gap.at, gap.width, gap.on_line));
@@ -433,19 +502,20 @@ 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);
}
// Each glyph where the file put it (#722): the kerning (the font's, or none) and tracking
// (one value for the line, or each character its own) that place them there.
let placed = (self.upright.is_none() && t.para.direction.is_none())
.then(|| place_each(&t, &self.slots, &self.baseline, path.as_ref(), self.at, self.at.size))
.flatten()
.map(|(runs, laid)| {
t.runs = runs;
laid.bounds
});
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() {
if placed.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();
@@ -479,12 +549,17 @@ impl TextLine {
if !t.vertical {
facts.length = length;
}
// Placed glyph by glyph: laid out so already.
if let Some(bounds) = placed {
t.cached_bounds = Some(bounds);
return Some((t, self.opacity, facts));
}
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 {
if 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();
@@ -499,59 +574,206 @@ 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;
/// Where along the line (points) layout `laid` puts each character at a byte offset of `chars`
/// (in order), and its advance: `None` for one inside another's cluster. Text in the file's order
/// (left to right).
fn pens(laid: &TextLayout, chars: &[usize]) -> Vec<Option<(f64, f64)>> {
let mut at = vec![None; chars.len()];
let mut pen = 0.0;
for g in &laid.glyphs {
if let Some(slot) = chars.binary_search(&g.byte).ok().and_then(|i| at.get_mut(i)) {
slot.get_or_insert((pen, g.advance));
}
pen += g.advance;
}
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)
at
}
/// `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![];
/// `t`'s runs with kerning `kern` and each character's tracking from `own` (points, one per
/// character, in thousandths of each run's em), characters alike kept in one run.
fn restyled(t: &TextObject, kern: Option<f64>, own: &[f64]) -> Vec<TextRun> {
let mut out: Vec<TextRun> = Vec::with_capacity(t.runs.len());
// The run of `t` the last character came from.
let mut from = None;
let mut k = 0;
for r in runs {
for (i, r) in t.runs.iter().enumerate() {
let size = r.style.size.max(1e-3);
for c in r.text.chars() {
let style = CharStyle { tracking: own.get(k).map_or(0.0, |e| em(*e)), ..r.style.clone() };
let tracking = own.get(k).map_or(0.0, |e| (e / size * 1000.0).clamp(-1000.0, 10000.0));
k += 1;
// Hundredths of a thousandth of an em: no drift along a line, no noise in the panels.
let tracking = (tracking * 100.0).round() / 100.0;
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() }),
Some(last) if from == Some(i) && last.style.tracking == tracking => last.text.push(c),
last => {
let style = CharStyle { tracking, kerning: kern, ..r.style.clone() };
match last {
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() }),
}
}
}
from = Some(i);
}
}
out
}
/// The runs of `t` that put its glyphs where the file did (#722), and their layout: with the
/// font's kerning or none, whichever the file's spacing follows, and tracking that is the same
/// after every character when the file spaced them evenly, else each character's own (word
/// spacing, `TJ` adjustments, character tightening, spaces read from gaps).
///
/// The file put the glyph of each character with a [`Slot`] at `baseline` (one origin per glyph),
/// measured along `path` (type on a path: one curve per glyph) or along `line`'s baseline from its
/// origin, where a character shifted off it by its baseline shift draws that shift times `line`'s
/// slant further along (the type's skew leans it so, as the file's text rise did). `None` when the
/// font laid out isn't the file's (its advances differ from the file's): spacing measured against
/// a stand-in's widths would keep the stand-in's differences once the font is installed; or when
/// the glyphs can't be placed so (the layout joins characters the file drew apart).
fn place_each(
t: &TextObject,
slots: &[Option<Slot>],
baseline: &[Point],
path: Option<&BezPath>,
line: Placement,
size: f64,
) -> Option<(Vec<TextRun>, TextLayout)> {
let chars: Vec<usize> = t
.runs
.iter()
.scan(0, |off, r| {
let base = *off;
*off += r.text.len();
Some(r.text.char_indices().map(move |(i, _)| base + i).collect::<Vec<_>>())
})
.flatten()
.collect();
if chars.len() < 2 || chars.len() != slots.len() || t.runs.iter().any(|r| r.inline.is_some()) {
return None;
}
// How far along the line the file put each glyph.
let along: Vec<f64> = match path {
Some(bp) => std::iter::once(0.0)
.chain(bp.segments().scan(0.0, |d, seg| {
*d += kurbo::ParamCurveArclen::arclen(&seg, 1e-3);
Some(*d)
}))
.take(baseline.len())
.collect(),
None => baseline.iter().map(|p| (*p - line.origin).dot(line.dir)).collect(),
};
// Each character's baseline shift, which the type's slant draws `slant` times as far along.
let shifts: Vec<f64> = t.runs.iter().flat_map(|r| r.text.chars().map(move |_| r.style.baseline_shift)).collect();
let slant = if path.is_some() { 0.0 } else { line.slant };
// Each character's target: where the file put it, if it put it anywhere.
let target: Vec<Option<(f64, Option<f64>)>> =
slots.iter().zip(&shifts).map(|(s, shift)| s.and_then(|s| Some((*along.get(s.glyph)? - slant * shift, s.advance)))).collect();
if target.first().is_none_or(Option::is_none) || target.iter().flatten().any(|(d, _)| !d.is_finite()) {
return None;
}
let db = vectorcraft_text::FontDb::global();
// The spacing each character takes after it, laid out with `pens`: whatever puts the next one
// the file placed where it did. Characters between two the file placed (a space read from a
// gap, the rest of a ligature) take the line's spacing, the last of them the rest.
let spacing = |pens: &[Option<(f64, f64)>]| -> Option<Vec<f64>> {
// How far each character the file placed is from where the layout puts it.
let placed: Vec<(usize, f64)> =
target.iter().zip(pens).enumerate().filter_map(|(i, (t, p))| Some((i, t.as_ref()?.0 - p.as_ref()?.0))).collect();
let steps: Vec<f64> = placed.windows(2).filter(|w| w[1].0 == w[0].0 + 1).map(|w| w[1].1 - w[0].1).collect();
let line = median(steps).unwrap_or(0.0);
let mut own = vec![line; chars.len()];
for w in placed.windows(2) {
let ((a, da), (b, db)) = (w[0], w[1]);
*own.get_mut(b.checked_sub(1)?)? = db - da - line * (b - a - 1) as f64;
}
// After the last character the file placed, spacing changes nothing: the line's.
let last = placed.last()?.0;
let tail = if last > 0 { own.get(last - 1).copied().unwrap_or(line) } else { line };
for e in own.get_mut(last..).into_iter().flatten() {
*e = tail;
}
Some(own)
};
// Evenly spaced: one tracking value for the line. (A file rounds its widths to thousandths of
// an em: a hair either way is even.)
let even = |own: &[f64]| {
let line = median(own.to_vec())?;
own.iter().all(|e| (e - line).abs() <= size * 0.002).then(|| vec![line; own.len()])
};
let changes = |own: &[f64]| own.windows(2).filter(|w| (w[1] - w[0]).abs() > size * 1e-4).count();
// The type as it is: untracked, with the font's kerning.
let auto = vectorcraft_text::layout(db, t);
// Each glyph's own advance (the font's, unkerned) tells whether the layout's font is the
// file's, whether the font kerned any of the line's pairs, and where the glyphs go unkerned
// (the same glyphs, each on its own advance: no layout needed to tell).
let mut faces: Vec<std::sync::Arc<vectorcraft_text::FontFace>> = vec![];
let mut unkerned_pens = vec![None; chars.len()];
let mut pen = 0.0;
let mut kerned_any = false;
for g in &auto.glyphs {
let face = match faces.iter().find(|f| f.id() == g.font_id) {
Some(f) => f.clone(),
None => {
let f = db.face_by_id(g.font_id)?;
faces.push(f.clone());
f
}
};
let style = &t.runs.get(g.run)?.style;
let own = face.advance(g.gid) * style.size / face.units_per_em().max(1e-9) * style.h_scale / 100.0;
kerned_any |= (own - g.advance).abs() > size * 1e-6;
if let Some(slot) = chars.binary_search(&g.byte).ok().and_then(|i| unkerned_pens.get_mut(i)) {
slot.get_or_insert((pen, own));
}
pen += own;
}
let same_font = unkerned_pens.iter().zip(&target).all(|(n, t)| match t {
Some((_, Some(adv))) => n.is_some_and(|(_, a): (f64, f64)| (a - adv).abs() <= size * 0.01),
_ => true,
});
if !same_font {
return None;
}
// The font's kerning when the file followed it (or there is none to follow), else none.
let kerned = spacing(&pens(&auto, &chars))?;
let (kern, own) = match even(&kerned) {
Some(own) => (None, own),
// Without kerning the line lays out the same.
None if !kerned_any => (None, kerned),
None => {
let unkerned = spacing(&unkerned_pens)?;
match even(&unkerned) {
Some(own) => (Some(0.0), own),
None if changes(&unkerned) < changes(&kerned) => (Some(0.0), unkerned),
None => (None, kerned),
}
}
};
let runs = restyled(t, kern, &own);
// As the type is: laid out so already.
if kern.is_none() && runs.iter().all(|r| r.style.tracking == 0.0) {
return Some((runs, auto));
}
// Laid out so, are they where the file put them? (A ligature the layout forms where the file
// drew two glyphs moves those after it.)
let mut probe = t.clone();
probe.runs = runs;
let check = vectorcraft_text::layout(db, &probe);
let got = pens(&check, &chars);
let ok = got.iter().zip(&target).all(|(g, t)| match t {
Some((d, _)) => g.is_some_and(|(p, _)| (p - d).abs() <= size * 0.02),
None => true,
});
ok.then_some((probe.runs, check))
}
/// The middle value of `v` (the upper one of an even count).
pub(crate) fn median(mut v: Vec<f64>) -> Option<f64> {
v.sort_by(f64::total_cmp);
v.get(v.len() / 2).copied()
}
/// 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
+2
View File
@@ -307,6 +307,8 @@ mod tests_stroke;
#[cfg(test)]
mod tests_strokeout;
#[cfg(test)]
mod tests_text_placement;
#[cfg(test)]
mod tests_textstroke;
#[cfg(test)]
mod tests_webview;
+2 -1
View File
@@ -384,7 +384,8 @@ fn fonts_are_noted_past_font_resources_the_reader_cannot_parse() {
let mut fonts = crate::import_scan::FontNames::new();
let mut ocgs = crate::import_scan::Ocgs::read(&pdf);
crate::import_scan::scan_page(&pdf.pages()[0], &mut ocgs, false, &mut fonts, pdf.decode_limits().max_decoded_stream()).unwrap();
assert_eq!(fonts.into_values().collect::<Vec<_>>(), [("Helvetica".to_string(), false)], "{junk}");
let noted: Vec<_> = fonts.into_values().map(|f| (f.name, f.embedded)).collect();
assert_eq!(noted, [("Helvetica".to_string(), false)], "{junk}");
}
}
+599
View File
@@ -0,0 +1,599 @@
//! #722: type from a PDF lands where the file draws it. Each file here is written from scratch
//! from the PDF specification (ISO 32000-1 §9, "Text": the text state, text-positioning and
//! text-showing operators, Type 0, Type 3 and TrueType fonts, vertical writing), with Source Sans 3
//! from this repo's assets embedded as its font program. What the file draws (its glyphs opened as
//! outlines) and what the type it opens as draws must match contour by contour.
use std::sync::Arc;
use kurbo::{BezPath, PathEl, Point, Rect, Shape};
use vectorcraft_doc::{Document, NodeKind, TextKind, TextObject};
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with};
use vectorcraft_text::{FontDb, FontFace};
use crate::tests_import_fidelity::stream;
use crate::*;
const SOURCE_SANS: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
fn sans() -> Arc<FontFace> {
FontDb::global().find_postscript("SourceSans3-Regular").expect("Source Sans 3 is built in")
}
/// Source Sans 3's advance of `c`, in thousandths of an em (as a PDF's widths give it).
fn width(c: char) -> f64 {
let face = sans();
(face.advance(face.glyph_for(c)) * 1000.0 / face.units_per_em()).round()
}
/// The font program of Source Sans 3 Regular as a stream (ASCII hex).
fn font_file() -> String {
let hex: String = SOURCE_SANS.iter().map(|b| format!("{b:02X}")).collect::<String>() + ">";
stream(&format!("/Length1 {} /Filter /ASCIIHexDecode", SOURCE_SANS.len()), &hex)
}
/// A font descriptor for the font program in object `file`.
fn descriptor(file: usize) -> String {
format!(
"<< /Type /FontDescriptor /FontName /SourceSans3-Regular /Flags 32 /FontBBox [-500 -300 1500 1000] /ItalicAngle 0 /Ascent 1024 /Descent -400 /CapHeight 660 /StemV 80 /FontFile2 {file} 0 R >>"
)
}
/// A simple TrueType font (WinAnsi, Source Sans 3's widths) described by object `desc`.
fn simple(desc: usize) -> String {
let widths: Vec<String> = (32u8..=126).map(|c| width(c as char).to_string()).collect();
format!(
"<< /Type /Font /Subtype /TrueType /BaseFont /SourceSans3-Regular /FirstChar 32 /LastChar 126 /Widths [{}] /FontDescriptor {desc} 0 R /Encoding /WinAnsiEncoding >>",
widths.join(" ")
)
}
/// The characters the Type 0 fonts map: printable ASCII, and symbols that stand upright in
/// vertical type and are narrower than an em.
fn cid_chars() -> impl Iterator<Item = char> {
(32u8..=126).map(char::from).chain("§±×".chars())
}
/// `text` as a string of Source Sans 3's glyph ids, two bytes each (Identity-H or Identity-V,
/// CIDToGIDMap Identity).
fn gids(text: &str) -> String {
let face = sans();
let hex: String = text.chars().map(|c| format!("{:04X}", face.glyph_for(c))).collect();
format!("<{hex}>")
}
/// A ToUnicode map of Source Sans 3's glyph ids for [`cid_chars`].
fn to_unicode() -> String {
let face = sans();
let rows: Vec<String> = cid_chars().map(|c| format!("<{:04X}> <{:04X}>", face.glyph_for(c), c as u32)).collect();
let data = format!(
"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /Test-UCS def /CMapType 2 def 1 begincodespacerange <0000> <FFFF> endcodespacerange {} beginbfchar\n{}\nendbfchar endcmap CMapName currentdict /CMap defineresource pop end end",
rows.len(),
rows.join("\n")
);
stream("", &data)
}
/// A CID-keyed TrueType font (the descendant of the Type 0 fonts) of Source Sans 3 with its
/// widths (/W), described by object `desc`.
fn cid_font(desc: usize) -> String {
let face = sans();
let w: Vec<String> = cid_chars().map(|c| format!("{} [{}]", face.glyph_for(c), width(c))).collect();
format!(
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /SourceSans3-Regular /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor {desc} 0 R /CIDToGIDMap /Identity /DW 1000 /W [{}] >>",
w.join(" ")
)
}
/// [`cid_font`] with vertical metrics (/W2: each glyph's position vector half its width across,
/// 880 up) and no /W (every width the default, an em).
fn cid_font_w2(desc: usize) -> String {
let face = sans();
let w2: Vec<String> = cid_chars().map(|c| format!("{} [-1000 {} 880]", face.glyph_for(c), width(c) / 2.0)).collect();
format!(
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /SourceSans3-Regular /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor {desc} 0 R /CIDToGIDMap /Identity /DW 1000 /DW2 [880 -1000] /W2 [{}] >>",
w2.join(" ")
)
}
/// A Type 3 font (object `first`, its glyph procedures the two after it) whose glyph space is
/// 1/`upm` of text space (FontMatrix [1/upm 0 0 1/upm 0 0]): `a` is half an em wide and draws a box
/// 0.4 em wide, `b` an em wide with a box of 0.9 em; both boxes are 0.7 em tall on the baseline.
fn type3(first: usize, upm: f64) -> Vec<String> {
let s = 1.0 / upm;
let (h, w) = (upm * 0.5, upm);
let a = stream("", &format!("{h} 0 d0 0 0 {} {} re f", h * 0.8, upm * 0.7));
let b = stream("", &format!("{w} 0 d0 0 0 {} {} re f", w * 0.9, upm * 0.7));
let font = format!(
"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 {w} {w}] /FontMatrix [{s} 0 0 {s} 0 0] /CharProcs << /a {} 0 R /b {} 0 R >> /Encoding << /Type /Encoding /Differences [97 /a /b] >> /FirstChar 97 /LastChar 98 /Widths [{h} {w}] /Resources << >> >>",
first + 1,
first + 2
);
vec![font, a, b]
}
/// A 300 × 200 pt page drawing `content`, plus `entries` in the page dictionary, with fonts:
/// - `/F1`: Source Sans 3, embedded, a simple TrueType font;
/// - `/F4`: Source Sans 3 as a Type 0 font, Identity-H (two-byte glyph ids, see [`gids`]);
/// - `/F5`: the same, Identity-V (vertical writing);
/// - `/F6`: the same with vertical metrics (/W2) and no widths (/W), Identity-V;
/// - `/T3`, `/T30`: Type 3 fonts of 1000 and of 100 units an em;
/// - `/HV`: Helvetica, not embedded.
fn page(content: &str, entries: &str) -> Vec<u8> {
sized_page(300.0, 200.0, content, entries)
}
/// [`page`], `w` × `h` pt.
fn sized_page(w: f64, h: f64, content: &str, entries: &str) -> Vec<u8> {
let n = first_extra(1);
let type0 = |enc: &str, cid: usize| {
format!(
"<< /Type /Font /Subtype /Type0 /BaseFont /SourceSans3-Regular /Encoding /{enc} /DescendantFonts [{cid} 0 R] /ToUnicode {} 0 R >>",
n + 4
)
};
let mut objs = vec![
font_file(), // n
descriptor(n), // n + 1
simple(n + 1), // n + 2: F1
cid_font(n + 1), // n + 3
to_unicode(), // n + 4
type0("Identity-H", n + 3), // n + 5: F4
type0("Identity-V", n + 3), // n + 6: F5
cid_font_w2(n + 1), // n + 7
type0("Identity-V", n + 7), // n + 8: F6
];
let t3 = n + objs.len();
objs.extend(type3(t3, 1000.0));
objs.extend(type3(t3 + 3, 100.0));
let refs: Vec<&str> = objs.iter().map(String::as_str).collect();
let resources = format!(
"/Font << /F1 {} 0 R /F4 {} 0 R /F5 {} 0 R /F6 {} 0 R /T3 {t3} 0 R /T30 {} 0 R /HV << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >>",
n + 2,
n + 5,
n + 6,
n + 8,
t3 + 3
);
pdf_with(&[PdfPage { resources, entries: entries.into(), ..PdfPage::new(w, h, content) }], &refs, None)
}
fn open_as(bytes: &[u8], text_as: TextAs) -> Document {
import_with_report(bytes, &ImportOptions { text_as, ..Default::default() }).unwrap().document
}
fn texts(d: &Document) -> Vec<TextObject> {
let mut v = vec![];
d.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
v.push((**t).clone());
}
});
v
}
fn plain(d: &Document) -> Vec<String> {
texts(d).iter().map(TextObject::plain_text).collect()
}
/// The largest difference between two boxes' edges.
fn off(a: Rect, b: Rect) -> f64 {
[a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()))
}
/// The bounds of each contour of `bp` (each subpath).
fn contours_of(bp: &BezPath, out: &mut Vec<Rect>) {
let mut cur = BezPath::new();
for el in bp.elements() {
if matches!(el, PathEl::MoveTo(_)) && cur.elements().len() > 1 {
out.push(std::mem::take(&mut cur).bounding_box());
}
if matches!(el, PathEl::MoveTo(_)) {
cur = BezPath::new();
}
cur.push(*el);
}
if cur.elements().len() > 1 {
out.push(cur.bounding_box());
}
}
/// The contours `d` draws: its paths' and its type's glyphs', in document space.
fn contours(d: &Document) -> Vec<Rect> {
let mut out = vec![];
d.walk(|n| match &n.kind {
NodeKind::Path { path, .. } => contours_of(&path.to_bezpath(), &mut out),
NodeKind::Text(t) => {
for g in vectorcraft_text::layout(FontDb::global(), t).glyphs {
contours_of(&(t.xf * g.outline), &mut out);
}
}
_ => {}
});
out
}
/// How far (points) the glyphs of the type `bytes` opens as are from where the file draws them:
/// the furthest any contour the file draws is from the nearest one the type draws, or the other
/// way round.
fn displacement(bytes: &[u8]) -> f64 {
let drawn = contours(&open_as(bytes, TextAs::Outlines));
let typed = contours(&open_as(bytes, TextAs::Text));
assert!(!drawn.is_empty() && !typed.is_empty(), "the file draws glyphs");
let worst = |a: &[Rect], b: &[Rect]| a.iter().map(|r| b.iter().map(|s| off(*r, *s)).fold(f64::INFINITY, f64::min)).fold(0.0, f64::max);
worst(&drawn, &typed).max(worst(&typed, &drawn))
}
/// The type `content` opens as lands where the file draws it, as `want` (its objects' text).
#[track_caller]
fn lands(content: &str, entries: &str, want: &[&str]) {
let bytes = page(content, entries);
assert_eq!(plain(&open_as(&bytes, TextAs::Text)), want, "{content}");
let d = displacement(&bytes);
assert!(d < 0.02, "{content}: glyphs {d:.3} pt from where the file draws them");
}
/// Three lines wrapped in a frame (each wrapped line ends in a space).
const PARAGRAPH: &str = "(The quick brown fox ) Tj T* (jumps over the lazy ) Tj T* (dog and runs away.) Tj";
const PARAGRAPH_TEXT: &str = "The quick brown fox jumps over the lazy dog and runs away.";
/// The text-positioning operators and the text matrix under any CTM: lines start where the file
/// starts them, the glyphs of each where it draws them.
#[test]
fn text_positioning_and_the_ctm_put_type_where_the_file_draws_it() {
let lines = ["Line one", "Line two", "Line three"];
for (content, entries, want) in [
("BT /F1 24 Tf 20 50 Td (Hello World) Tj ET", "", &["Hello World"][..]),
("BT /F1 1 Tf 24 0 0 24 20 50 Tm (Hello World) Tj ET", "", &["Hello World"]),
("q 2 0 0 2 10 10 cm BT /F1 12 Tf 10 20 Td (Hello World) Tj ET Q", "", &["Hello World"]),
// A y-down CTM with a text matrix that turns the type upright again.
("q 1 0 0 -1 0 200 cm BT /F1 12 Tf 1 0 0 -1 20 50 Tm (Hello World) Tj ET Q", "", &["Hello World"]),
("BT /F1 12 Tf 14 TL 20 150 Td (Line one) Tj T* (Line two) Tj T* (Line three) Tj ET", "", &lines),
("BT /F1 12 Tf 20 150 Td (Line one) Tj 0 -16 TD (Line two) Tj T* (Line three) Tj ET", "", &lines),
// ' moves to the next line; " also sets word and character spacing.
("BT /F1 12 Tf 14 TL 20 150 Td (Line one) Tj (Line two) ' 2 1 (Line three) \" ET", "", &lines),
("BT /F1 18 Tf 0.866 0.5 -0.5 0.866 40 40 Tm (Turned line) Tj ET", "", &["Turned line"]),
("BT /F1 1 Tf 30 0 0 15 20 50 Tm (Squashed) Tj ET", "", &["Squashed"]),
("q 1 0 0 0.5 0 0 cm BT /F1 20 Tf 20 100 Td (Squashed) Tj ET Q", "", &["Squashed"]),
("BT /F1 24 Tf 60 80 Td (Cropped) Tj ET", "/CropBox [40 30 260 180]", &["Cropped"]),
("BT /F1 24 Tf 20 50 Td (Turned page) Tj ET", "/Rotate 90", &["Turned page"]),
] {
lands(content, entries, want);
}
}
/// Horizontal scaling, character and word spacing, and `TJ`'s adjustments: each glyph keeps the
/// place the file gave it (they used to be evened out over the line, and laid out with the font's
/// kerning where the file had none, so the glyphs between the ends slid).
#[test]
fn spacing_and_scaling_keep_each_glyph_where_the_file_put_it() {
for (content, want) in [
("BT /F1 20 Tf 50 Tz 20 50 Td (Narrow type) Tj ET", "Narrow type"),
("BT /F1 20 Tf 150 Tz 20 50 Td (Wide type) Tj ET", "Wide type"),
("BT /F1 16 Tf 2 Tc 20 50 Td (Spaced out) Tj ET", "Spaced out"),
("BT /F1 16 Tf -0.8 Tc 20 50 Td (Tight type here) Tj ET", "Tight type here"),
("BT /F1 16 Tf 6 Tw 20 50 Td (Word space here) Tj ET", "Word space here"),
("BT /F1 16 Tf 1.5 Tc 80 Tz 20 50 Td (Spaced and narrow) Tj ET", "Spaced and narrow"),
("BT /F1 16 Tf 20 50 Td [(A) 120 (V) -80 (A) 300 (Tower)] TJ ET", "AVATower"),
("BT /F1 16 Tf 20 50 Td (AVATAR WAVE) Tj ET", "AVATAR WAVE"),
] {
lands(content, "", &[want]);
}
// Two-byte codes of a Type 0 font alike (word spacing applies only to single-byte code 32,
// ISO 32000-1 §9.3.3: none here).
for content in ["BT /F4 24 Tf 70 Tz 3 Ts 20 50 Td <> Tj ET", "BT /F4 24 Tf 9 Tw 2 Tc 20 50 Td <> Tj ET"] {
lands(&content.replace("<>", &gids("Hello World")), "", &["Hello World"]);
}
}
/// Word spacing stays on the spaces, as tracking of their own; evenly spaced letters keep one
/// tracking value for the line, and a file that didn't kern opens with kerning off.
#[test]
fn word_spacing_is_tracking_on_the_spaces_and_unkerned_type_opens_unkerned() {
let d = open_as(&page("BT /F1 16 Tf 6 Tw 20 50 Td (Word space here) Tj ET", ""), TextAs::Text);
let t = texts(&d);
let letters: Vec<f64> = t[0].runs.iter().filter(|r| r.text != " ").map(|r| r.style.tracking).collect();
let spaces: Vec<f64> = t[0].runs.iter().filter(|r| r.text == " ").map(|r| r.style.tracking).collect();
assert_eq!(spaces.len(), 2, "{:?}", t[0].runs);
for s in &spaces {
assert!((s - 375.0).abs() < 0.5, "6 pt at 16 pt is 375/1000 em: {spaces:?}");
}
assert!(letters.iter().all(|l| l.abs() < 0.5), "the letters as the font spaces them: {letters:?}");
// "AV", "VA", "WA" kern in Source Sans 3; this file set them on their widths alone.
let d = open_as(&page("BT /F1 16 Tf 20 50 Td (AVATAR WAVE) Tj ET", ""), TextAs::Text);
let t = texts(&d);
assert_eq!(t[0].runs.len(), 1, "{:?}", t[0].runs);
assert_eq!(t[0].runs[0].style.kerning, Some(0.0), "kerning off, as the file set it");
assert!(t[0].runs[0].style.tracking.abs() < 0.5);
}
/// Type the font's kerning spaced (as editors write it) keeps the font's kerning.
#[test]
fn kerned_type_keeps_the_fonts_kerning() {
let bytes = crate::tests_live_text::text_pdf(&[(20.0, 50.0, 24.0, "AVATAR WAVE")], false);
let d = open_as(&bytes, TextAs::Text);
let t = texts(&d);
assert_eq!(t.len(), 1);
assert!(t[0].runs.iter().all(|r| r.style.kerning.is_none()), "{:?}", t[0].runs);
assert!(displacement(&bytes) < 0.02);
}
/// Text rise (`Ts`): a superscript or subscript stays in its line as baseline shift (it used to be
/// pulled onto the baseline within 0.15 em of it, or split off as an object of its own), and a line
/// that starts raised keeps the baseline of the rest of its text.
#[test]
fn raised_and_lowered_characters_stay_in_their_line_shifted_off_its_baseline() {
for (content, want) in [
("BT /F1 12 Tf 20 50 Td 5 Ts (Raised line) Tj ET", "Raised line"),
("BT /F1 12 Tf 20 50 Td (E = mc) Tj 5 Ts (2) Tj 0 Ts ( here) Tj ET", "E = mc2 here"),
("BT /F1 12 Tf 20 50 Td (H) Tj -3 Ts (2) Tj 0 Ts (O water) Tj ET", "H2O water"),
("BT /F1 12 Tf 20 50 Td (H) Tj 1.5 Ts (2) Tj 0 Ts (O water) Tj ET", "H2O water"),
("BT /F1 12 Tf 20 50 Td (CO) Tj -1.2 Ts /F1 8 Tf (2) Tj 0 Ts /F1 12 Tf ( gas) Tj ET", "CO2 gas"),
("BT /F1 12 Tf 20 50 Td 1.5 Ts (1) Tj 0 Ts (Footnote text) Tj ET", "1Footnote text"),
] {
lands(content, "", &[want]);
}
// Under a sheared text matrix (slanted type) text rise moves a glyph along the slant, as the
// type's slant draws its baseline shift: the shifted glyphs land where the file draws them.
for (content, want) in [
("BT /F1 12 Tf 1 0 0.2 1 20 50 Tm (E = mc) Tj 5 Ts (2) Tj ET", "E = mc2"),
("BT /F1 12 Tf 1 0 0.2 1 20 50 Tm (E = mc) Tj 5 Ts (2) Tj 0 Ts ( here) Tj ET", "E = mc2 here"),
("BT /F1 12 Tf 1 0 -0.3 1 20 50 Tm (H) Tj -3 Ts (2) Tj 0 Ts (O water) Tj ET", "H2O water"),
("BT /F1 12 Tf 1 0 0.2 1 20 50 Tm 1.5 Ts (1) Tj 0 Ts (Footnote text) Tj ET", "1Footnote text"),
("BT /F1 12 Tf 1 0 0.2 1 20 50 Tm 4 Ts (Raised) Tj ET", "Raised"),
] {
lands(content, "", &[want]);
}
let t = texts(&open_as(&page("BT /F1 12 Tf 20 50 Td 1.5 Ts (1) Tj 0 Ts (Footnote text) Tj ET", ""), TextAs::Text));
let origin = t[0].xf * Point::ZERO;
assert!((origin.y - 150.0).abs() < 1e-6, "on the text's baseline, not the mark's: {origin:?}");
assert_eq!(t[0].runs[0].text, "1");
assert!((t[0].runs[0].style.baseline_shift - 1.5).abs() < 1e-6, "{:?}", t[0].runs[0].style.baseline_shift);
assert!(t[0].runs[1..].iter().all(|r| r.style.baseline_shift == 0.0));
}
/// Lines wrapped in a frame (area type, as an editor writes it: each line placed with `T*` or `Td`)
/// come back as one area type object whose glyphs are where the lines' were: aligned left, or
/// justified with word spacing (`Tw`) on each wrapped line.
#[test]
fn text_boxes_open_where_their_lines_were() {
for content in [
format!("BT /F1 12 Tf 14 TL 20 150 Td {PARAGRAPH} ET"),
format!("BT /F1 12 Tf 14 TL 4 Ts 80 Tz 20 150 Td {PARAGRAPH} ET"),
format!("q 1.5 0 0 1.5 5 5 cm BT /F1 8 Tf 10 TL 10 90 Td {PARAGRAPH} ET Q"),
format!("BT /F1 12 Tf 14 TL 0.866 0.5 -0.5 0.866 40 40 Tm {PARAGRAPH} ET"),
] {
let t = texts(&open_as(&page(&content, ""), TextAs::Text));
assert_eq!(t.len(), 1, "{content}: one text box");
assert!(matches!(t[0].kind, TextKind::Area { .. }), "{content}");
lands(&content, "", &[PARAGRAPH_TEXT]);
}
// Justified: every wrapped line spaced out to the frame's width with word spacing.
let lines = ["The quick brown fox ", "jumps over the lazy ", "dog and runs away."];
let (size, frame) = (12.0, 110.0);
let mut content = format!("BT /F1 {size} Tf 20 170 Td ");
for (i, l) in lines.iter().enumerate() {
let natural = l.trim_end().chars().map(width).sum::<f64>() * size / 1000.0;
let spaces = l.trim_end().matches(' ').count() as f64;
let tw = if i + 1 < lines.len() { (frame - natural) / spaces } else { 0.0 };
if i > 0 {
content += "0 -14.4 Td ";
}
content += &format!("{tw:.4} Tw ({l}) Tj ");
}
content += "ET";
let t = texts(&open_as(&page(&content, ""), TextAs::Text));
assert_eq!(t.len(), 1, "one text box: {:?}", t.iter().map(TextObject::plain_text).collect::<Vec<_>>());
assert_eq!(t[0].para.justify, vectorcraft_doc::Justify::JustifyLeft);
lands(&content, "", &[PARAGRAPH_TEXT]);
}
/// Vertical writing (Identity-V): the file puts each glyph's origin half its width (/W) left of
/// the column (ISO 32000-1 §9.7.4.3); glyphs narrower than an em stay centred on it.
#[test]
fn vertical_glyphs_narrower_than_an_em_stay_on_their_column() {
lands(&format!("BT /F5 24 Tf 150 180 Td {} Tj ET", gids("§±×")), "", &["§±×"]);
}
/// Type 3 glyphs (drawn by their procedures, kept as outlines) are scaled by the font's matrix:
/// the same boxes at 1000 and at 100 units an em.
#[test]
fn type3_glyphs_follow_the_font_matrix() {
for font in ["T3", "T30"] {
let d = open_as(&page(&format!("BT /{font} 20 Tf 20 50 Td (ab) Tj ET"), ""), TextAs::Text);
assert!(texts(&d).is_empty(), "Type 3 text keeps its outlines");
let mut boxes = contours(&d);
boxes.sort_by(|a, b| a.x0.total_cmp(&b.x0));
// `a` at x 20 (0.4 em = 8 pt wide), `b` after its half-em advance; 0.7 em = 14 pt tall,
// standing on the baseline at y 150 (200 - 50).
let want = [Rect::new(20.0, 136.0, 28.0, 150.0), Rect::new(30.0, 136.0, 48.0, 150.0)];
assert_eq!(boxes.len(), 2, "{font}: {boxes:?}");
for (b, w) in boxes.iter().zip(want) {
assert!(off(*b, w) < 1e-3, "{font}: {b:?} vs {w:?}");
}
}
}
/// A font that isn't embedded or installed is set in a stand-in: the line still starts where
/// the file starts it, on its baseline, and is as long as the file's.
#[test]
fn type_in_a_stand_in_font_still_starts_where_the_file_starts_it() {
let t = texts(&open_as(&page("BT /HV 12 Tf 6 Tw 20 50 Td 5 Ts (Raised word line) Tj ET", ""), TextAs::Text));
assert_eq!(t.len(), 1);
assert_eq!(t[0].plain_text(), "Raised word line");
let origin = t[0].xf * Point::ZERO;
assert!((origin.x - 20.0).abs() < 1e-6 && (origin.y - 145.0).abs() < 1e-6, "{origin:?}");
}
/// Type on a gentle arc: each glyph turned a little from the one before, the first few within a
/// hair of the first's direction. They sit on the curve; none is shifted off it as well (which
/// moved them off the curve by the shift twice over).
#[test]
fn type_on_a_gentle_arc_sits_on_its_curve_unshifted() {
let text = "Gentle arc of type here";
let (size, r) = (12.0, 450.0);
let (cx, cy) = (150.0, -300.0);
let mut phi = 100f64.to_radians();
let mut content = format!("BT /F1 {size} Tf ");
for c in text.chars() {
let a = phi - std::f64::consts::FRAC_PI_2;
let (x, y) = (cx + r * phi.cos(), cy + r * phi.sin());
let esc = if c == ' ' { " ".to_string() } else { c.to_string() };
content += &format!("{:.6} {:.6} {:.6} {:.6} {x:.4} {y:.4} Tm ({esc}) Tj ", a.cos(), a.sin(), -a.sin(), a.cos());
phi -= width(c) * size / 1000.0 / r;
}
content += "ET";
let t = texts(&open_as(&page(&content, ""), TextAs::Text));
assert_eq!(t.len(), 1, "{:?}", t.iter().map(TextObject::plain_text).collect::<Vec<_>>());
assert_eq!(t[0].plain_text(), text);
assert!(matches!(t[0].kind, TextKind::OnPath { .. }), "type on a path");
assert!(t[0].runs.iter().all(|r| r.style.baseline_shift == 0.0), "{:?}", t[0].runs);
let d = displacement(&page(&content, ""));
assert!(d < 0.05, "glyphs {d:.3} pt off the curve");
}
/// A line with as many raised characters as level ones keeps the baseline of its first glyph:
/// "m²" sits on the m's baseline, the 2 shifted up.
#[test]
fn a_tie_keeps_the_first_glyphs_baseline() {
let content = "BT /F1 12 Tf 20 50 Td (m) Tj 5 Ts (2) Tj ET";
lands(content, "", &["m2"]);
let t = texts(&open_as(&page(content, ""), TextAs::Text));
let origin = t[0].xf * Point::ZERO;
assert!((origin.y - 150.0).abs() < 1e-6, "on the m's baseline: {origin:?}");
assert_eq!(t[0].runs.len(), 2, "{:?}", t[0].runs);
assert_eq!(t[0].runs[0].style.baseline_shift, 0.0);
assert!((t[0].runs[1].style.baseline_shift - 5.0).abs() < 1e-6, "{:?}", t[0].runs[1].style);
}
/// The cells of a table's row whose baselines differ a little (a third of an em) stay objects of
/// their own: only a character that follows on from the one before is raised in its line.
#[test]
fn table_cells_a_little_apart_stay_apart() {
for content in [
"BT /F1 12 Tf 20 50 Td (Cell one) Tj 60 3.6 Td (Cell two) Tj ET",
"BT /F1 12 Tf 20 50 Td (Cell one) Tj 60 -3.6 Td (Cell two) Tj ET",
"BT /F1 12 Tf 20 50 Td (Cell one) Tj 3.6 Ts 60 0 Td (Cell two) Tj ET",
] {
lands(content, "", &["Cell one", "Cell two"]);
}
}
/// Vertical writing with the font's own vertical metrics (/W2): each glyph's position vector puts
/// the column's centre where the font says (ISO 32000-1 §9.7.4.3), not half the default width.
#[test]
fn vertical_type_takes_the_fonts_position_vectors() {
lands(&format!("BT /F6 24 Tf 150 180 Td {} Tj ET", gids("§±×")), "", &["§±×"]);
}
/// A vertical font's metrics as the file gives them: /W2 first, then half the width from /W, then
/// half /DW; glyphs mapped back to their CIDs through a /CIDToGIDMap stream; a CFF font's glyphs
/// (whose charset maps them) take /DW's.
#[test]
fn vertical_font_metrics_are_read_from_w2_w_and_dw() {
use craft_pdf::syntax::Pdf;
use craft_pdf::syntax::object::{Dict, ObjectIdentifier};
let n = first_extra(1);
// CID → glyph: 10 → 5, 20 → 6, 30 → 7, 31 → 9, 40 → 8 (others 0).
let mut map = [0u16; 41];
for (cid, gid) in [(10, 5), (20, 6), (30, 7), (31, 9), (40, 8)] {
map[cid] = gid;
}
let hex: String = map.iter().map(|g| format!("{g:04X}")).collect::<String>() + ">";
let descendant = |subtype: &str, gid_map: &str| {
format!(
"<< /Type /Font /Subtype /{subtype} /BaseFont /Test /DW 900 /W [10 20 600 30 [400 500]] /W2 [10 [-1000 222 880] 40 41 -1000 333 880] {gid_map} >>"
)
};
let type0 = |enc: &str, d: usize| format!("<< /Type /Font /Subtype /Type0 /BaseFont /Test /Encoding /{enc} /DescendantFonts [{d} 0 R] >>");
let objs = [
stream("/Filter /ASCIIHexDecode", &hex), // n
descendant("CIDFontType2", &format!("/CIDToGIDMap {n} 0 R")), // n + 1
type0("Identity-V", n + 1), // n + 2
descendant("CIDFontType2", ""), // n + 3
type0("Identity-V", n + 3), // n + 4
descendant("CIDFontType0", ""), // n + 5
type0("Identity-V", n + 5), // n + 6
type0("Identity-H", n + 3), // n + 7
];
let refs: Vec<&str> = objs.iter().map(String::as_str).collect();
let bytes = pdf_with(&[PdfPage::new(100.0, 100.0, "")], &refs, None);
let pdf = Pdf::new(Arc::new(bytes)).unwrap();
let font = |k: usize| pdf.xref().get::<Dict<'_>>(ObjectIdentifier::new((n + k) as i32, 0)).unwrap();
let mapped = crate::import_scan::vertical_metrics(&font(2), true).expect("vertical");
for (gid, want) in [(5, 222.0), (6, 300.0), (7, 200.0), (9, 250.0), (8, 333.0), (100, 450.0)] {
assert_eq!(mapped.centre(gid), want, "glyph {gid}");
}
// CIDToGIDMap /Identity: glyph ids are CIDs.
let identity = crate::import_scan::vertical_metrics(&font(4), true).expect("vertical");
for (gid, want) in [(10, 222.0), (15, 300.0), (31, 250.0), (41, 333.0), (42, 450.0)] {
assert_eq!(identity.centre(gid), want, "glyph {gid}");
}
// A font the file doesn't carry, or a CFF one: its glyphs can't be told by CID.
let stand_in = crate::import_scan::vertical_metrics(&font(4), false).expect("vertical");
let cff = crate::import_scan::vertical_metrics(&font(6), true).expect("vertical");
assert_eq!((stand_in.centre(10), cff.centre(10)), (450.0, 450.0));
assert!(crate::import_scan::vertical_metrics(&font(7), true).is_none(), "horizontal");
}
/// Area type whose lines the file spaced a hair unevenly (`TJ` adjustments of a few thousandths
/// of an em) still comes back as one text box.
#[test]
fn text_boxes_with_a_hair_of_uneven_spacing_still_open_as_one() {
let content =
"BT /F1 12 Tf 14 TL 20 150 Td [(The qu) -4 (ick brown fox )] TJ T* [(jumps o) 5 (ver the) -3 ( lazy )] TJ T* (dog and runs away.) Tj ET";
let t = texts(&open_as(&page(content, ""), TextAs::Text));
assert_eq!(t.len(), 1, "one text box: {:?}", t.iter().map(TextObject::plain_text).collect::<Vec<_>>());
assert!(matches!(t[0].kind, TextKind::Area { .. }));
assert_eq!(t[0].plain_text(), PARAGRAPH_TEXT);
assert!(displacement(&page(content, "")) < 0.1);
}
/// Word spacing the same on every line of a text box, its last line too, is the type's: it comes
/// back as one text box, aligned left, its spaces tracked apart.
#[test]
fn text_boxes_word_spaced_throughout_open_as_one() {
let content = format!("BT /F1 12 Tf 14 TL 2 Tw 20 150 Td {PARAGRAPH} ET");
let t = texts(&open_as(&page(&content, ""), TextAs::Text));
assert_eq!(t.len(), 1, "one text box: {:?}", t.iter().map(TextObject::plain_text).collect::<Vec<_>>());
assert!(matches!(t[0].kind, TextKind::Area { .. }));
assert_eq!(t[0].para.justify, vectorcraft_doc::Justify::Left);
let spaces: Vec<f64> = t[0].runs.iter().filter(|r| r.text.trim().is_empty()).map(|r| r.style.tracking).collect();
assert!(!spaces.is_empty() && spaces.iter().all(|s| (s - 2.0 / 12.0 * 1000.0).abs() < 1.0), "{:?}", t[0].runs);
lands(&content, "", &[PARAGRAPH_TEXT]);
}
/// A text-heavy page: `columns` columns of `rows` lines of 10 pt type, alternately plain lines and
/// paragraphs wrapped in a frame (left aligned, and justified with word spacing).
fn text_heavy(columns: usize, rows: usize) -> Vec<u8> {
const WORDS: [&str; 12] = ["Gather", "the", "quick", "brown", "fox", "over", "lazy", "dogs", "while", "AVATAR", "waves", "kerning"];
let mut content = String::new();
for c in 0..columns {
let x = 20.0 + c as f64 * 200.0;
content += &format!("BT /F1 10 Tf 12 TL {x} {} Td ", 40.0 + rows as f64 * 12.0);
for r in 0..rows {
let words: Vec<&str> = (0..6).map(|k| WORDS[(c * 7 + r * 5 + k) % WORDS.len()]).collect();
let wrapped = c % 2 == 1 && r % 6 != 5;
let tw = if c % 4 == 3 && wrapped { 1.5 } else { 0.0 };
content += &format!("{tw} Tw ({}{}) Tj T* ", words.join(" "), if wrapped { " " } else { "" });
}
content += "ET ";
}
sized_page(40.0 + columns as f64 * 200.0, 80.0 + rows as f64 * 12.0, &content, "")
}
/// How long a text-heavy file takes to open as type (run by hand: `-- --ignored --nocapture`).
#[test]
#[ignore]
fn perf_text_heavy_open() {
let bytes = text_heavy(20, 200);
for _ in 0..2 {
let start = std::time::Instant::now();
let d = open_as(&bytes, TextAs::Text);
let took = start.elapsed();
let n = texts(&d).len();
let start = std::time::Instant::now();
let _ = open_as(&bytes, TextAs::Outlines);
println!("4000 lines: {n} text objects in {took:?} (as outlines: {:?})", start.elapsed());
}
}
File diff suppressed because one or more lines are too long
+11 -2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long