Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_austenite/tests/pdf_fonts.rs

11.0 KiB, 7 runs

created by r1870400018:59744, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! The PDF font-embedding gate: exported text must be real text in real fonts, which a reader can select,
2//! copy and search. Each test writes a PDF and hands it to tools that did not write it -- poppler's
3//! `pdftotext` and `pdffonts`, and Ghostscript -- so the claim is checked by an outside reader rather
4//! than by the writer's own idea of what it wrote.
5//!
6//! Non-vacuity: every test asserts the font's kind as `pdffonts` reports it (`CID Type 0C` for CFF,
7//! `CID TrueType` for `glyf`), so reverting the emitter to outline glyphs -- which still extract, through a
8//! Type-3 `/ToUnicode` -- turns them red; [`restricted_face_falls_back_to_outlines`] is the converse, red
9//! if a face whose licence forbids embedding is embedded anyway. A tool that is not installed is reported
10//! and its test skipped, never passed silently.
11
12use oxedyne_fe2o3_austenite::compile;
13use oxedyne_fe2o3_austenite::emit::pdf;
14use oxedyne_fe2o3_austenite::font::ShapedText;
15use oxedyne_fe2o3_austenite::fonts;
16use oxedyne_fe2o3_austenite::ir::Sp;
17use oxedyne_fe2o3_austenite::page::{
18 Frame,
19 Page,
20 PageGeometry,
21 Placed,
22 PlacedKind,
23};
24
25use oxedyne_fe2o3_core::prelude::*;
26use oxedyne_fe2o3_font::{
27 face::Role,
28 font::Font,
29 shape::Dir,
30};
31
32use std::path::PathBuf;
33use std::process::Command;
34use std::sync::Arc;
35
36const DEJAVU: &[u8] = include_bytes!("../../fe2o3_font/fonts/DejaVuSans.ttf");
37
38/// Is `tool` on the path? When it is not, the caller skips with a note rather than passing unexamined.
39fn have(tool: &str) -> bool {
40 match Command::new(tool).arg("-v").output() {
41 Ok(_) => true,
42 Err(_) => {
43 eprintln!("SKIP: `{}` is not installed, so this PDF check cannot run.", tool);
44 false
45 },
46 }
47}
48
49/// Writes `bytes` to a file named `name` in the test scratch directory and returns its path.
50fn write(name: &str, bytes: &[u8]) -> Outcome<PathBuf> {
51 let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(name);
52 res!(std::fs::write(&path, bytes));
53 Ok(path)
54}
55
56fn run(tool: &str, args: &[&str]) -> Outcome<String> {
57 let out = res!(Command::new(tool).args(args).output());
58 if !out.status.success() {
59 return Err(err!("{} {:?} failed: {}", tool, args, String::from_utf8_lossy(&out.stderr); Test));
60 }
61 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
62}
63
64/// The text poppler extracts from the whole file.
65fn extract(path: &PathBuf) -> Outcome<String> {
66 run("pdftotext", &[&path.to_string_lossy(), "-"])
67}
68
69/// The rows of `pdffonts`, each as (name without subset tag, type, embedded, subset, has ToUnicode).
70fn font_rows(path: &PathBuf) -> Outcome<Vec<(String, String, bool, bool, bool)>> {
71 let out = res!(run("pdffonts", &[&path.to_string_lossy()]));
72 let mut rows = Vec::new();
73 for line in out.lines().skip(2) {
74 let cols: Vec<&str> = line.split_whitespace().collect();
75 // name, type words..., encoding, emb, sub, uni, object, generation
76 if cols.len() < 7 {
77 continue;
78 }
79 let n = cols.len();
80 let name = cols[0].split('+').last().unwrap_or("").to_string();
81 let kind = cols[1..n - 6].join(" ");
82 rows.push((name, kind, cols[n - 5] == "yes", cols[n - 4] == "yes", cols[n - 3] == "yes"));
83 }
84 Ok(rows)
85}
86
87/// Ghostscript interprets the whole file and stops on the first error: the nearest thing here to a
88/// validator, since it must parse every font program to render the pages.
89fn gs_clean(path: &PathBuf) -> Outcome<()> {
90 if !have("gs") {
91 return Ok(());
92 }
93 let out = res!(Command::new("gs")
94 .args(["-q", "-dNOPAUSE", "-dBATCH", "-dPDFSTOPONERROR", "-sDEVICE=nullpage"])
95 .arg(path)
96 .output());
97 let noise = fmt!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
98 assert!(out.status.success() && noise.trim().is_empty(),
99 "Ghostscript reports the PDF as faulty: {}", noise);
100 Ok(())
101}
102
103/// Compiles a sample through the real assemble/author/run path to one PDF.
104fn sample_pdf(name: &str) -> Outcome<Vec<u8>> {
105 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("samples").join(fmt!("{}.typ", name));
106 let assembled = res!(compile::assemble(
107 &path,
108 || Ok(Arc::new(res!(fonts::libertinus()))),
109 ));
110 let rendered = res!(compile::author_and_run(assembled));
111 pdf::render_document(&rendered.out.pages)
112}
113
114/// One A4 page carrying each `(font, text)` as a line of its own.
115fn lines_pdf(lines: Vec<(Arc<Font>, &str)>) -> Outcome<Vec<u8>> {
116 let mut frame = Frame::new();
117 for (k, (font, text)) in lines.into_iter().enumerate() {
118 let shaped = res!(ShapedText::new_with_font(font, Dir::Ltr, Sp::from_pt(12.0), text));
119 frame.push(Placed::new(
120 Sp::from_pt(60.0), Sp::from_pt(80.0 + 24.0 * k as f64), shaped.dims(), PlacedKind::Text(shaped)));
121 }
122 pdf::render_document(&[Page::new(1, PageGeometry::a4(), frame)])
123}
124
125/// Collapses runs of whitespace, so a comparison is about the words and not poppler's layout spacing.
126fn words(s: &str) -> String {
127 s.split_whitespace().collect::<Vec<_>>().join(" ")
128}
129
130#[test]
131fn a_sample_exports_selectable_text_in_embedded_subset_fonts() -> Outcome<()> {
132 if !have("pdftotext") || !have("pdffonts") {
133 return Ok(());
134 }
135 let path = res!(write("hierarchy.pdf", &res!(sample_pdf("hierarchy"))));
136 res!(gs_clean(&path));
137
138 let text = words(&res!(extract(&path)));
139 for want in [
140 "Field Notes on Structure",
141 "papyrus was brittle and cracked when folded;",
142 "ret the raw fibre until the cellulose loosens;", // "fi" is one ligature glyph
143 ] {
144 assert!(text.contains(want), "poppler did not extract {:?} from:\n{}", want, text);
145 }
146
147 let rows = res!(font_rows(&path));
148 assert!(!rows.is_empty(), "pdffonts lists no font at all");
149 for (name, kind, emb, sub, uni) in &rows {
150 assert_eq!(kind, "CID Type 0C", "{} is not an embedded CFF CIDFont", name);
151 assert!(*emb && *sub && *uni, "{} is not embedded, subset and mapped to Unicode", name);
152 }
153 for face in ["LibertinusSerif-Regular", "LibertinusSerif-Bold", "LibertinusSerif-Italic"] {
154 assert!(rows.iter().any(|r| r.0 == face), "{} is missing from {:?}", face, rows);
155 }
156 Ok(())
157}
158
159#[test]
160fn maths_embeds_its_own_font_and_extracts_its_symbols() -> Outcome<()> {
161 if !have("pdftotext") || !have("pdffonts") {
162 return Ok(());
163 }
164 let path = res!(write("maths.pdf", &res!(sample_pdf("maths"))));
165 res!(gs_clean(&path));
166 let rows = res!(font_rows(&path));
167 assert!(rows.iter().any(|r| r.0 == "NewCMMath-Regular" && r.1 == "CID Type 0C" && r.2 && r.4),
168 "the maths font is not embedded with a ToUnicode: {:?}", rows);
169 let text = res!(extract(&path));
170 // A mathematical italic a (U+1D44E) is a character outside the Basic Multilingual Plane, so this also
171 // exercises a surrogate pair in the CMap.
172 assert!(text.contains("\u{1D44E}"), "the maths italic a did not extract:\n{}", text);
173 assert!(text.contains("Pythagoras"), "the prose around the maths did not extract:\n{}", text);
174 Ok(())
175}
176
177#[test]
178fn ligatures_and_a_truetype_face_round_trip_through_copy() -> Outcome<()> {
179 if !have("pdftotext") || !have("pdffonts") {
180 return Ok(());
181 }
182 let serif = Arc::new(res!(Font::new(res!(std::fs::read(
183 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fonts").join("LibertinusSerif-Regular.otf"))))));
184 let dejavu = Arc::new(res!(Font::new(DEJAVU.to_vec())));
185
186 // Libertinus sets "ffi" and "fl" as single ligature glyphs; the CMap must expand each back to its
187 // letters, or a search for "office" misses the word.
188 let lig = res!(ShapedText::new_with_font(serif.clone(), Dir::Ltr, Sp::from_pt(12.0), "affluent office"));
189 assert!(lig.run().glyphs.len() < "affluent office".chars().count(),
190 "no ligature formed, so this test would prove nothing about ligature mapping");
191
192 let path = res!(write("lines.pdf", &res!(lines_pdf(vec![
193 (serif, "affluent office"),
194 (dejavu, "Grüße, Ωμέγα — naïve"),
195 ]))));
196 res!(gs_clean(&path));
197 let text = words(&res!(extract(&path)));
198 assert!(text.contains("affluent office"), "the ligatures did not extract as letters: {:?}", text);
199 assert!(text.contains("Grüße, Ωμέγα — naïve"), "the TrueType line did not extract: {:?}", text);
200
201 let rows = res!(font_rows(&path));
202 assert!(rows.iter().any(|r| r.0 == "DejaVuSans" && r.1 == "CID TrueType" && r.2 && r.3 && r.4),
203 "DejaVu Sans is not an embedded TrueType subset: {:?}", rows);
204 assert!(rows.iter().any(|r| r.0 == "LibertinusSerif-Regular" && r.1 == "CID Type 0C"),
205 "Libertinus is not an embedded CFF: {:?}", rows);
206 Ok(())
207}
208
209#[test]
210fn restricted_face_falls_back_to_outlines() -> Outcome<()> {
211 if !have("pdftotext") || !have("pdffonts") {
212 return Ok(());
213 }
214 // DejaVu with its OS/2 fsType set to 2, a restricted licence: the face must not be embedded, so its
215 // glyphs are drawn as Type-3 outlines -- and still extract, through that font's own ToUnicode.
216 let mut bytes = DEJAVU.to_vec();
217 let n = u16::from_be_bytes([bytes[4], bytes[5]]) as usize;
218 let mut patched = false;
219 for i in 0..n {
220 let rec = 12 + 16 * i;
221 if &bytes[rec..rec + 4] == b"OS/2" {
222 let off = u32::from_be_bytes([bytes[rec + 8], bytes[rec + 9], bytes[rec + 10], bytes[rec + 11]]) as usize;
223 bytes[off + 8] = 0;
224 bytes[off + 9] = 2;
225 patched = true;
226 }
227 }
228 assert!(patched, "DejaVu has no OS/2 table to restrict");
229 let font = Arc::new(res!(Font::new(bytes)));
230 let path = res!(write("restricted.pdf", &res!(lines_pdf(vec![(font, "Restricted licence")]))));
231 res!(gs_clean(&path));
232 let rows = res!(font_rows(&path));
233 assert!(rows.iter().all(|r| r.1 == "Type 3"), "a restricted face was embedded: {:?}", rows);
234 assert!(words(&res!(extract(&path))).contains("Restricted licence"), "the fallback text did not extract");
235 Ok(())
236}
237
238#[test]
239fn embedded_output_is_deterministic() -> Outcome<()> {
240 let a = res!(sample_pdf("maths"));
241 let b = res!(sample_pdf("maths"));
242 assert!(a == b, "two compiles of one sample differ");
243 // The streaming writer the binary uses must agree with the buffered one to the byte.
244 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("samples").join("maths.typ");
245 let assembled = res!(compile::assemble(
246 &path,
247 || Ok(Arc::new(res!(fonts::libertinus()))),
248 ));
249 let rendered = res!(compile::author_and_run(assembled));
250 let mut stream = res!(pdf::open_document(Vec::new(), rendered.out.pages.len()));
251 for page in &rendered.out.pages {
252 res!(pdf::write_page(&mut stream, page));
253 }
254 let c = res!(stream.finish());
255 assert!(a == c, "the streamed file differs from the buffered one");
256 Ok(())
257}
258
259#[test]
260fn role_faces_share_one_embedded_font_per_file() -> Outcome<()> {
261 if !have("pdffonts") {
262 return Ok(());
263 }
264 // Two independently loaded sets of the same files are one font per file in the PDF, not two.
265 let a = Arc::new(res!(fonts::libertinus()));
266 let b = Arc::new(res!(fonts::libertinus()));
267 let mut frame = Frame::new();
268 for (k, set) in [a, b].into_iter().enumerate() {
269 let s = res!(ShapedText::new(set, Role::Body, Dir::Ltr, Sp::from_pt(11.0), "Shared face"));
270 frame.push(Placed::new(Sp::from_pt(60.0), Sp::from_pt(80.0 + 20.0 * k as f64), s.dims(), PlacedKind::Text(s)));
271 }
272 let path = res!(write("shared.pdf", &res!(pdf::render_document(&[Page::new(1, PageGeometry::a4(), frame)]))));
273 let rows = res!(font_rows(&path));
274 assert_eq!(rows.len(), 1, "one file loaded twice became {} fonts: {:?}", rows.len(), rows);
275 assert_eq!(rows[0].1, "CID Type 0C", "the shared face is not embedded: {:?}", rows);
276 Ok(())
277}