mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 13:36:53 +00:00
1217 lines
61 KiB
Rust
1217 lines
61 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::text::{Bullet, NumberStyle, ParaList};
|
||
use vectorcraft_doc::{CharStyle, Document, Effect, Node, NodeKind, ParaStyle, TextKind, TextObject, TextRun};
|
||
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, shapes};
|
||
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}");
|
||
}
|
||
}
|
||
|
||
/// Area type 120 points wide holding `text` in `style`, hyphenated (Paragraph panel › Hyphenate)
|
||
/// when `hyphenate` is set.
|
||
fn hyphenated(text: &str, style: CharStyle, hyphenate: bool) -> TextObject {
|
||
let mut t = TextObject::point(Point::ZERO, text, style);
|
||
t.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(Rect::new(20.0, 20.0, 140.0, 280.0)) };
|
||
t.xf = Affine::IDENTITY;
|
||
t.para.hyphenate = hyphenate;
|
||
t
|
||
}
|
||
|
||
/// The characters the canvas draws for the type objects of `d`, in layout order (a hyphen for
|
||
/// each glyph that stands for no character), and the origin of each such glyph in document space.
|
||
fn drawn_with_hyphens(d: &Document) -> (String, Vec<Point>) {
|
||
let (mut drawn, mut hyphens) = (String::new(), vec![]);
|
||
d.walk(|n| {
|
||
if let NodeKind::Text(t) = &n.kind {
|
||
let text = t.plain_text();
|
||
for g in &vectorcraft_text::layout(FontDb::global(), t).glyphs {
|
||
if g.len == 0 {
|
||
drawn.push('-');
|
||
hyphens.push(t.xf * g.origin);
|
||
} else if let Some(c) = text.get(g.byte..).and_then(|s| s.chars().next()).filter(|c| !c.is_control() && *c != '\u{AD}') {
|
||
drawn.push(c);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
(drawn, hyphens)
|
||
}
|
||
|
||
/// The origins in document space of the glyphs of the type objects of `d` that stand for '-'.
|
||
fn hyphen_origins(d: &Document) -> Vec<Point> {
|
||
let mut out = vec![];
|
||
d.walk(|n| {
|
||
if let NodeKind::Text(t) = &n.kind {
|
||
let text = t.plain_text();
|
||
let lay = vectorcraft_text::layout(FontDb::global(), t);
|
||
out.extend(lay.glyphs.iter().filter(|g| g.len > 0 && text.get(g.byte..).is_some_and(|s| s.starts_with('-'))).map(|g| t.xf * g.origin));
|
||
}
|
||
});
|
||
out
|
||
}
|
||
|
||
/// The hyphen at the end of a line that area type breaks inside a word (Hyphenate) is written as
|
||
/// text: reopened, the export reads each line's characters and then the hyphen where the canvas
|
||
/// draws one, the hyphen sits where the canvas draws it, and the type's ink bounds match those of
|
||
/// outlined type. Type without paint, written as invisible text, holds the hyphens too.
|
||
#[test]
|
||
fn line_break_hyphens_are_written_as_text_where_the_canvas_draws_them() {
|
||
let text = "Extraordinarily comprehensive typographical considerations notwithstanding everything.";
|
||
for paint in [Paint::solid(Color::BLACK), Paint::None] {
|
||
let d = doc(vec![hyphenated(text, CharStyle { fill: paint.clone(), ..style(14.0) }, true)]);
|
||
let (drawn, hyphens) = drawn_with_hyphens(&d);
|
||
assert!(!hyphens.is_empty(), "the layout hyphenates: {drawn}");
|
||
let back = import_as(&pdf(&d, false).bytes, TextAs::Text);
|
||
let all = texts(&back).concat();
|
||
let solid = |s: &str| s.chars().filter(|c| !c.is_whitespace()).collect::<String>();
|
||
assert_eq!(solid(&all), solid(&drawn), "{paint:?}");
|
||
// Each hyphen follows its line's last character, with no space between them.
|
||
assert!(!all.chars().zip(all.chars().skip(1)).any(|(a, b)| a.is_whitespace() && b == '-'), "{paint:?}: {all:?}");
|
||
let got = hyphen_origins(&back);
|
||
for p in &hyphens {
|
||
assert!(got.iter().any(|g| g.distance(*p) < 0.1), "{paint:?}: a hyphen at {p:?}: {got:?}");
|
||
}
|
||
}
|
||
let d = doc(vec![hyphenated(text, style(14.0), true)]);
|
||
let (real, outlined) = (ink(&import_as(&pdf(&d, false).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, "{real:?} vs {outlined:?}");
|
||
}
|
||
|
||
/// Right-to-left area type broken at its soft hyphens writes each hyphen as text where the canvas
|
||
/// draws it, and its letters come back in their order. Skipped without a font that covers Hebrew.
|
||
#[test]
|
||
fn line_break_hyphens_of_right_to_left_lines_are_written_as_text_where_the_canvas_draws_them() {
|
||
let db = 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;
|
||
};
|
||
let text = "שלוםעולם\u{AD}גדולמאוד\u{AD}ומאוד ירושלים\u{AD}שלזהב\u{AD}ובתלאביב";
|
||
let mut t = hyphenated(text, CharStyle { font_family: face.family.clone(), ..style(14.0) }, true);
|
||
t.para.justify = vectorcraft_doc::Justify::Auto;
|
||
let lines = vectorcraft_text::layout(db, &t).lines.len();
|
||
let d = doc(vec![t]);
|
||
let (_, hyphens) = drawn_with_hyphens(&d);
|
||
assert!(!hyphens.is_empty(), "the lines break at soft hyphens");
|
||
let r = pdf(&d, false);
|
||
// Each line's letters are still marked as written in reverse (/ReversedChars), the hyphenated
|
||
// lines too.
|
||
let reversed = String::from_utf8_lossy(&r.bytes).matches("/ReversedChars").count();
|
||
assert!(reversed >= lines, "{reversed} reversed runs for {lines} lines");
|
||
let back = import_as(&r.bytes, TextAs::Text);
|
||
let got = hyphen_origins(&back);
|
||
for p in &hyphens {
|
||
assert!(got.iter().any(|g| g.distance(*p) < 0.1), "a hyphen at {p:?}: {got:?}");
|
||
}
|
||
let letters = |s: &str| s.chars().filter(|c| c.is_alphabetic()).collect::<String>();
|
||
assert_eq!(letters(&texts(&back).concat()), letters(text));
|
||
}
|
||
|
||
/// The kashidas of justified Arabic type (Insert Kashidas), which stand for no character either,
|
||
/// are drawn as paths: reopened, the type holds its letters and no hyphen or tatweel. Skipped
|
||
/// without an OpenType font that covers Arabic and the tatweel, when the layout adds no kashida,
|
||
/// or when PDF text import returns no strings on this platform.
|
||
#[test]
|
||
fn kashidas_are_not_written_as_hyphens() {
|
||
let db = FontDb::global();
|
||
let Some(face) = ["Noto Sans Arabic", "Arial", "Geeza Pro"]
|
||
.into_iter()
|
||
.filter_map(|f| db.face(f, "Regular"))
|
||
.find(|f| f.covers('ب') && f.covers('د') && f.covers('\u{0640}'))
|
||
else {
|
||
return;
|
||
};
|
||
let text = "كتب ورد محمد";
|
||
let mut t = TextObject::point(Point::ZERO, text, CharStyle { font_family: face.family.clone(), ..style(20.0) });
|
||
t.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(Rect::new(20.0, 20.0, 320.0, 280.0)) };
|
||
t.xf = Affine::IDENTITY;
|
||
t.para.justify = vectorcraft_doc::Justify::JustifyAll;
|
||
t.para.kashida = vectorcraft_doc::text::Kashida::Medium;
|
||
let tatweel = face.glyph_for('\u{0640}');
|
||
if !vectorcraft_text::layout(db, &t).glyphs.iter().any(|g| g.len == 0 && g.gid == tatweel) {
|
||
return;
|
||
}
|
||
let back = texts(&import_as(&pdf(&doc(vec![t]), false).bytes, TextAs::Text));
|
||
if back.is_empty() {
|
||
return;
|
||
}
|
||
assert!(!back.concat().contains(['-', '\u{2010}', '\u{0640}']), "{back:?}");
|
||
// Every letter comes back, at least as often as the text holds it (the justified words may
|
||
// come back as type objects of their own).
|
||
let all = back.concat();
|
||
if all.is_empty() {
|
||
return;
|
||
}
|
||
let count = |s: &str, c: char| s.chars().filter(|&x| x == c).count();
|
||
for c in text.chars().filter(|c| c.is_alphabetic()) {
|
||
assert!(count(&all, c) >= count(text, c), "{c}: {back:?}");
|
||
}
|
||
}
|
||
|
||
/// Area type without Hyphenate comes back with its characters only, and no hyphen.
|
||
#[test]
|
||
fn type_without_line_break_hyphens_comes_back_without_hyphens() {
|
||
let text = "Extraordinarily comprehensive typographical considerations notwithstanding everything.";
|
||
let d = doc(vec![hyphenated(text, style(14.0), false)]);
|
||
let (drawn, hyphens) = drawn_with_hyphens(&d);
|
||
assert!(hyphens.is_empty(), "{drawn}");
|
||
let all = texts(&import_as(&pdf(&d, false).bytes, TextAs::Text)).concat();
|
||
assert_eq!(all.split_whitespace().collect::<String>(), text.split_whitespace().collect::<String>());
|
||
}
|
||
|
||
/// The marker of a hyphenated bulleted list, which stands for no character of the text either, is
|
||
/// not written as a hyphen: reopened, the type holds one hyphen for each line-break hyphen only.
|
||
#[test]
|
||
fn list_markers_are_not_written_as_line_break_hyphens() {
|
||
use vectorcraft_doc::text::{Bullet, ParaList};
|
||
let text = "Extraordinarily comprehensive typographical considerations notwithstanding everything.";
|
||
for bullet in [Bullet::Dash, Bullet::Dot] {
|
||
let mut t = hyphenated(text, style(14.0), true);
|
||
t.para.list = Some(ParaList::bulleted(bullet));
|
||
let lay = vectorcraft_text::layout(FontDb::global(), &t);
|
||
assert!(!lay.markers.is_empty(), "{bullet:?}: the paragraph has a marker");
|
||
let breaks = lay.glyphs.iter().enumerate().filter(|&(i, g)| g.len == 0 && lay.marker_of(i).is_none()).count();
|
||
assert!(breaks > 0, "{bullet:?}: the layout hyphenates");
|
||
let all = texts(&import_as(&pdf(&doc(vec![t]), false).bytes, TextAs::Text)).concat();
|
||
assert_eq!(all.matches(['-', '\u{2010}']).count(), breaks, "{bullet:?}: {all:?}");
|
||
}
|
||
}
|
||
|
||
/// 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}");
|
||
}
|
||
}
|
||
|
||
/// Area type 200 points wide holding `text`, its paragraphs in lists `lists`, with an 18 pt
|
||
/// hanging indent.
|
||
fn listed(text: &str, lists: &[Option<ParaList>]) -> TextObject {
|
||
let mut t = TextObject::point(Point::ZERO, text, style(12.0));
|
||
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(20.0, 20.0, 220.0, 280.0)) };
|
||
t.xf = Affine::IDENTITY;
|
||
t.para.left_indent = 18.0;
|
||
t.para.first_line_indent = -18.0;
|
||
let styles = lists.iter().map(|&list| ParaStyle { list, ..t.para.clone() }).collect();
|
||
t.set_paragraph_styles(styles);
|
||
t
|
||
}
|
||
|
||
/// The origins (document space) of the glyphs of each list marker of the type objects of `d`, in
|
||
/// the order they are drawn, the marker's tab left out.
|
||
fn marker_origins(d: &Document) -> Vec<Vec<Point>> {
|
||
let mut out = vec![];
|
||
d.walk(|n| {
|
||
if let NodeKind::Text(t) = &n.kind {
|
||
let lay = vectorcraft_text::layout(FontDb::global(), t);
|
||
for m in &lay.markers {
|
||
out.push(m.glyphs.clone().filter(|&i| i != m.tab).map(|i| t.xf * lay.glyphs[i].origin).collect());
|
||
}
|
||
}
|
||
});
|
||
out
|
||
}
|
||
|
||
/// Each glyph of the type objects of `d` that stands for characters of its text: the first of
|
||
/// them and the glyph's origin in document space.
|
||
fn glyph_chars(d: &Document) -> Vec<(char, Point)> {
|
||
let mut out = vec![];
|
||
d.walk(|n| {
|
||
if let NodeKind::Text(t) = &n.kind {
|
||
let text = t.plain_text();
|
||
for g in vectorcraft_text::layout(FontDb::global(), t).glyphs.iter().filter(|g| g.len > 0) {
|
||
if let Some(c) = text.get(g.byte..).and_then(|s| s.chars().next()) {
|
||
out.push((c, t.xf * g.origin));
|
||
}
|
||
}
|
||
}
|
||
});
|
||
out
|
||
}
|
||
|
||
/// Does `text` hold `a` before `b`?
|
||
fn before(text: &str, a: &str, b: &str) -> bool {
|
||
matches!((text.find(a), text.find(b)), (Some(x), Some(y)) if x < y)
|
||
}
|
||
|
||
/// `back`, the export of `d` reopened, has glyphs of the characters `drawn` (each list marker's,
|
||
/// in the order the canvas draws them) within `near` points of where `d` draws its markers'
|
||
/// glyphs.
|
||
fn assert_markers_in_place(d: &Document, back: &Document, drawn: &[&str], near: f64) {
|
||
let want = marker_origins(d);
|
||
assert_eq!(want.len(), drawn.len(), "{drawn:?}");
|
||
let got = glyph_chars(back);
|
||
for (origins, chars) in want.iter().zip(drawn) {
|
||
assert_eq!(origins.len(), chars.chars().count(), "{chars}");
|
||
for (p, c) in origins.iter().zip(chars.chars()) {
|
||
assert!(got.iter().any(|(gc, gp)| *gc == c && gp.distance(*p) < near), "{c:?} at {p:?}: {got:?}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// List markers (Paragraph panel › Bullets and Numbered List) are written as text: reopened, the
|
||
/// export holds each bullet and number as characters, before its paragraph's text and where the
|
||
/// canvas draws them, and the page looks as it does with outlined type.
|
||
#[test]
|
||
fn list_markers_are_written_as_text_where_the_canvas_draws_them() {
|
||
let number = Some(ParaList::numbered(NumberStyle::Arabic));
|
||
for (bullet, character) in [(Bullet::Dot, "\u{2022}"), (Bullet::Dash, "\u{2013}"), (Bullet::Circle, "\u{25E6}"), (Bullet::Square, "\u{25AA}")] {
|
||
let bullet = Some(ParaList::bulleted(bullet));
|
||
let t = listed("alpha beta gamma delta epsilon zeta eta theta\nbullet\nfirst\nsecond", &[bullet, bullet, number, number]);
|
||
let d = doc(vec![t]);
|
||
let back = import_as(&pdf(&d, false).bytes, TextAs::Text);
|
||
let all = texts(&back).concat();
|
||
assert_eq!(all.matches(character).count(), 2, "{all}");
|
||
assert!(before(&all, character, "alpha") && before(&all, "1.", "first") && before(&all, "2.", "second"), "{all}");
|
||
assert_markers_in_place(&d, &back, &[character, character, "1.", "2."], 0.1);
|
||
let (real, outlined) = (ink(&import_as(&pdf(&d, false).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, "{character}: {real:?} vs {outlined:?}");
|
||
}
|
||
}
|
||
|
||
/// A right-to-left numbered paragraph's number is written as its characters where the canvas
|
||
/// draws them (the period, then 1 and 2, from the left), and the paragraph's own text comes back
|
||
/// as it does without a list.
|
||
#[test]
|
||
fn a_right_to_left_number_is_written_as_text_where_the_canvas_draws_it() {
|
||
let hebrew = "שלום עולם";
|
||
let mut plain = listed(hebrew, &[None]);
|
||
plain.edit_paras(None, |p| p.justify = vectorcraft_doc::Justify::Auto);
|
||
let mut numbered = plain.clone();
|
||
numbered.edit_paras(None, |p| p.list = Some(ParaList::numbered(NumberStyle::Arabic)));
|
||
numbered.list_count = 11;
|
||
let d = doc(vec![numbered]);
|
||
let back = import_as(&pdf(&d, false).bytes, TextAs::Text);
|
||
// The reader tracks a reopened right-to-left line as a whole, and the gap before the number
|
||
// comes back as a space of the Hebrew font's width, so the number may sit up to a tenth of an
|
||
// em (1.2 pt) away; its characters keep their spacing to within 0.1 pt.
|
||
assert_markers_in_place(&d, &back, &[".12"], 1.2);
|
||
let drawn = marker_origins(&d).concat();
|
||
let got = glyph_chars(&back);
|
||
let at = |c: char| got.iter().find(|(gc, _)| *gc == c).map(|(_, p)| p.x);
|
||
for (k, (a, b)) in [('.', '1'), ('1', '2')].into_iter().enumerate() {
|
||
let (Some(pa), Some(pb), Some(da), Some(db)) = (at(a), at(b), drawn.get(k), drawn.get(k + 1)) else {
|
||
panic!("{a:?} and {b:?} in {got:?}");
|
||
};
|
||
assert!(((pb - pa) - (db.x - da.x)).abs() < 0.1, "{a:?} to {b:?}: {} against {}", pb - pa, db.x - da.x);
|
||
}
|
||
let without = texts(&import_as(&pdf(&doc(vec![plain]), false).bytes, TextAs::Text)).concat();
|
||
let all = texts(&back).concat();
|
||
assert!(all.contains(without.trim()), "{all:?} holds {without:?}");
|
||
}
|
||
|
||
/// Type on a path writes its bullet as text where the canvas draws it along the path.
|
||
#[test]
|
||
fn a_bullet_on_a_path_is_written_as_text_where_the_canvas_draws_it() {
|
||
let mut t = TextObject::point(Point::ZERO, "along the arc", style(18.0));
|
||
t.kind = TextKind::OnPath { path: arc(), start: 0.0, end: None };
|
||
t.xf = Affine::IDENTITY;
|
||
t.para.list = Some(ParaList::bulleted(Bullet::Dot));
|
||
let d = doc(vec![t]);
|
||
let back = import_as(&pdf(&d, false).bytes, TextAs::Text);
|
||
assert!(texts(&back).concat().contains('\u{2022}'), "{:?}", texts(&back));
|
||
assert_markers_in_place(&d, &back, &["\u{2022}"], 0.3);
|
||
}
|
||
|
||
/// Threaded frames write each marker their layouts draw as text, the number of the paragraph the
|
||
/// first frame splits included, in each frame where the canvas draws it.
|
||
#[test]
|
||
fn numbers_in_threaded_frames_are_written_as_text_in_each_frame() {
|
||
let numbered =
|
||
ParaStyle { list: Some(ParaList::numbered(NumberStyle::Arabic)), left_indent: 18.0, first_line_indent: -18.0, ..Default::default() };
|
||
let frame = |r: Rect| {
|
||
let mut t = TextObject::point(Point::ZERO, "", style(12.0));
|
||
t.kind = TextKind::Area { frame: shapes::rectangle(r) };
|
||
t.xf = Affine::IDENTITY;
|
||
t
|
||
};
|
||
let (mut a, mut b) = (frame(Rect::new(20.0, 20.0, 180.0, 80.0)), frame(Rect::new(200.0, 20.0, 360.0, 280.0)));
|
||
let text = format!("One\nTwo\n{}\nFour", "The quick brown fox jumps over the lazy dog. ".repeat(4));
|
||
let story = vec![TextRun { text, style: style(12.0), inline: None }];
|
||
let parts = vectorcraft_text::thread::distribute(FontDb::global(), &[&a, &b], &story, &vec![numbered; 4]);
|
||
for (f, part) in [&mut a, &mut b].into_iter().zip(&parts) {
|
||
f.runs = part.runs.clone();
|
||
f.set_paragraph_styles(part.paras.clone());
|
||
f.list_count = part.list_count;
|
||
}
|
||
let first = a.plain_text();
|
||
assert!(first.contains("quick") && !first.ends_with('\n'), "the first frame splits the third paragraph: {first:?}");
|
||
let d = doc(vec![a, b]);
|
||
let mut drawn: Vec<String> = vec![];
|
||
d.walk(|n| {
|
||
if let NodeKind::Text(t) = &n.kind {
|
||
drawn.extend(vectorcraft_text::layout(FontDb::global(), t).markers.into_iter().map(|m| m.text));
|
||
}
|
||
});
|
||
assert!(drawn.contains(&"4.".to_string()), "{drawn:?}");
|
||
let back = import_as(&pdf(&d, false).bytes, TextAs::Text);
|
||
assert_markers_in_place(&d, &back, &drawn.iter().map(String::as_str).collect::<Vec<_>>(), 0.1);
|
||
}
|
||
|
||
/// The numbers of list paragraphs at a deeper level are written as text where the canvas draws
|
||
/// them, 18 pt further in, each level with its own count.
|
||
#[test]
|
||
fn numbers_at_a_deeper_list_level_are_written_as_text_where_the_canvas_draws_them() {
|
||
let number = |level| Some(ParaList::numbered(NumberStyle::Arabic).at_level(level));
|
||
let d = doc(vec![listed("first\nsecond\nthird\nfourth", &[number(1), number(2), number(2), number(1)])]);
|
||
let origins = marker_origins(&d);
|
||
let x = |m: usize| origins.get(m).and_then(|o| o.first()).map_or(f64::NAN, |p| p.x);
|
||
assert!((x(1) - x(0) - 18.0).abs() < 1e-6 && (x(3) - x(0)).abs() < 1e-6, "{origins:?}");
|
||
let back = import_as(&pdf(&d, false).bytes, TextAs::Text);
|
||
let all = texts(&back).concat();
|
||
assert!(before(&all, "1.", "first") && before(&all, "2.", "third") && all.matches("2.").count() == 2, "{all}");
|
||
assert_markers_in_place(&d, &back, &["1.", "1.", "2.", "2."], 0.1);
|
||
}
|
||
|
||
/// A number set in oldstyle figures (the first character's OpenType features) is written as its
|
||
/// digits, though the character map gives the digits other glyphs. The reopened type has no
|
||
/// OpenType features and sets lining figures, so only where each number starts is compared.
|
||
#[test]
|
||
fn a_number_in_oldstyle_figures_is_written_as_its_digits() {
|
||
let number = Some(ParaList::numbered(NumberStyle::Arabic));
|
||
let mut t = listed("first\nsecond", &[number, number]);
|
||
for r in &mut t.runs {
|
||
r.style.features = vec!["onum".into()];
|
||
}
|
||
let lay = vectorcraft_text::layout(FontDb::global(), &t);
|
||
let one = &lay.glyphs[lay.markers[0].glyphs.start];
|
||
let face = FontDb::global().face_by_id(one.font_id).unwrap();
|
||
assert_ne!(face.glyph_for('1'), one.gid, "an oldstyle 1, not the character map's");
|
||
let d = doc(vec![t]);
|
||
let back = import_as(&pdf(&d, false).bytes, TextAs::Text);
|
||
let all = texts(&back).concat();
|
||
assert!(before(&all, "1.", "first") && before(&all, "2.", "second"), "{all:?}");
|
||
let got = glyph_chars(&back);
|
||
for (origins, digit) in marker_origins(&d).iter().zip(['1', '2']) {
|
||
assert!(got.iter().any(|(c, p)| *c == digit && p.distance(origins[0]) < 0.1), "{digit} at {:?}: {got:?}", origins[0]);
|
||
}
|
||
}
|
||
|
||
/// 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.setting_notes.iter().any(|w| w.contains("subset")), "{:?}", r.setting_notes);
|
||
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"]);
|
||
}
|
||
|
||
/// Exact craft-fonts face: a substituted Arabic font would miss Noto's dot decomposition.
|
||
fn mark_text(text: &str) -> Option<TextObject> {
|
||
// The craft-fonts build of the face: a system font of the same family (Linux distributions
|
||
// install one) can lack the Latin and digit glyphs these fixtures shape with it.
|
||
let bundled = vectorcraft_text::CRAFT_FONTS.iter().any(|f| f.family == "Noto Sans Arabic" && f.style == "Regular");
|
||
let face = FontDb::global().face("Noto Sans Arabic", "Regular").filter(|f| bundled && f.family == "Noto Sans Arabic");
|
||
let Some(face) = face else {
|
||
assert!(std::env::var("CRAFT_FONTS_REQUIRED").as_deref() != Ok("1"), "Noto Sans Arabic is required");
|
||
eprintln!("SKIPPED: PDF mark-text regression needs craft-fonts Noto Sans Arabic");
|
||
return None;
|
||
};
|
||
Some(TextObject::point(Point::new(30.0, 100.0), text, CharStyle { font_family: face.family.clone(), ..style(32.0) }))
|
||
}
|
||
|
||
/// Decode semantic cluster mappings directly: ActualText replaces its enclosed glyphs, whose
|
||
/// CIDs otherwise resolve through the active font's ToUnicode. A shared mark glyph can only
|
||
/// carry one ToUnicode value; its other clusters must still occur exactly once via ActualText.
|
||
fn mark_stream(bytes: &[u8]) -> (usize, Vec<String>, Vec<String>) {
|
||
use craft_pdf::syntax::{
|
||
Pdf,
|
||
content::UntypedIter,
|
||
object::{Dict, Object, Stream},
|
||
};
|
||
let file = Pdf::new(Arc::new(bytes.to_vec())).unwrap();
|
||
let page = &file.pages()[0];
|
||
let resources = page.raw().get::<Dict<'_>>(b"Resources").unwrap();
|
||
let fonts = resources.get::<Dict<'_>>(b"Font").unwrap();
|
||
let mut font_maps = std::collections::BTreeMap::new();
|
||
for name in fonts.keys() {
|
||
let font = fonts.get::<Dict<'_>>(name.as_ref()).unwrap();
|
||
let cmap = font.get::<Stream<'_>>(b"ToUnicode").unwrap().decoded().unwrap();
|
||
let cmap = std::str::from_utf8(&cmap).unwrap();
|
||
let mut mappings = std::collections::BTreeMap::new();
|
||
// The writer emits bfchar entries with a hexadecimal UTF-16BE destination.
|
||
for block in cmap.split("beginbfchar").skip(1) {
|
||
for entry in block.split("endbfchar").next().unwrap().lines().filter(|l| l.trim_start().starts_with('<')) {
|
||
let mut pair = entry.split_whitespace();
|
||
let cid = u16::from_str_radix(pair.next().unwrap().trim_matches(['<', '>']), 16).unwrap();
|
||
let value = pair.next().unwrap().trim_matches(['<', '>']);
|
||
let units = (0..value.len()).step_by(4).map(|i| u16::from_str_radix(&value[i..i + 4], 16).unwrap()).collect::<Vec<_>>();
|
||
assert!(mappings.insert(cid, String::from_utf16(&units).unwrap()).is_none());
|
||
}
|
||
}
|
||
let unique: std::collections::BTreeSet<_> = mappings.values().collect();
|
||
assert_eq!(unique.len(), mappings.len(), "a cluster must not be assigned to multiple glyphs: {mappings:?}");
|
||
font_maps.insert(name.as_ref().to_vec(), mappings);
|
||
}
|
||
let mut ops = UntypedIter::new(page.page_stream().unwrap());
|
||
let (mut batches, mut actual, mut mapped) = (0, vec![], vec![]);
|
||
let (mut marked, mut font) = (vec![], vec![]);
|
||
while let Some(op) = ops.next() {
|
||
match &**op.operator {
|
||
b"BT" => batches += 1,
|
||
b"Tf" => {
|
||
let Some(Object::Name(name)) = op.operands().next() else { panic!("font name") };
|
||
font = name.as_ref().to_vec();
|
||
}
|
||
b"BMC" | b"BDC" => {
|
||
let replacement = op.operands().find_map(|operand| {
|
||
if let Object::Dict(d) = operand {
|
||
d.get::<craft_pdf::syntax::object::String<'_>>(b"ActualText").map(|s| crate::import_scan::text_string(s.as_bytes()))
|
||
} else {
|
||
None
|
||
}
|
||
});
|
||
if let Some(text) = &replacement {
|
||
assert!(!marked.contains(&true), "no nested replacement in these fixtures");
|
||
actual.push(text.clone());
|
||
mapped.push(text.clone());
|
||
}
|
||
marked.push(replacement.is_some());
|
||
}
|
||
b"EMC" => {
|
||
marked.pop().unwrap();
|
||
}
|
||
b"Tj" | b"TJ" if !marked.contains(&true) => {
|
||
let strings: Vec<_> = op
|
||
.operands()
|
||
.flat_map(|o| match o {
|
||
Object::String(s) => vec![s.clone()],
|
||
Object::Array(a) => a.iter::<craft_pdf::syntax::object::String<'_>>().collect(),
|
||
_ => vec![],
|
||
})
|
||
.collect();
|
||
for string in strings {
|
||
let (cids, trailing) = string.as_bytes().as_chunks::<2>();
|
||
assert!(trailing.is_empty());
|
||
for cid in cids {
|
||
let cid = u16::from_be_bytes(*cid);
|
||
mapped.push(font_maps[&font].get(&cid).expect("unspanned glyph needs a Unicode mapping").clone());
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
assert!(marked.is_empty());
|
||
(batches, actual, mapped)
|
||
}
|
||
|
||
/// Every contour, including each dot and harakat, must stay at the outline export's position.
|
||
fn mark_placement(real: &[u8], outlined: &[u8]) {
|
||
use vectorcraft_geom::{PathEl, Shape};
|
||
let contours = |bytes: &[u8]| {
|
||
let mut boxes = vec![];
|
||
import_as(bytes, TextAs::Outlines).walk(|n| {
|
||
let paths = match &n.kind {
|
||
NodeKind::Path { path, .. } => vec![path.to_bezpath()],
|
||
_ => vec![],
|
||
};
|
||
for path in paths {
|
||
let mut contour = BezPath::new();
|
||
for el in path.elements() {
|
||
if matches!(el, PathEl::MoveTo(_)) && contour.elements().len() > 1 {
|
||
boxes.push(std::mem::take(&mut contour).bounding_box());
|
||
}
|
||
contour.push(*el);
|
||
}
|
||
if contour.elements().len() > 1 {
|
||
boxes.push(contour.bounding_box());
|
||
}
|
||
}
|
||
});
|
||
boxes
|
||
};
|
||
let (got, mut want) = (contours(real), contours(outlined));
|
||
assert!(!got.is_empty());
|
||
assert_eq!(got.len(), want.len(), "every outline contour is retained");
|
||
for a in got {
|
||
let (i, distance) = want
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, b)| (i, [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].into_iter().map(f64::abs).fold(0.0, f64::max)))
|
||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||
.unwrap();
|
||
assert!(distance < 0.05, "contour {a:?} moved by {distance} pt; nearest {:?}", want[i]);
|
||
want.remove(i);
|
||
}
|
||
}
|
||
|
||
fn check_mark_clusters(text: &str) {
|
||
let Some(t) = mark_text(text) else { return };
|
||
let layout = vectorcraft_text::layout(FontDb::global(), &t);
|
||
let face = FontDb::global().face("Noto Sans Arabic", "Regular").unwrap();
|
||
assert!(layout.glyphs.iter().all(|g| g.font_id == face.id() && g.gid != 0), "no fallback or missing glyphs");
|
||
assert_eq!(layout.lines.len(), 1);
|
||
let mut clusters = std::collections::BTreeMap::<_, Vec<_>>::new();
|
||
for g in &layout.glyphs {
|
||
clusters.entry((g.byte, g.len)).or_default().push(g);
|
||
}
|
||
let repeated: Vec<_> = clusters.iter().filter(|(_, glyphs)| glyphs.len() > 1).collect();
|
||
assert!(!repeated.is_empty(), "fixture must shape into multi-glyph clusters");
|
||
assert!(
|
||
repeated.iter().any(|(_, gs)| gs.windows(2).any(|w| (w[0].xf.as_coeffs()[5] - w[1].xf.as_coeffs()[5]).abs() > 0.01)),
|
||
"marks have vertical offsets"
|
||
);
|
||
assert!(repeated.iter().any(|(_, gs)| gs.iter().any(|g| g.advance.abs() < 1e-6)), "zero-advance marks, including dots in unvocalized شنطة");
|
||
let d = doc(vec![t]);
|
||
let r = pdf(&d, false);
|
||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||
let (batches, actual, mappings) = mark_stream(&r.bytes);
|
||
assert_eq!(batches, 1, "one batch per compatible layout line: {text}");
|
||
let mut expected_actual: Vec<_> = repeated.iter().map(|((byte, len), _)| text[*byte..byte + len].to_string()).collect();
|
||
let mut actual = actual;
|
||
actual.sort();
|
||
expected_actual.sort();
|
||
assert_eq!(actual, expected_actual, "each multi-glyph cluster has exactly one semantic replacement");
|
||
let mut expected: Vec<_> = clusters.keys().map(|(byte, len)| text[*byte..byte + len].to_string()).collect();
|
||
let mut mappings = mappings;
|
||
expected.sort();
|
||
mappings.sort();
|
||
assert_eq!(mappings, expected, "each source cluster maps exactly once through ActualText or ToUnicode");
|
||
mark_placement(&r.bytes, &pdf(&d, true).bytes);
|
||
}
|
||
|
||
#[test]
|
||
fn mark_text_harakat_clusters_are_mapped_once() {
|
||
check_mark_clusters("مُحَمَّد");
|
||
}
|
||
|
||
#[test]
|
||
fn mark_text_zero_advance_dot_clusters_are_mapped_once() {
|
||
check_mark_clusters("شنطة");
|
||
}
|
||
|
||
#[test]
|
||
fn mark_text_mixed_arabic_latin_and_digits_are_mapped_once() {
|
||
check_mark_clusters("ABC 12 مُحَمَّد شنطة");
|
||
}
|
||
|
||
#[test]
|
||
fn mark_text_columns_and_rotations_keep_their_boundaries() {
|
||
let mut vertical = TextObject::point(Point::new(200.0, 30.0), "§§\n§§", style(24.0));
|
||
vertical.vertical = true;
|
||
let layout = vectorcraft_text::layout(FontDb::global(), &vertical);
|
||
assert_eq!(layout.lines.len(), 2);
|
||
let d = doc(vec![vertical]);
|
||
let r = pdf(&d, false);
|
||
assert_eq!(mark_stream(&r.bytes).0, 2, "one text batch per vertical column");
|
||
mark_placement(&r.bytes, &pdf(&d, true).bytes);
|
||
|
||
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::ZERO, "CURVE", style(24.0));
|
||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&arc), start: 0.0, end: None };
|
||
let layout = vectorcraft_text::layout(FontDb::global(), &t);
|
||
assert!(layout.glyphs.windows(2).all(|w| (w[0].angle - w[1].angle).abs() > 1e-4));
|
||
let d = doc(vec![t]);
|
||
let r = pdf(&d, false);
|
||
assert_eq!(mark_stream(&r.bytes).0, layout.glyphs.len(), "different rotations still split");
|
||
mark_placement(&r.bytes, &pdf(&d, true).bytes);
|
||
}
|
||
|
||
#[test]
|
||
fn mark_text_pdf_x_variants_export() {
|
||
let Some(t) = mark_text("مُحَمَّد شنطة") else { return };
|
||
let d = doc(vec![t]);
|
||
for (standard, compatibility) in [("pdfX1a", "1.3"), ("pdfX3", "1.3"), ("pdfX4", "1.6")] {
|
||
let settings: PdfSettings =
|
||
serde_json::from_value(json!({"standard": standard, "compatibility": compatibility, "advanced": {"outlineText": false}})).unwrap();
|
||
let r = export_with_report(&d, &PdfOptions { settings, ..Default::default() }).unwrap();
|
||
assert_eq!(mark_stream(&r.bytes).0, 1, "{standard}");
|
||
mark_placement(&r.bytes, &pdf(&d, true).bytes);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "#6165: editable-text importer does not consume ActualText; run explicitly to track the exact round trip"]
|
||
fn mark_text_reader_round_trip_harakat() {
|
||
let Some(t) = mark_text("مُحَمَّد") else { return };
|
||
assert_eq!(texts(&import_as(&pdf(&doc(vec![t]), false).bytes, TextAs::Text)), ["مُحَمَّد"]);
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "#6165: editable-text importer does not consume ActualText; run explicitly to track the exact round trip"]
|
||
fn mark_text_reader_round_trip_dots() {
|
||
let Some(t) = mark_text("شنطة") else { return };
|
||
assert_eq!(texts(&import_as(&pdf(&doc(vec![t]), false).bytes, TextAs::Text)), ["شنطة"]);
|
||
}
|