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 | |
| 12 | use oxedyne_fe2o3_austenite::compile; |
| 13 | use oxedyne_fe2o3_austenite::emit::pdf; |
| 14 | use oxedyne_fe2o3_austenite::font::ShapedText; |
| 15 | use oxedyne_fe2o3_austenite::fonts; |
| 16 | use oxedyne_fe2o3_austenite::ir::Sp; |
| 17 | use oxedyne_fe2o3_austenite::page::{ |
| 18 | Frame, |
| 19 | Page, |
| 20 | PageGeometry, |
| 21 | Placed, |
| 22 | PlacedKind, |
| 23 | }; |
| 24 | |
| 25 | use oxedyne_fe2o3_core::prelude::*; |
| 26 | use oxedyne_fe2o3_font::{ |
| 27 | face::Role, |
| 28 | font::Font, |
| 29 | shape::Dir, |
| 30 | }; |
| 31 | |
| 32 | use std::path::PathBuf; |
| 33 | use std::process::Command; |
| 34 | use std::sync::Arc; |
| 35 | |
| 36 | const 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. |
| 39 | fn 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. |
| 50 | fn 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 | |
| 56 | fn 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. |
| 65 | fn 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). |
| 70 | fn 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. |
| 89 | fn 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. |
| 104 | fn 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. |
| 115 | fn 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. |
| 126 | fn words(s: &str) -> String { |
| 127 | s.split_whitespace().collect::<Vec<_>>().join(" ") |
| 128 | } |
| 129 | |
| 130 | #[test] |
| 131 | fn 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] |
| 160 | fn 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] |
| 178 | fn 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] |
| 210 | fn 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] |
| 239 | fn 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] |
| 260 | fn 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 | } |