Files
vectorcraft/crates/pdf/src/tests_live_text.rs
T
Juan Manuel Verges SolanasandClaude Opus 5.5 562e09707a Fix #813: type can name an installed version of its font
A font library can hold two versions of one family and style (a new
Montserrat-Bold.ttf beside an old montserrat.ttf). The font database kept
only the first one loaded, so type set in the other couldn't be matched
when a PDF or .ai was opened: it came in as outlines. Now every version
loads (the first stays the one family and style resolve to), PDF import
tries the other versions when the glyphs differ, and type set in a version
the family and style alone wouldn't pick names it in CharStyle::font_version
(left out when None, so documents without it are unchanged). Layout,
shaping, the substituted-glyph highlight, Find Font, Package and Document
Info follow it; changing the font clears it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:26:51 +00:00

365 lines
16 KiB
Rust
Raw 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.
//! PDF text in installed fonts imports as live type (one point type object per line).
use krilla::geom::{Point as KPoint, Transform};
use krilla::page::PageSettings;
use krilla::text::{Font, TextDirection};
use kurbo::Shape;
use vectorcraft_doc::{Document, NodeKind, TextObject};
use crate::*;
const SOURCE_SANS: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
const SOURCE_SERIF: &[u8] = include_bytes!("../../../assets/fonts/SourceSerif4-Regular.ttf");
/// A 300 × 200 pt page with `lines` of (x, y from the top, size, text) in Source Sans 3, and the
/// same rotated 90° when `rotated`.
pub(crate) fn text_pdf(lines: &[(f32, f32, f32, &str)], rotated: bool) -> Vec<u8> {
let mut pdf = krilla::Document::new();
let mut page = pdf.start_page_with(PageSettings::from_wh(300.0, 200.0).unwrap());
let mut s = page.surface();
let font = Font::new(SOURCE_SANS.into(), 0).unwrap();
if rotated {
s.push_transform(&Transform::from_row(0.0, 1.0, -1.0, 0.0, 150.0, 20.0));
}
for &(x, y, size, text) in lines {
s.draw_text(KPoint::from_xy(x, y), font.clone(), size, text, false, TextDirection::Auto);
}
if rotated {
s.pop();
}
s.finish();
page.finish();
pdf.finish().unwrap()
}
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 ink_width(t: &TextObject) -> f64 {
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).glyphs.iter().map(|g| g.advance).sum()
}
#[test]
fn lines_of_text_in_installed_fonts_come_in_as_point_type() {
let bytes = text_pdf(&[(20.0, 50.0, 24.0, "Gagaku Concert"), (20.0, 90.0, 12.0, "Hall A")], false);
let r = import_with_report(&bytes, &ImportOptions::default()).unwrap();
let t = texts(&r.document);
assert_eq!(t.len(), 2, "{t:?}");
let first = t.iter().find(|t| t.plain_text() == "Gagaku Concert").unwrap();
let st = first.first_style();
assert_eq!((st.font_family.as_str(), st.font_style.as_str()), ("Source Sans 3", "Regular"));
assert!((st.size - 24.0).abs() < 0.01);
let origin = first.xf * kurbo::Point::ZERO;
assert!((origin.x - 20.0).abs() < 0.01 && (origin.y - 50.0).abs() < 0.01, "on the PDF's baseline: {origin:?}");
assert!(st.tracking.abs() <= 1.0, "set as VectorCraft sets it: {}", st.tracking);
assert!(t.iter().any(|t| t.plain_text() == "Hall A" && (t.first_style().size - 12.0).abs() < 0.01));
assert!(!r.warnings.iter().any(|w| w.contains("outlines")), "{:?}", r.warnings);
}
#[test]
fn rotated_lines_keep_their_angle_and_outline_text_option_outlines_everything() {
let bytes = text_pdf(&[(0.0, 0.0, 18.0, "Sideways")], true);
let d = import_with_report(&bytes, &ImportOptions::default()).unwrap().document;
let t = texts(&d);
assert_eq!(t.len(), 1);
let dir = t[0].xf * kurbo::Point::new(1.0, 0.0) - t[0].xf * kurbo::Point::ZERO;
assert!(dir.x.abs() < 1e-6 && (dir.y - 1.0).abs() < 1e-6, "the baseline runs down the page: {dir:?}");
let outlined = import_with_report(&bytes, &ImportOptions { text_as: TextAs::Outlines, ..Default::default() }).unwrap();
assert!(texts(&outlined.document).is_empty());
assert!(outlined.warnings.iter().any(|w| w.contains("outlines")));
}
#[test]
fn letter_spacing_in_the_pdf_becomes_tracking() {
// Source Sans 3 at 20 pt, every letter 2 pt further apart than its advance.
let mut x = 20.0;
let mut lines = vec![];
let word = "Wide";
let font = vectorcraft_text::FontDb::global().find_postscript("SourceSans3-Regular").unwrap();
let mut pieces = vec![];
for c in word.chars() {
pieces.push((x, c.to_string()));
x += font.advance(font.glyph_for(c)) as f32 / font.units_per_em() as f32 * 20.0 + 2.0;
}
for (x, c) in &pieces {
lines.push((*x, 60.0, 20.0, c.as_str()));
}
let d = import_with_report(&text_pdf(&lines, false), &ImportOptions::default()).unwrap().document;
let t = texts(&d);
assert_eq!(t.len(), 1, "{t:?}");
assert_eq!(t[0].plain_text(), "Wide");
assert!((t[0].first_style().tracking - 100.0).abs() <= 1.0, "2 pt at 20 pt = 100/1000 em: {}", t[0].first_style().tracking);
let expected = (x - 2.0 - 20.0) as f64 + 2.0;
assert!((ink_width(&t[0]) - expected).abs() < 0.3, "{} vs {expected}", ink_width(&t[0]));
}
/// Embedded font data is untrusted: anything that isn't a CID-keyed CFF is no table, never a panic.
#[test]
fn cid_tables_ignore_fonts_that_are_not_cid_keyed_cff() {
assert!(crate::import::CidText::of(&[]).is_none());
assert!(crate::import::CidText::of(b"not a font at all").is_none());
let mut x: u32 = 0x9E37_79B9;
for len in [4usize, 64, 1024, 65_536] {
let junk: Vec<u8> = (0..len)
.map(|_| {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
x as u8
})
.collect();
assert!(crate::import::CidText::of(&junk).is_none(), "{len} bytes");
// A CFF header in front of the junk.
let mut cff = vec![1, 0, 4, 4];
cff.extend_from_slice(&junk);
let _ = crate::import::CidText::of(&cff);
}
let sans = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/SourceSans3-Regular.ttf")).unwrap();
assert!(crate::import::CidText::of(&sans).is_none(), "a TrueType font has no CID charset");
}
#[test]
fn kangxi_radicals_from_cid_tables_become_ideographs() {
assert_eq!(crate::import::unify_radical('\u{2FD3}'), '龍');
assert_eq!(crate::import::unify_radical('\u{2F00}'), '一');
assert_eq!(crate::import::unify_radical('笙'), '笙');
}
#[test]
fn a_glyph_whose_unicode_names_another_letter_stays_outlined() {
// The glyph of `l` labelled `W` in the PDF's ToUnicode map: opening it as live `W` would change
// what the page shows.
let font = vectorcraft_text::FontDb::global().find_postscript("SourceSans3-Regular").unwrap();
let l = font.glyph_for('l');
let mut pdf = krilla::Document::new();
let mut page = pdf.start_page_with(PageSettings::from_wh(300.0, 200.0).unwrap());
let mut s = page.surface();
let kfont = Font::new(SOURCE_SANS.into(), 0).unwrap();
let glyph = krilla::text::KrillaGlyph {
glyph_id: krilla::text::GlyphId::new(l),
text_range: 0..1,
x_advance: 0.25,
x_offset: 0.0,
y_offset: 0.0,
y_advance: 0.0,
location: None,
};
s.draw_glyphs(KPoint::from_xy(20.0, 50.0), &[glyph], kfont, "W", 24.0, false);
s.finish();
page.finish();
let r = import_with_report(&pdf.finish().unwrap(), &ImportOptions::default()).unwrap();
assert!(texts(&r.document).is_empty(), "{:?}", texts(&r.document));
assert!(r.warnings.iter().any(|w| w.contains("differ from the installed font")), "{:?}", r.warnings);
}
/// `font` with each of `names` (`from`, `to`, the same length) replaced, in ASCII and UTF-16.
fn renamed(font: &[u8], names: &[(&str, &str)]) -> Vec<u8> {
let mut bytes = font.to_vec();
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
for &(from, to) in names {
assert_eq!(from.len(), to.len());
for (from, to) in [(from.as_bytes().to_vec(), to.as_bytes().to_vec()), (utf16(from), utf16(to))] {
let mut i = 0;
while let Some(at) = bytes[i..].windows(from.len()).position(|w| w == from.as_slice()) {
bytes[i + at..i + at + from.len()].copy_from_slice(&to);
i += at + from.len();
}
}
}
bytes
}
/// Source Sans 3 renamed `SourceSans9-Regular` (a font no machine has installed).
fn uninstalled_font() -> Vec<u8> {
renamed(SOURCE_SANS, &[("SourceSans3-Regular", "SourceSans9-Regular")])
}
/// Source Serif 4 renamed `SourceSerif9-Regular`, family Source Serif 9: a serif font that isn't
/// installed (the fallback font, Source Sans 3, is a sans).
fn uninstalled_serif() -> Vec<u8> {
renamed(SOURCE_SERIF, &[("SourceSerif4", "SourceSerif9"), ("Source Serif 4", "Source Serif 9")])
}
/// A page with `text` in `font` (24 pt, at 20, 50).
fn one_line_pdf(font: Vec<u8>, text: &str) -> Vec<u8> {
let mut pdf = krilla::Document::new();
let mut page = pdf.start_page_with(PageSettings::from_wh(300.0, 200.0).unwrap());
let mut s = page.surface();
s.draw_text(KPoint::from_xy(20.0, 50.0), Font::new(font.into(), 0).unwrap(), 24.0, text, false, TextDirection::Auto);
s.finish();
page.finish();
pdf.finish().unwrap()
}
/// The outlines text imported as (`textAs: outlines`), as one path.
fn outlines(d: &Document) -> kurbo::BezPath {
let mut all = kurbo::BezPath::new();
d.walk(|n| {
if let NodeKind::Path { path, .. } = &n.kind {
all.extend(path.to_bezpath().iter());
}
});
all
}
#[test]
fn outlined_text_in_a_missing_font_keeps_the_embedded_glyphs() {
let outlined = |bytes: &[u8]| {
let r = import_with_report(bytes, &ImportOptions { text_as: TextAs::Outlines, ..Default::default() }).unwrap();
assert!(texts(&r.document).is_empty());
outlines(&r.document)
};
let missing = outlined(&one_line_pdf(uninstalled_serif(), "Serif Hamburg"));
let installed = outlined(&one_line_pdf(SOURCE_SERIF.to_vec(), "Serif Hamburg"));
let fallback = outlined(&one_line_pdf(SOURCE_SANS.to_vec(), "Serif Hamburg"));
let (m, i, f) = (missing.bounding_box(), installed.bounding_box(), fallback.bounding_box());
assert!(m.width() > 50.0, "{m:?}");
let near = |a: kurbo::Rect, b: kurbo::Rect| (a.x0 - b.x0).abs() + (a.x1 - b.x1).abs() + (a.y0 - b.y0).abs() + (a.y1 - b.y1).abs() < 0.01;
assert!(near(m, i), "the file's own serif glyphs: {m:?} vs {i:?}");
assert!(!near(m, f), "not the fallback font's: {m:?} vs {f:?}");
}
#[test]
fn text_in_a_missing_font_stays_live_type() {
for stroke_only in [false, true] {
let mut pdf = krilla::Document::new();
let mut page = pdf.start_page_with(PageSettings::from_wh(300.0, 200.0).unwrap());
let mut s = page.surface();
let font = Font::new(uninstalled_font().into(), 0).unwrap();
if stroke_only {
s.set_fill(None);
s.set_stroke(Some(krilla::paint::Stroke::default()));
}
s.draw_text(KPoint::from_xy(20.0, 50.0), font, 24.0, "Gagaku", false, TextDirection::Auto);
s.finish();
page.finish();
let r = import_with_report(&pdf.finish().unwrap(), &ImportOptions::default()).unwrap();
assert_eq!(r.document.layers.len(), 1, "no extra layers");
let t = texts(&r.document);
assert_eq!(t.len(), 1, "stroke only {stroke_only}");
assert_eq!(t[0].plain_text(), "Gagaku");
// Named by the family its PostScript name reads as, so it picks the font up once installed.
let family = t[0].first_style().font_family.clone();
assert_eq!(family, "Source Sans 9");
assert!(r.warnings.iter().any(|w| w.contains(family.as_str()) && w.contains("fallback font")), "{:?}", r.warnings);
}
}
#[test]
fn a_single_upright_glyph_comes_in_as_vertical_type_and_takes_no_horizontal_glyphs() {
use crate::import_text::{Look, Placement, TextLine, Upright};
use vectorcraft_geom::{Point, Vec2};
let look = Look {
font: 1,
family: "Source Sans 3".into(),
style: "Regular".into(),
version: None,
size: 20.0,
h_scale: 100.0,
fill: Some(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))),
stroke: None,
};
let at = |x: f64, y: f64| Placement { origin: Point::new(x, y), dir: Vec2::new(1.0, 0.0), size: 20.0, h_scale: 100.0, slant: 0.0 };
let mut line = TextLine::new(at(100.0, 100.0), 1.0);
assert!(line.push_upright(&look, at(100.0, 100.0), 1.0, Upright { top: Point::new(110.0, 82.0) }, "§"));
assert!(!line.push(&look, at(130.0, 100.0), 1.0, 10.0, "a"), "a horizontal glyph starts another line");
let (t, ..) = line.finish().unwrap();
assert!(t.vertical, "one upright glyph is vertical type");
assert_eq!(t.plain_text(), "§");
}
#[test]
fn letter_spaced_vertical_type_keeps_its_characters_together() {
use crate::import_text::{Look, Placement, TextLine, Upright};
use vectorcraft_geom::{Point, Vec2};
let look = Look {
font: 1,
family: "Source Sans 3".into(),
style: "Regular".into(),
version: None,
size: 20.0,
h_scale: 100.0,
fill: Some(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))),
stroke: None,
};
// Glyphs set down a column 1.5 em apart (tracking 500): no spaces come in between them.
let at = |y: f64| Placement { origin: Point::new(100.0, y), dir: Vec2::new(1.0, 0.0), size: 20.0, h_scale: 100.0, slant: 0.0 };
let mut line = TextLine::new(at(100.0), 1.0);
for (i, c) in ["§", "§", "§"].iter().enumerate() {
let y = 100.0 + 30.0 * i as f64;
assert!(line.push_upright(&look, at(y), 1.0, Upright { top: Point::new(110.0, y - 18.0) }, c));
}
let (t, ..) = line.finish().unwrap();
assert!(t.vertical);
assert_eq!(t.plain_text(), "§§§", "letter spacing isn't a space");
}
#[test]
fn a_tagged_span_inside_a_line_leaves_the_line_whole() {
use krilla::tagging::{ContentTag, SpanTag};
let mut pdf = krilla::Document::new();
let mut page = pdf.start_page_with(PageSettings::from_wh(300.0, 200.0).unwrap());
let mut s = page.surface();
let font = Font::new(SOURCE_SANS.into(), 0).unwrap();
// "Ga" "ga" "ku" with the middle part in its own marked content (a span, as apps write for
// actual text), all on one baseline.
s.draw_text(KPoint::from_xy(20.0, 50.0), font.clone(), 24.0, "Ga", false, TextDirection::Auto);
let x = 20.0 + ink_width_of(&font, 24.0, "Ga");
s.start_tagged(ContentTag::Span(SpanTag::empty()));
s.draw_text(KPoint::from_xy(x, 50.0), font.clone(), 24.0, "ga", false, TextDirection::Auto);
s.end_tagged();
let x = x + ink_width_of(&font, 24.0, "ga");
s.draw_text(KPoint::from_xy(x, 50.0), font, 24.0, "ku", false, TextDirection::Auto);
s.finish();
page.finish();
let d = import(&pdf.finish().unwrap()).unwrap();
let t: Vec<String> = texts(&d).iter().map(TextObject::plain_text).collect();
assert_eq!(t, ["Gagaku"]);
}
/// Advance of `text` in `font` at `size` (from the bundled Source Sans 3, as laid out).
fn ink_width_of(_font: &Font, size: f32, text: &str) -> f32 {
let style = vectorcraft_doc::CharStyle { font_family: "Source Sans 3".into(), size: f64::from(size), ..Default::default() };
ink_width(&TextObject::point(vectorcraft_geom::Point::ZERO, text, style)) as f32
}
/// Hebrew drawn by a PDF (in visual order, as VectorCraft's own export draws it) comes back as type
/// in logical order that shows as drawn. Needs an installed font with Hebrew (skipped without one).
#[test]
fn hebrew_comes_back_in_logical_order() {
let db = vectorcraft_text::FontDb::global();
let Some(face) =
["Arial", "Noto Sans Hebrew", "DejaVu Sans", "Liberation Sans"].into_iter().filter_map(|f| db.face(f, "Regular")).find(|f| f.covers('ש'))
else {
return;
};
for text in ["שלום עולם", "שלום 123", "Hello שלום"] {
let style = vectorcraft_doc::CharStyle { font_family: face.family.clone(), size: 24.0, ..Default::default() };
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let n = vectorcraft_doc::Node::new(
d.alloc_id(),
NodeKind::Text(Box::new(TextObject::point(vectorcraft_geom::Point::new(20.0, 60.0), text, style))),
);
d.insert(Some(l), 0, n).unwrap();
let settings: PdfSettings = serde_json::from_value(serde_json::json!({"advanced": {"outlineText": false}})).unwrap();
let bytes = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap().bytes;
let r = import_with_report(&bytes, &ImportOptions::default()).unwrap();
let t = texts(&r.document);
assert_eq!(t.iter().map(TextObject::plain_text).collect::<Vec<_>>(), [text], "{:?}", r.warnings);
let shown = |t: &TextObject| -> String {
let plain = t.plain_text();
vectorcraft_text::layout(db, t).glyphs.iter().filter_map(|g| plain.get(g.byte..)?.chars().next()).collect()
};
let original = TextObject::point(vectorcraft_geom::Point::ZERO, text, vectorcraft_doc::CharStyle::default());
assert_eq!(shown(&t[0]), shown(&original), "{text}");
}
}