mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 01:37:02 -06:00
G11 #722: keep imported PDF text boxes in place
Squashed from #3356 onto main.
This commit is contained in:
committed by
bflatastic
parent
3b5b185947
commit
16d39d7710
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
// 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();
|
||||
if let Some(trackings) = trackings {
|
||||
tries.push((Justify::Left, longest(&all) + size * 0.02, median(trackings)?));
|
||||
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();
|
||||
|
||||
@@ -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
|
||||
|
||||
+293
-71
@@ -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));
|
||||
}
|
||||
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;
|
||||
pen += g.advance;
|
||||
}
|
||||
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 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user