mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
Review follow-up (CodeRabbit): a gradient drawn as pixels was sized from its area in the space it is drawn in, so type scaled 4 times got a quarter of the raster effects resolution; the image is now sized from the area on the page, within the same pixel caps. And type whose fill is drawn as art (a dithered or freeform gradient's image, a pattern) went out as outlines only; its characters are now also written as invisible text (rendering mode 3), as unpainted type already is, so they can be selected and searched.
598 lines
30 KiB
Rust
598 lines
30 KiB
Rust
//! Real text: type exported as selectable, searchable text in embedded subset fonts with a
|
|
//! ToUnicode map, where the outlines would be; fonts whose licence forbids it stay outlines.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use serde_json::json;
|
|
use vectorcraft_color::{Color, Gradient, GradientPaint, Paint};
|
|
use vectorcraft_doc::{CharStyle, Document, Effect, Node, NodeKind, TextKind, TextObject, TextRun};
|
|
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect};
|
|
use vectorcraft_text::{FALLBACK_FAMILY, FontDb};
|
|
|
|
use crate::*;
|
|
|
|
fn style(size: f64) -> CharStyle {
|
|
CharStyle { size, fill: Paint::solid(Color::BLACK), ..Default::default() }
|
|
}
|
|
|
|
/// A document holding `texts`.
|
|
fn doc(texts: Vec<TextObject>) -> Document {
|
|
let mut d = Document::new(400.0, 300.0);
|
|
let l = d.layers[0].id;
|
|
for (i, t) in texts.into_iter().enumerate() {
|
|
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
|
|
d.insert(Some(l), i, n).unwrap();
|
|
}
|
|
d
|
|
}
|
|
|
|
/// The export of `d` with real text (or outlines), content streams left readable.
|
|
fn pdf(d: &Document, outline_text: bool) -> ExportReport {
|
|
let settings: PdfSettings =
|
|
serde_json::from_value(json!({"compression": {"compressText": false}, "advanced": {"outlineText": outline_text}})).unwrap();
|
|
export_with_report(d, &PdfOptions { settings, ..Default::default() }).unwrap()
|
|
}
|
|
|
|
fn import_as(bytes: &[u8], text_as: TextAs) -> Document {
|
|
import_with_report(bytes, &ImportOptions { text_as, ..Default::default() }).unwrap().document
|
|
}
|
|
|
|
/// The text of the type objects of `d`.
|
|
fn texts(d: &Document) -> Vec<String> {
|
|
let mut out = vec![];
|
|
d.walk(|n| {
|
|
if let NodeKind::Text(t) = &n.kind {
|
|
out.push(t.plain_text());
|
|
}
|
|
});
|
|
out
|
|
}
|
|
|
|
/// The bounds of the paths of `d` (the outlines type imports as), all together.
|
|
fn ink(d: &Document) -> Rect {
|
|
let mut b: Option<Rect> = None;
|
|
d.walk(|n| {
|
|
if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })
|
|
&& let Some(r) = n.geometric_bounds()
|
|
{
|
|
b = Some(b.map_or(r, |b| b.union(r)));
|
|
}
|
|
});
|
|
b.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn type_is_written_as_text_in_embedded_subset_fonts() {
|
|
let d = doc(vec![TextObject::point(Point::new(20.0, 60.0), "Hello World", style(24.0))]);
|
|
let r = pdf(&d, false);
|
|
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
|
let text = String::from_utf8_lossy(&r.bytes);
|
|
assert!(text.contains("/FontFile2") || text.contains("/FontFile3"), "an embedded font program");
|
|
assert!(text.contains("/ToUnicode") && text.contains("/Type0"));
|
|
// A subset: the font's name carries the subset tag.
|
|
assert!(text.contains(&format!("+{}", FALLBACK_FAMILY.replace(' ', ""))), "a subset");
|
|
assert!(text.contains(" Tf") && text.contains("TJ"));
|
|
// Selectable and searchable: reopened, it is type again, spaces included.
|
|
assert_eq!(texts(&import_as(&r.bytes, TextAs::Text)), ["Hello World"]);
|
|
// Outlined text has no font.
|
|
assert!(!String::from_utf8_lossy(&pdf(&d, true).bytes).contains("/Font"));
|
|
}
|
|
|
|
/// Type that a Transform effect only moves and copies, on the type or on its group, is written as
|
|
/// text, the copies too.
|
|
#[test]
|
|
fn type_copied_by_a_transform_effect_is_written_as_text() {
|
|
let copies = Effect { id: "distort.transform".into(), params: json!({"moveH": 200, "copies": 1}), visible: true };
|
|
for on_group in [false, true] {
|
|
let mut d = Document::new(400.0, 300.0);
|
|
let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 60.0), "Hello", style(24.0)))));
|
|
if on_group {
|
|
n = Node::group(d.alloc_id(), vec![Arc::new(n)]);
|
|
}
|
|
n.appearance.effects.push(copies.clone());
|
|
let l = d.layers[0].id;
|
|
d.insert(Some(l), 0, n).unwrap();
|
|
let back = import_as(&pdf(&d, false).bytes, TextAs::Text);
|
|
assert_eq!(texts(&back), ["Hello", "Hello"], "on the group: {on_group}");
|
|
}
|
|
}
|
|
|
|
/// A curve type runs along.
|
|
fn arc() -> PathData {
|
|
let mut p = BezPath::new();
|
|
p.move_to((20.0, 250.0));
|
|
p.curve_to((120.0, 150.0), (260.0, 150.0), (380.0, 250.0));
|
|
PathData::from_bezpath(&p)
|
|
}
|
|
|
|
#[test]
|
|
fn real_text_lands_within_half_a_point_of_the_outlines() {
|
|
let plain = TextObject::point(Point::new(20.0, 60.0), "Hg fly", style(36.0));
|
|
let mut turned = TextObject::point(Point::new(60.0, 160.0), "Wave AV", CharStyle { h_scale: 80.0, tracking: 120.0, ..style(30.0) });
|
|
turned.xf *= Affine::rotate(-0.5) * Affine::skew(0.2, 0.0);
|
|
let mut mixed = TextObject::point(Point::new(200.0, 60.0), "", style(20.0));
|
|
mixed.runs = vec![
|
|
TextRun { text: "x".into(), style: style(20.0), inline: None },
|
|
TextRun { text: "2".into(), style: CharStyle { baseline_shift: 8.0, v_scale: 140.0, ..style(12.0) }, inline: None },
|
|
TextRun { text: " ok".into(), style: CharStyle { rotation: 20.0, ..style(20.0) }, inline: None },
|
|
];
|
|
let mut on_path = TextObject::point(Point::ZERO, "along the arc", style(18.0));
|
|
on_path.kind = TextKind::OnPath { path: arc(), start: 0.0, end: None };
|
|
on_path.xf = Affine::IDENTITY;
|
|
for t in [plain, turned, mixed, on_path] {
|
|
let what = t.plain_text();
|
|
let d = doc(vec![t]);
|
|
let real = import_as(&pdf(&d, false).bytes, TextAs::Outlines);
|
|
let outlined = import_as(&pdf(&d, true).bytes, TextAs::Outlines);
|
|
let (a, b) = (ink(&real), ink(&outlined));
|
|
let off = [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()));
|
|
assert!(off < 0.5, "{what}: {a:?} vs {b:?}");
|
|
assert_eq!(texts(&import_as(&pdf(&d, false).bytes, TextAs::Text)).concat().replace(' ', ""), what.replace(' ', ""), "{what}");
|
|
}
|
|
}
|
|
|
|
/// A run shifted two billion points off its baseline is left out, as real text or outlines, and
|
|
/// the rest of the type is written. On a page 50 points high, the page's y flip puts the shifted
|
|
/// run's baseline at -2,147,483,648, which the PDF writer panics on in builds with overflow checks.
|
|
#[test]
|
|
fn a_run_shifted_two_billion_points_is_left_out() {
|
|
let mut t = TextObject::point(Point::new(5.0, 40.0), "", style(12.0));
|
|
t.runs = vec![
|
|
TextRun { text: "in".into(), style: style(12.0), inline: None },
|
|
TextRun { text: "out".into(), style: CharStyle { baseline_shift: -2_147_483_649.0, ..style(12.0) }, inline: None },
|
|
];
|
|
let mut d = doc(vec![t]);
|
|
d.artboards[0].rect = Rect::new(0.0, 0.0, 50.0, 50.0);
|
|
assert_eq!(texts(&import_as(&pdf(&d, false).bytes, TextAs::Text)), ["in"]);
|
|
let b = ink(&import_as(&pdf(&d, true).bytes, TextAs::Outlines));
|
|
assert!(b.y0 > 0.0 && b.y1 < 50.0, "{b:?}");
|
|
}
|
|
|
|
/// An overprinting run is written, or left out, as the same run is without overprint: the form
|
|
/// marked to overprint the run holds the type's transform. For type moved 2,147,483,689 points down
|
|
/// with its run shifted back onto a page 50 points high, a form placed through that transform would
|
|
/// sit at y = -2,147,483,648, which the PDF writer panics on in builds with overflow checks. For
|
|
/// type scaled 3 times, a form without that transform would hold a run shifted 500,000,000 points
|
|
/// up, and the export would write the run 1,500,000,000 points up.
|
|
#[test]
|
|
fn overprinting_runs_are_written_or_left_out_as_other_runs_are() {
|
|
let moved = |overprint_fill| {
|
|
let back = CharStyle { baseline_shift: 2_147_483_649.0, overprint_fill, ..style(12.0) };
|
|
TextObject::point(Point::new(5.0, 2_147_483_689.0), "moved", back)
|
|
};
|
|
let scaled = |overprint_fill| {
|
|
let mut t = TextObject::point(Point::new(5.0, 40.0), "", style(12.0));
|
|
t.xf *= Affine::scale(3.0);
|
|
let up = CharStyle { baseline_shift: 500_000_000.0, overprint_fill, ..style(12.0) };
|
|
t.runs = vec![TextRun { text: "in".into(), style: style(12.0), inline: None }, TextRun { text: "out".into(), style: up, inline: None }];
|
|
t
|
|
};
|
|
let written = |mut t: TextObject| {
|
|
// The export culls type by the laid-out bounds that the engine keeps in `cached_bounds`.
|
|
t.cached_bounds = Some(vectorcraft_text::layout(FontDb::global(), &t).bounds);
|
|
let mut d = doc(vec![t]);
|
|
d.artboards[0].rect = Rect::new(0.0, 0.0, 50.0, 50.0);
|
|
pdf(&d, false).bytes
|
|
};
|
|
let text_of = |bytes: &[u8]| texts(&import_as(bytes, TextAs::Text));
|
|
let (on, off) = (written(moved(true)), written(moved(false)));
|
|
assert!(String::from_utf8_lossy(&on).contains("/VCop gs"), "an overprinting form");
|
|
assert_eq!(text_of(&on), ["moved"]);
|
|
assert_eq!(text_of(&off), ["moved"]);
|
|
assert_eq!(text_of(&written(scaled(true))), ["in"]);
|
|
assert_eq!(text_of(&written(scaled(false))), ["in"]);
|
|
}
|
|
|
|
/// A named instance of a variable font is real text in that instance: its embedded subset draws
|
|
/// the instance's glyphs where the outlines would be, not the default instance's (#296).
|
|
#[test]
|
|
fn variable_font_instances_are_real_text_in_their_own_glyphs() {
|
|
use vectorcraft_text::test_fonts::{VARIABLE_CHARS, VARIABLE_FAMILY, variable_font};
|
|
FontDb::global().add_font(variable_font().unwrap());
|
|
let mut widths = vec![];
|
|
for s in ["Regular", "Bold"] {
|
|
let st = CharStyle { font_family: VARIABLE_FAMILY.into(), font_style: s.into(), ..style(48.0) };
|
|
let d = doc(vec![TextObject::point(Point::new(20.0, 80.0), VARIABLE_CHARS, st)]);
|
|
let r = pdf(&d, false);
|
|
assert!(r.warnings.is_empty(), "{s}: {:?}", r.warnings);
|
|
assert_eq!(texts(&import_as(&r.bytes, TextAs::Text)), [VARIABLE_CHARS], "{s}: real text");
|
|
let (real, outlined) = (ink(&import_as(&r.bytes, TextAs::Outlines)), ink(&import_as(&pdf(&d, true).bytes, TextAs::Outlines)));
|
|
let off =
|
|
[real.x0 - outlined.x0, real.y0 - outlined.y0, real.x1 - outlined.x1, real.y1 - outlined.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()));
|
|
assert!(off < 0.5, "{s}: {real:?} vs {outlined:?}");
|
|
widths.push(real.width());
|
|
}
|
|
assert!(widths[1] > widths[0] * 1.2, "Bold is wider: {widths:?}");
|
|
}
|
|
|
|
/// Type with Variable Font settings is real text in a font embedded at those settings: it draws
|
|
/// where its outlines would, between the named instances on either side.
|
|
#[test]
|
|
fn variable_font_settings_are_real_text_in_their_own_glyphs() {
|
|
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, VARIABLE_CHARS, two_axis_font};
|
|
FontDb::global().add_font(two_axis_font().unwrap());
|
|
let mut widths = vec![];
|
|
for (s, wght) in [("Regular", None), ("Regular", Some(650.0)), ("Bold", None)] {
|
|
let font_axes = wght.map(|w| [("wght".to_string(), w)].into_iter().collect()).unwrap_or_default();
|
|
let st = CharStyle { font_family: TWO_AXIS_FAMILY.into(), font_style: s.into(), font_axes, ..style(48.0) };
|
|
let d = doc(vec![TextObject::point(Point::new(20.0, 80.0), VARIABLE_CHARS, st)]);
|
|
let r = pdf(&d, false);
|
|
assert!(r.warnings.is_empty(), "{s} {wght:?}: {:?}", r.warnings);
|
|
assert_eq!(texts(&import_as(&r.bytes, TextAs::Text)), [VARIABLE_CHARS], "{s} {wght:?}: real text");
|
|
let (real, outlined) = (ink(&import_as(&r.bytes, TextAs::Outlines)), ink(&import_as(&pdf(&d, true).bytes, TextAs::Outlines)));
|
|
let off =
|
|
[real.x0 - outlined.x0, real.y0 - outlined.y0, real.x1 - outlined.x1, real.y1 - outlined.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()));
|
|
assert!(off < 0.5, "{s} {wght:?}: {real:?} vs {outlined:?}");
|
|
widths.push(real.width());
|
|
}
|
|
assert!(widths[0] * 1.1 < widths[1] && widths[1] < widths[2], "{widths:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn gradients_on_real_text_stay_where_they_were() {
|
|
let fill = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
|
|
let mut t = TextObject::point(Point::new(30.0, 80.0), "Gradient", CharStyle { fill, ..style(40.0) });
|
|
t.xf *= Affine::rotate(0.3);
|
|
let d = doc(vec![t]);
|
|
let geom = |outline: bool| {
|
|
let back = import_as(&pdf(&d, outline).bytes, TextAs::Outlines);
|
|
let mut g = None;
|
|
back.walk(|n| {
|
|
if let Paint::Gradient(p) = n.appearance.fill_paint() {
|
|
g = g.or(p.geom);
|
|
}
|
|
});
|
|
g.unwrap()
|
|
};
|
|
let (real, outlined) = (geom(false), geom(true));
|
|
assert!((real.start - outlined.start).hypot() < 0.1 && (real.end - outlined.end).hypot() < 0.1, "{real:?} vs {outlined:?}");
|
|
}
|
|
|
|
/// The bundled fallback face as another family `family` (13 characters, like the bundled name),
|
|
/// whose licence flags (OS/2 `fsType`) are `fs`.
|
|
fn licensed(family: &str, fs: u16) -> String {
|
|
let face = FontDb::global().face(FALLBACK_FAMILY, "Regular").unwrap();
|
|
let mut bytes = face.file_data().to_vec();
|
|
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
|
for (from, to) in [(utf16(FALLBACK_FAMILY), utf16(family)), (FALLBACK_FAMILY.as_bytes().to_vec(), family.as_bytes().to_vec())] {
|
|
assert_eq!(from.len(), to.len());
|
|
let mut at = 0;
|
|
while let Some(i) = bytes[at..].windows(from.len()).position(|w| w == from) {
|
|
bytes[at + i..at + i + from.len()].copy_from_slice(&to);
|
|
at += i + from.len();
|
|
}
|
|
}
|
|
let tables = u16::from_be_bytes([bytes[4], bytes[5]]) as usize;
|
|
let os2 = (0..tables).map(|i| 12 + 16 * i).find(|r| &bytes[*r..*r + 4] == b"OS/2").unwrap();
|
|
let off = u32::from_be_bytes(bytes[os2 + 8..os2 + 12].try_into().unwrap()) as usize;
|
|
bytes[off + 8..off + 10].copy_from_slice(&fs.to_be_bytes());
|
|
FontDb::global().add_font(bytes);
|
|
assert!(FontDb::global().has_family(family));
|
|
family.into()
|
|
}
|
|
|
|
#[test]
|
|
fn fonts_whose_licence_forbids_embedding_fall_back_to_outlines() {
|
|
let locked = licensed("Locked Sans 3", 0x0002);
|
|
let whole = licensed("Entire Sans 3", 0x0100);
|
|
let d = doc(vec![
|
|
TextObject::point(Point::new(20.0, 40.0), "Open", style(24.0)),
|
|
TextObject::point(Point::new(20.0, 100.0), "Shut", CharStyle { font_family: locked.clone(), ..style(24.0) }),
|
|
TextObject::point(Point::new(20.0, 160.0), "Full", CharStyle { font_family: whole.clone(), ..style(24.0) }),
|
|
]);
|
|
let r = pdf(&d, false);
|
|
assert!(r.warnings.iter().any(|w| w.contains(&locked) && w.contains("doesn't allow embedding")), "{:?}", r.warnings);
|
|
assert!(r.warnings.iter().any(|w| w.contains(&whole) && w.contains("whole")), "{:?}", r.warnings);
|
|
// Only the open font is text; the others are drawn as their outlines.
|
|
let back = import_as(&r.bytes, TextAs::Text);
|
|
assert_eq!(texts(&back), ["Open"]);
|
|
let mut paths = 0;
|
|
back.walk(|n| paths += usize::from(matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })));
|
|
assert!(paths >= 2, "{paths} outlined words");
|
|
}
|
|
|
|
#[test]
|
|
fn type_with_outline_object_is_written_as_paths_with_no_font() {
|
|
let mut d = doc(vec![TextObject::point(Point::new(20.0, 60.0), "Hello World", style(24.0))]);
|
|
let id = d.layers[0].children().unwrap()[0].id;
|
|
d.node_mut(id).unwrap().appearance.effects.push(vectorcraft_doc::Effect { id: "path.outlineObject".into(), params: json!({}), visible: true });
|
|
let r = pdf(&d, false);
|
|
let text = String::from_utf8_lossy(&r.bytes);
|
|
assert!(!text.contains("/FontFile") && !text.contains("/Font"), "no font");
|
|
// Reopened, it is paths, even when type is asked for.
|
|
let back = import_as(&r.bytes, TextAs::Text);
|
|
assert!(texts(&back).is_empty());
|
|
let b = ink(&back);
|
|
assert!(b.width() > 50.0 && b.height() > 10.0, "{b:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn subset_thresholds_below_100_percent_are_accepted_with_a_warning() {
|
|
let d = doc(vec![TextObject::point(Point::new(20.0, 60.0), "Subset", style(24.0))]);
|
|
let settings: PdfSettings = serde_json::from_value(json!({"advanced": {"outlineText": false, "fontSubsetPercent": 40}})).unwrap();
|
|
let r = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap();
|
|
assert!(r.warnings.iter().any(|w| w.contains("subset")), "{:?}", r.warnings);
|
|
assert_eq!(texts(&import_as(&r.bytes, TextAs::Text)), ["Subset"]);
|
|
}
|
|
|
|
/// Characters with neither fill nor stroke (a scanned page's recognized text, as a PDF opens) are
|
|
/// written as invisible text, in rendering mode 3: searchable, drawing nothing, and type without
|
|
/// paint again when the file opens. Compressed or not, in PDF/X-1a and PDF/A too (#2675).
|
|
#[test]
|
|
fn type_without_paint_is_written_as_invisible_text() {
|
|
let unpainted = CharStyle { fill: Paint::None, ..style(24.0) };
|
|
let d = doc(vec![
|
|
TextObject::point(Point::new(20.0, 60.0), "Recognized", unpainted),
|
|
TextObject::point(Point::new(20.0, 160.0), "Visible", style(24.0)),
|
|
]);
|
|
let r = pdf(&d, false);
|
|
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
|
let ops = String::from_utf8_lossy(&r.bytes).split_whitespace().collect::<Vec<_>>().join(" ");
|
|
assert!(ops.contains("3 Tr") && ops.contains("0 Tr"), "the invisible text in mode 3, the visible in mode 0");
|
|
let written = |v: serde_json::Value| {
|
|
let settings: PdfSettings = serde_json::from_value(v).unwrap();
|
|
export(&d, &PdfOptions { settings, created: Some(0), ..Default::default() }).unwrap()
|
|
};
|
|
let files = [
|
|
("uncompressed", r.bytes.clone()),
|
|
("compressed", written(json!({"advanced": {"outlineText": false}}))),
|
|
("PDF/X-1a", written(json!({"standard": "pdfX1a", "compatibility": "1.3", "advanced": {"outlineText": false}}))),
|
|
("PDF/A-2b", written(json!({"standard": "pdfA2b", "advanced": {"outlineText": false}}))),
|
|
];
|
|
for (what, bytes) in files {
|
|
let back = import_as(&bytes, TextAs::Text);
|
|
assert_eq!(texts(&back), ["Recognized", "Visible"], "{what}");
|
|
let mut paints = vec![];
|
|
back.walk(|n| {
|
|
if let NodeKind::Text(t) = &n.kind {
|
|
paints.push(t.runs.iter().all(|r| r.style.fill.is_none() && !r.style.has_stroke()));
|
|
}
|
|
});
|
|
assert_eq!(paints, [true, false], "{what}: only the recognized text has no paint");
|
|
// It draws nothing: as outlines, only the visible text is there (its baseline on 160).
|
|
let drawn = ink(&import_as(&bytes, TextAs::Outlines));
|
|
assert!(drawn.y0 > 100.0, "{what}: {drawn:?}");
|
|
}
|
|
// Text exported as outlines has no invisible text: there is nothing to draw.
|
|
let outlined = import_as(&pdf(&d, true).bytes, TextAs::Text);
|
|
assert!(texts(&outlined).is_empty());
|
|
assert!(ink(&outlined).y0 > 100.0);
|
|
}
|
|
|
|
#[test]
|
|
fn pdf_a_takes_real_text() {
|
|
let d = doc(vec![TextObject::point(Point::new(20.0, 60.0), "Archive", style(24.0))]);
|
|
let settings: PdfSettings = serde_json::from_value(json!({"standard": "pdfA2b", "advanced": {"outlineText": false}})).unwrap();
|
|
let bytes = export(&d, &PdfOptions { settings, created: Some(0), ..Default::default() }).unwrap();
|
|
assert_eq!(texts(&import_as(&bytes, TextAs::Text)), ["Archive"]);
|
|
}
|
|
|
|
/// A named instance comes back as live type in that style (not the default instance's).
|
|
#[test]
|
|
fn a_variable_font_instance_comes_back_in_its_own_style() {
|
|
use vectorcraft_text::test_fonts::{VARIABLE_CHARS, VARIABLE_FAMILY, variable_font};
|
|
FontDb::global().add_font(variable_font().unwrap());
|
|
let st = CharStyle { font_family: VARIABLE_FAMILY.into(), font_style: "Bold".into(), ..style(48.0) };
|
|
let d = doc(vec![TextObject::point(Point::new(20.0, 80.0), VARIABLE_CHARS, st)]);
|
|
let back = crate::import_with_report(&pdf(&d, false).bytes, &ImportOptions { text_as: TextAs::Text, ..Default::default() }).unwrap().document;
|
|
let mut styles = vec![];
|
|
back.walk(|n| {
|
|
if let NodeKind::Text(t) = &n.kind {
|
|
styles.extend(t.runs.iter().map(|r| r.style.font_style.clone()));
|
|
}
|
|
});
|
|
assert_eq!(styles, ["Bold"]);
|
|
}
|
|
|
|
/// The export of `text` (one point type object) with its ToUnicode map rewritten so that the
|
|
/// digits map to U+FFFD, as some files have it (#708). Each `<003D>` of a digit becomes `<FFFD>`, so
|
|
/// the file's offsets still hold.
|
|
fn digits_unnamed(text: &str) -> Vec<u8> {
|
|
let d = doc(vec![TextObject::point(Point::new(20.0, 50.0), text, style(24.0))]);
|
|
let bytes = pdf(&d, false).bytes;
|
|
let at = bytes.windows(11).position(|w| w == b"beginbfchar").expect("a readable ToUnicode map");
|
|
let end = at + bytes[at..].windows(9).position(|w| w == b"endbfchar").unwrap();
|
|
let mut out = bytes.clone();
|
|
for i in at..end.saturating_sub(5) {
|
|
// `<0030>` … `<0039>` after a space: a digit's character (each line's code comes first).
|
|
if &bytes[i..i + 4] == b"<003" && bytes[i + 4].is_ascii_digit() && bytes[i + 5] == b'>' && bytes[i - 1] == b' ' {
|
|
out[i + 1..i + 5].copy_from_slice(b"FFFD");
|
|
}
|
|
}
|
|
assert_ne!(out, bytes, "digits were mapped");
|
|
out
|
|
}
|
|
|
|
/// #708, #811: a glyph whose ToUnicode value is U+FFFD isn't type showing "�". This subset font
|
|
/// has no character map of its own, but the font is installed: each digit is found by its outline
|
|
/// there, so the whole text stays one type object, with no outlines left behind.
|
|
#[test]
|
|
fn unnamed_glyphs_of_an_installed_font_are_found_by_their_outline() {
|
|
let bytes = digits_unnamed("Tel 02-12345");
|
|
let report = import_with_report(&bytes, &ImportOptions { text_as: TextAs::Text, ..Default::default() }).unwrap();
|
|
assert_eq!(texts(&report.document), ["Tel 02-12345"]);
|
|
let mut paths = 0;
|
|
report.document.walk(|n| paths += usize::from(matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })));
|
|
assert_eq!(paths, 0, "no outlines left: {:?}", report.warnings);
|
|
assert!(!report.warnings.iter().any(|w| w.contains("U+FFFD")), "{:?}", report.warnings);
|
|
}
|
|
|
|
/// #811: an outline is named only by a glyph that draws it exactly; one moved off its place, or an
|
|
/// empty glyph (which every space draws), stays unnamed and keeps its outlines.
|
|
#[test]
|
|
fn glyphs_are_named_only_by_an_exact_outline() {
|
|
let db = FontDb::global();
|
|
let face = db.face("Source Sans 3", "Regular").unwrap();
|
|
let k = 1000.0 / face.units_per_em();
|
|
let pdf_space = |c: char| Affine::new([k, 0.0, 0.0, -k, 0.0, 0.0]) * db.outline(&face, face.glyph_for(c)).as_ref().clone();
|
|
for c in ['R', 'a', '7', '&', 'é'] {
|
|
assert_eq!(crate::import::identify_glyph(&face, &pdf_space(c)), Some(c));
|
|
}
|
|
assert_eq!(crate::import::identify_glyph(&face, &(Affine::translate((100.0, 0.0)) * pdf_space('R'))), None);
|
|
assert_eq!(crate::import::identify_glyph(&face, &BezPath::new()), None);
|
|
}
|
|
|
|
/// #708: where the embedded font program has a character map, a glyph's character comes from it.
|
|
#[test]
|
|
fn an_embedded_fonts_own_cmap_names_its_glyphs() {
|
|
let data = std::fs::read(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/SourceSans3-Regular.ttf")).unwrap();
|
|
let chars = crate::import::font_chars(&data).unwrap();
|
|
use skrifa::MetadataProvider;
|
|
let font = skrifa::FontRef::new(&data).unwrap();
|
|
for c in ['0', '7', 'A', 'z'] {
|
|
let g = font.charmap().map(c).unwrap().to_u32();
|
|
assert_eq!(chars.get(&g), Some(&c));
|
|
}
|
|
assert!(crate::import::font_chars(b"not a font").is_none());
|
|
}
|
|
|
|
/// Type whose text matrix shears its glyphs (slanted as a whole, as Illustrator writes type
|
|
/// turned and skewed) reopens as type that still leans: drawn, it covers what its outlines do.
|
|
#[test]
|
|
fn slanted_type_reopens_as_type_that_still_leans() {
|
|
for (turn, lean) in [(0.0, 0.25), (-0.2, 0.25), (0.3, -0.15)] {
|
|
let mut t = TextObject::point(Point::new(80.0, 150.0), "LEAN", style(60.0));
|
|
t.xf *= Affine::rotate(turn) * Affine::skew(lean, 0.0);
|
|
let d = doc(vec![t]);
|
|
let reopened = import_as(&pdf(&d, false).bytes, TextAs::Text);
|
|
assert_eq!(texts(&reopened), ["LEAN"], "{turn} {lean}: reopened as type");
|
|
let (a, b) = (ink(&import_as(&pdf(&reopened, true).bytes, TextAs::Outlines)), ink(&import_as(&pdf(&d, true).bytes, TextAs::Outlines)));
|
|
let off = [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()));
|
|
assert!(off < 0.5, "{turn} {lean}: {a:?} vs {b:?}");
|
|
}
|
|
}
|
|
|
|
/// Type set in an installed font's older version (a library keeps both, one name) reopens as type
|
|
/// in that version: the file's glyphs are matched to it rather than kept as outlines, and the type
|
|
/// names the version, which the family and style alone don't pick.
|
|
#[test]
|
|
fn type_in_another_installed_version_reopens_in_that_version() {
|
|
use vectorcraft_text::test_fonts::{TWIN_FAMILY, TWIN_VERSIONS, twin_font};
|
|
// The newer version first: the one the family and style resolve to.
|
|
FontDb::global().add_font(twin_font(true).unwrap());
|
|
FontDb::global().add_font(twin_font(false).unwrap());
|
|
for version in [None, Some(TWIN_VERSIONS[0])] {
|
|
let st = CharStyle { font_family: TWIN_FAMILY.into(), font_version: version.map(Into::into), ..style(40.0) };
|
|
let d = doc(vec![TextObject::point(Point::new(20.0, 80.0), "Hamburgefonstiv", st)]);
|
|
let back = import_as(&pdf(&d, false).bytes, TextAs::Text);
|
|
let mut found = vec![];
|
|
back.walk(|n| {
|
|
if let NodeKind::Text(t) = &n.kind {
|
|
found.push((t.plain_text(), t.first_style().font_version.clone()));
|
|
}
|
|
});
|
|
assert_eq!(found, [("Hamburgefonstiv".to_string(), version.map(String::from))], "{version:?}");
|
|
}
|
|
}
|
|
|
|
/// The glyph origins of the type objects of `d`, in document space, in order.
|
|
fn origins(d: &Document) -> Vec<Point> {
|
|
let mut out = vec![];
|
|
d.walk(|n| {
|
|
if let NodeKind::Text(t) = &n.kind {
|
|
out.extend(vectorcraft_text::layout(FontDb::global(), t).glyphs.iter().map(|g| t.xf * g.origin));
|
|
}
|
|
});
|
|
out
|
|
}
|
|
|
|
/// Type spaced glyph by glyph (character tightening, optical kerning) comes back with each glyph
|
|
/// in its place, not evened out by one tracking value; evenly spaced type keeps one run.
|
|
#[test]
|
|
fn glyphs_spaced_each_their_own_way_keep_their_place() {
|
|
let runs = [("T", 0.0), ("i", -120.0), ("g", 160.0), ("h", -60.0), ("tl", 0.0), ("y", 0.0)];
|
|
let mut t = TextObject::point(Point::new(20.0, 100.0), "", style(40.0));
|
|
t.runs = runs.iter().map(|(s, tr)| TextRun { text: s.to_string(), style: CharStyle { tracking: *tr, ..style(40.0) }, inline: None }).collect();
|
|
let d = doc(vec![t]);
|
|
let want = origins(&d);
|
|
let back = import_as(&pdf(&d, false).bytes, TextAs::default());
|
|
let got = origins(&back);
|
|
assert_eq!(got.len(), want.len(), "{:?}", texts(&back));
|
|
for (g, w) in got.iter().zip(&want) {
|
|
assert!((g.x - w.x).abs() < 0.1 && (g.y - w.y).abs() < 0.1, "{got:?}\nvs {want:?}");
|
|
}
|
|
let mut runs_back = 0;
|
|
back.walk(|n| {
|
|
if let NodeKind::Text(t) = &n.kind {
|
|
runs_back += t.runs.len();
|
|
}
|
|
});
|
|
assert!(runs_back > 1, "one tracking can't place them");
|
|
// Evenly tracked: one run.
|
|
let even = doc(vec![TextObject::point(Point::new(20.0, 100.0), "Tightly", CharStyle { tracking: -50.0, ..style(40.0) })]);
|
|
let back = import_as(&pdf(&even, false).bytes, TextAs::default());
|
|
back.walk(|n| {
|
|
if let NodeKind::Text(t) = &n.kind {
|
|
assert_eq!(t.runs.len(), 1, "{:?}", t.runs.iter().map(|r| r.style.tracking).collect::<Vec<_>>());
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Words set apart by spaces tracked past an em (#2384) come back with each glyph in its place:
|
|
/// the file draws each space and leaves the gap after it. Evenly tracked words keep one run.
|
|
#[test]
|
|
fn words_set_apart_by_spaces_tracked_past_an_em_keep_their_place() {
|
|
let runs = [("Hooks", 0.0), (" ", 1800.0), ("and", 0.0), (" ", 2400.0), ("leads", 0.0)];
|
|
let mut t = TextObject::point(Point::new(20.0, 100.0), "", style(12.0));
|
|
t.runs = runs.iter().map(|(s, tr)| TextRun { text: s.to_string(), style: CharStyle { tracking: *tr, ..style(12.0) }, inline: None }).collect();
|
|
let d = doc(vec![t]);
|
|
let want = origins(&d);
|
|
let back = import_as(&pdf(&d, false).bytes, TextAs::default());
|
|
assert_eq!(texts(&back), ["Hooks and leads"]);
|
|
let got = origins(&back);
|
|
assert_eq!(got.len(), want.len());
|
|
for (g, w) in got.iter().zip(&want) {
|
|
assert!((g.x - w.x).abs() < 0.1 && (g.y - w.y).abs() < 0.1, "{got:?}\nvs {want:?}");
|
|
}
|
|
// Evenly tracked, the spaces too: one run.
|
|
let even = doc(vec![TextObject::point(Point::new(20.0, 100.0), "Hooks and leads", CharStyle { tracking: 150.0, ..style(12.0) })]);
|
|
let back = import_as(&pdf(&even, false).bytes, TextAs::default());
|
|
assert_eq!(texts(&back), ["Hooks and leads"]);
|
|
back.walk(|n| {
|
|
if let NodeKind::Text(t) = &n.kind {
|
|
assert_eq!(t.runs.len(), 1, "{:?}", t.runs.iter().map(|r| r.style.tracking).collect::<Vec<_>>());
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Evenly tracked type on a path comes back with its tracking: each glyph where it was along the
|
|
/// curve, not closed up to the font's advances.
|
|
#[test]
|
|
fn evenly_tracked_type_on_a_path_keeps_its_length() {
|
|
let mut arc = BezPath::new();
|
|
arc.move_to((40.0, 200.0));
|
|
arc.curve_to((100.0, 80.0), (260.0, 80.0), (320.0, 200.0));
|
|
let mut t = TextObject::point(Point::ORIGIN, "ARCHING TYPE", CharStyle { tracking: 80.0, ..style(24.0) });
|
|
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&arc), start: 0.0, end: None };
|
|
let d = doc(vec![t]);
|
|
let want = origins(&d);
|
|
let back = import_as(&pdf(&d, false).bytes, TextAs::default());
|
|
let got = origins(&back);
|
|
assert_eq!(got.len(), want.len(), "{:?}", texts(&back));
|
|
for (g, w) in got.iter().zip(&want) {
|
|
assert!(g.distance(*w) < 0.3, "{got:?}\nvs {want:?}");
|
|
}
|
|
}
|
|
|
|
/// Type filled with a dithered gradient is an image clipped to its outlines, sampled at the raster
|
|
/// effects resolution on the page however the type is scaled, with its characters under it as
|
|
/// invisible text (rendering mode 3), so they can still be selected and searched.
|
|
#[test]
|
|
fn dithered_type_keeps_its_resolution_when_scaled_and_stays_searchable() {
|
|
let fill = Paint::Gradient(Box::new(GradientPaint::new(Gradient { dither: true, ..Default::default() })));
|
|
let big = TextObject::point(Point::new(20.0, 80.0), "Dither", CharStyle { fill: fill.clone(), ..style(40.0) });
|
|
let mut scaled = TextObject::point(Point::new(5.0, 20.0), "Dither", CharStyle { fill, ..style(10.0) });
|
|
scaled.xf = Affine::scale(4.0) * scaled.xf;
|
|
let width = |bytes: &[u8]| -> u32 {
|
|
let s = String::from_utf8_lossy(bytes);
|
|
s.split("/Width ").nth(1).and_then(|w| w.split(|c: char| !c.is_ascii_digit()).next()).and_then(|n| n.parse().ok()).unwrap()
|
|
};
|
|
let (big, scaled) = (pdf(&doc(vec![big]), false).bytes, pdf(&doc(vec![scaled]), false).bytes);
|
|
let (wb, ws) = (width(&big), width(&scaled));
|
|
// At 72 ppi a pixel a point: 40 pt "Dither" is about 100 points wide.
|
|
assert!(wb >= 80 && wb.abs_diff(ws) <= 2, "40 pt type {wb} pixels wide, 10 pt type scaled 4 times {ws}");
|
|
let ops = String::from_utf8_lossy(&scaled).split_whitespace().collect::<Vec<_>>().join(" ");
|
|
assert!(ops.contains("3 Tr"), "the characters as invisible text");
|
|
assert_eq!(texts(&import_as(&scaled, TextAs::Text)), ["Dither"]);
|
|
}
|