Files
vectorcraft/crates/pdf/src/tests_text_placement.rs
Taha-Mahmoodi b6b1ec7300 PDF import: keep a line's glyphs on course when the file's widths are a little off the font's advances
Cause: since #3742 place_each measures a line's spacing from the file's
own widths, so tracking is only the file's Tc/Tw/TJ. Where the widths
are systematically off the installed font's advances (up to the 1% that
still counts as the same font), those differences add up along a line
and nothing made up for them. With no kerning and every tracking value
0, place_each returned the untracked layout early, without the 0.02 em
placement check (CodeRabbit's review of #3742): a line of embedded
Source Sans 3 with /Widths 0.8% wide opened 3.7 pt off. With the early
return removed the check rejects the line and the line-wide fit takes
over: one whole-thousandth tracking value with the font's kerning,
0.30 pt off (as before #3715). Before #3742 main placed it exactly but
with a run per character (49 and 62 runs).

Change: the early return is gone; every line goes through the check.
Before it, on_course() keeps the file's spacing where the glyphs stay
within 0.01 em of the file's; otherwise each stretch of the line takes
one share after every character (the longest stretch one share keeps
within 0.01 em), so ordinary text stays one run, and a line whose
differences change course (30 m then 30 i) takes a run per stretch (2).

Verification: new test
a_long_line_of_widths_a_little_off_the_fonts_advances_lands_in_one_run
(embedded Source Sans 3, widths x1.008 and x0.992, a kerned and an
unkerned line): one run each, glyphs under 0.24 pt (0.02 em) from where
the file draws them. Without on_course it fails after #3742, with and
without the early return ("glyphs 0.300 pt"); before #3742 it fails on
the run count. It replaces an Inter test drafted for the review: Inter
isn't embedded there, so the file's outlines are the interpreter's
stand-in stretched to the widths, not Inter, and the contours differ by
0.6 pt even with exact widths. tests_text_placement and
text_becomes_point_type pass; cargo xtask ci: all 10 steps passed.

Thanks to the CodeRabbit review on #3742 for the early-return finding.
2026-10-23 10:42:45 +07:00

660 lines
34 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! #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 font the file names but doesn't carry, drawn with the installed one of that name, whose
/// advances (2048 units an em) its widths give only to the thousandth of an em: the spacing the
/// file set is measured from its widths, so letters a hair off the font's widths still take one
/// tracking value, not each character its own (a run each). Inter is built in, so this doesn't
/// depend on the fonts installed.
#[test]
fn widths_rounded_off_the_installed_fonts_advances_keep_one_tracking() {
let inter = FontDb::global().find_postscript("Inter-Regular").expect("Inter is built in");
let upm = inter.units_per_em();
assert!(upm != 1000.0, "advances finer than the file's widths: {upm}");
let widths: Vec<String> = (32u8..=126).map(|c| (inter.advance(inter.glyph_for(c as char)) * 1000.0 / upm).round().to_string()).collect();
let font = format!(
"<< /Type /Font /Subtype /TrueType /BaseFont /Inter-Regular /FirstChar 32 /LastChar 126 /Widths [{}] /Encoding /WinAnsiEncoding >>",
widths.join(" ")
);
let resources = format!("/Font << /IN {} 0 R >>", first_extra(1));
let content = "BT /IN 12 Tf 20 50 Td [(Next) -1000 (line here)] TJ ET";
let bytes = pdf_with(&[PdfPage { resources, ..PdfPage::new(300.0, 200.0, content) }], &[&font], None);
let t = texts(&open_as(&bytes, TextAs::Text));
assert_eq!(t.len(), 1);
let runs: Vec<&str> = t[0].runs.iter().map(|r| r.text.as_str()).collect();
assert_eq!(runs, ["Next", " ", "line here"], "{:?}", t[0].runs);
assert!(t[0].runs[0].style.tracking.abs() < 0.5 && t[0].runs[2].style.tracking.abs() < 0.5, "{:?}", t[0].runs);
assert!(t[0].runs[1].style.tracking > 500.0, "the em's gap on the space: {:?}", t[0].runs);
}
/// A long line whose file widths are each a little off the font's advances (0.8% wider or
/// narrower, within the 1% that still counts as the same font) opens with its glyphs where the
/// file draws them, in one run: the differences add up along the line (with only the spacing the
/// file set, none, the glyphs would end points away from the file's), so the line takes their
/// share after every character. A line the font kerns (laid out without its kerning, as the file
/// sets it) and one it doesn't (laid out as the type is). Source Sans 3 is embedded: what the file
/// draws and what the type draws are the same outlines, so their contours tell where each glyph is.
#[test]
fn a_long_line_of_widths_a_little_off_the_fonts_advances_lands_in_one_run() {
let n = first_extra(1);
let resources = format!("/Font << /F1 {} 0 R >>", n + 2);
for scale in [1.008, 0.992] {
let widths: Vec<String> = (32u8..=126).map(|c| (width(c as char) * scale).round().to_string()).collect();
let font = format!(
"<< /Type /Font /Subtype /TrueType /BaseFont /SourceSans3-Regular /FirstChar 32 /LastChar 126 /Widths [{}] /FontDescriptor {} 0 R /Encoding /WinAnsiEncoding >>",
widths.join(" "),
n + 1
);
for line in [
"Gather the quick brown fox over the lazy dogs while the waves roll in",
"minimum minimum minimum minimum minimum minimum minimum minimum",
] {
let content = format!("BT /F1 12 Tf 10 50 Td ({line}) Tj ET");
let page = PdfPage { resources: resources.clone(), ..PdfPage::new(600.0, 200.0, &content) };
let bytes = pdf_with(&[page], &[&font_file(), &descriptor(n), &font], None);
let t = texts(&open_as(&bytes, TextAs::Text));
assert_eq!(t.iter().map(TextObject::plain_text).collect::<Vec<_>>(), [line]);
assert_eq!(t[0].runs.len(), 1, "{scale} {line:?}: {:?}", t[0].runs);
let d = displacement(&bytes);
assert!(d < 12.0 * 0.02, "{scale} {line:?}: glyphs {d:.3} pt from where the file draws them");
}
}
}
/// 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());
}
}