oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

1//! Native proof that the module produces a PDF with the text as embedded
2//! glyphs from the font it was given, not as paths.
3//!
4//! The assertions grep the PDF's bytes rather than parsing it, so they
5//! tolerate both spellings of a dictionary entry: pdf-writer 0.12 (which
6//! svg2pdf used) wrote `/Type /Page`, and 0.15 (which krilla uses) writes
7//! `/Type/Page`. The object is the property under test; the space is not.
8
9use std::io::Read;
10
11const FONT: &[u8] = include_bytes!("fonts/LiberationSerif-Regular.ttf");
12
13const SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080">
14 <rect x="100" y="100" width="800" height="300" fill="#1e5aa8"/>
15 <text x="120" y="600" font-family="Liberation Serif" font-size="96" fill="#111">Hello slides</text>
16</svg>"##;
17
18fn frames(items: &[&[u8]]) -> Vec<u8> {
19 let mut out = Vec::new();
20 for it in items {
21 out.extend_from_slice(&(it.len() as u32).to_be_bytes());
22 out.extend_from_slice(it);
23 }
24 out
25}
26
27/// Inflates every FlateDecode stream in the PDF and returns the PDF with
28/// those streams replaced by their plain contents, so text operators and
29/// font dictionaries can be grepped for. Streams are located by their
30/// dictionary's direct `/Length` (pdf-writer always writes one).
31fn inflate_streams(pdf: &[u8]) -> Vec<u8> {
32 let mut out = Vec::new();
33 let mut pos = 0;
34 while let Some(i) = find(&pdf[pos..], b">>\nstream\n") {
35 let dict_end = pos + i;
36 let start = dict_end + b">>\nstream\n".len();
37 // The stream's own dictionary starts at the enclosing "N 0 obj"
38 // header, not at the last "<<" (that could be a nested dict).
39 let dict_start = pdf[..dict_end]
40 .windows(6)
41 .rposition(|w| w == b" 0 obj")
42 .unwrap_or(0);
43 let dict = &pdf[dict_start..dict_end];
44 let len = find(dict, b"/Length ")
45 .map(|j| {
46 let digits: String = dict[j + 8..]
47 .iter()
48 .take_while(|b| b.is_ascii_digit())
49 .map(|&b| b as char)
50 .collect();
51 digits.parse::<usize>().expect("/Length")
52 })
53 .expect("stream dictionary without /Length");
54 let end = start + len;
55 out.extend_from_slice(&pdf[pos..start]);
56 let raw = &pdf[start..end];
57 if find(dict, b"FlateDecode").is_some() {
58 let mut z = flate2::read::ZlibDecoder::new(raw);
59 let mut plain = Vec::new();
60 match z.read_to_end(&mut plain) {
61 Ok(_) => out.extend_from_slice(&plain),
62 Err(_) => out.extend_from_slice(raw),
63 }
64 } else {
65 out.extend_from_slice(raw);
66 }
67 pos = end;
68 }
69 out.extend_from_slice(&pdf[pos..]);
70 out
71}
72
73fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
74 hay.windows(needle.len()).position(|w| w == needle)
75}
76
77fn count(hay: &[u8], needle: &[u8]) -> usize {
78 hay.windows(needle.len()).filter(|w| *w == needle).count()
79}
80
81/// Both spellings of a `/Key /Value` entry. See the module comment.
82fn count_either(hay: &[u8], spaced: &[u8], tight: &[u8]) -> usize {
83 count(hay, spaced) + count(hay, tight)
84}
85
86fn find_either(hay: &[u8], spaced: &[u8], tight: &[u8]) -> bool {
87 find(hay, spaced).is_some() || find(hay, tight).is_some()
88}
89
90/// Page objects, less the one page-tree node whose `/Type /Pages` also
91/// matches the `/Type /Page` prefix.
92fn page_count(pdf: &[u8]) -> usize {
93 count_either(pdf, b"/Type /Page", b"/Type/Page")
94 - count_either(pdf, b"/Type /Pages", b"/Type/Pages")
95}
96
97#[test]
98fn renders_text_as_embedded_glyphs() {
99 let pdf = slides_pdf::render(&[SVG.as_bytes()], &[FONT]).expect("render");
100 assert!(pdf.starts_with(b"%PDF-"), "no PDF header");
101
102 // Page count: /Type /Page objects less the /Type /Pages tree node.
103 let pages = page_count(&pdf);
104 assert_eq!(pages, 1, "expected one page");
105 assert!(find(&pdf, b"/Count 1").is_some());
106 assert!(
107 find_either(
108 &pdf,
109 b"/MediaBox [0 0 1920 1080]",
110 b"/MediaBox[0 0 1920 1080]"
111 ),
112 "page is 1920x1080 pt"
113 );
114
115 // An embedded font program (TrueType -> FontFile2; CFF -> FontFile3).
116 assert!(
117 find(&pdf, b"/FontFile2").is_some() || find(&pdf, b"/FontFile3").is_some(),
118 "no embedded font program"
119 );
120 assert!(
121 find_either(&pdf, b"/Type /Font", b"/Type/Font"),
122 "no /Font dictionary"
123 );
124 assert!(
125 find(&pdf, b"LiberationSerif").is_some(),
126 "font name not Liberation Serif"
127 );
128
129 // Text drawn with a text-showing operator, not as filled paths.
130 let plain = inflate_streams(&pdf);
131 let has_show = find(&plain, b"Tj").is_some() || find(&plain, b"TJ").is_some();
132 assert!(has_show, "no Tj/TJ operator in any content stream");
133 assert!(
134 find(&plain, b"BT").is_some() && find(&plain, b"Tf").is_some(),
135 "no BT/Tf"
136 );
137
138 // ToUnicode so the text extracts as "Hello slides".
139 assert!(find(&pdf, b"/ToUnicode").is_some(), "no ToUnicode CMap");
140
141 std::fs::create_dir_all("target").ok();
142 std::fs::write("target/spike.pdf", &pdf).ok();
143 eprintln!("PDF {} bytes, {} pages", pdf.len(), pages);
144}
145
146#[test]
147fn two_svgs_make_two_pages() {
148 let pdf = slides_pdf::render(&[SVG.as_bytes(), SVG.as_bytes()], &[FONT]).expect("render");
149 let pages = page_count(&pdf);
150 assert_eq!(pages, 2);
151 assert!(find(&pdf, b"/Count 2").is_some());
152}
153
154#[test]
155fn abi_frames_round_trip() {
156 let svgs = frames(&[SVG.as_bytes()]);
157 let fonts = frames(&[FONT]);
158 assert_eq!(slides_pdf::abi::render_frames(&svgs, &fonts), 0);
159 assert!(slides_pdf::abi::slides_pdf_out_len() > 0);
160 assert_eq!(slides_pdf::abi::slides_pdf_err_len(), 0);
161
162 // A truncated frame is a refusal with a message, not a panic.
163 let bad = &svgs[..svgs.len() - 3];
164 assert_eq!(
165 slides_pdf::abi::render_frames(bad, &fonts),
166 slides_pdf::abi::ERR_RENDER
167 );
168 assert_eq!(slides_pdf::abi::slides_pdf_out_len(), 0);
169 assert!(slides_pdf::abi::slides_pdf_err_len() > 0);
170
171 // No pages is a refusal too.
172 assert_eq!(
173 slides_pdf::abi::render_frames(&[], &fonts),
174 slides_pdf::abi::ERR_RENDER
175 );
176}
177
178#[test]
179fn a_null_zero_length_pair_is_no_frames_not_a_dereference() {
180 // The host sends (0, 0) for "no frames". Natively there is no linear
181 // memory to bound-check against, so this exercises exactly the path
182 // that must not build a slice over a null pointer: no pages is a
183 // refusal with a message, and the fonts side reads as empty.
184 // SAFETY: (0, 0) is the documented "nothing" pair; nothing is read.
185 let rc = unsafe { slides_pdf::abi::slides_pdf_render(0, 0, 0, 0) };
186 assert_eq!(rc, slides_pdf::abi::ERR_RENDER);
187 assert!(slides_pdf::abi::slides_pdf_err_len() > 0);
188 assert_eq!(slides_pdf::abi::slides_pdf_out_len(), 0);
189 // Reading an empty result into an empty buffer is 0, not a copy.
190 // SAFETY: a zero-length destination is never written.
191 assert_eq!(unsafe { slides_pdf::abi::slides_pdf_out_read(0, 0) }, 0);
192}
193
194#[test]
195fn text_with_no_fonts_at_all_still_renders_the_shapes() {
196 // A face the host did not send: usvg drops the text with a log
197 // warning, never substitutes and never errors. The shapes are still
198 // a page.
199 let pdf = slides_pdf::render(&[SVG.as_bytes()], &[]).expect("render");
200 assert_eq!(page_count(&pdf), 1);
201 assert!(find(&pdf, b"/FontFile2").is_none());
202}
203
204#[test]
205fn raster_image_is_embedded() {
206 // A 2x2 opaque red PNG, so the `image` feature is exercised end to end.
207 let png = "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEElEQVR4nGP4z8AARAwQCgAf7gP9i18U1AAAAABJRU5ErkJggg==";
208 let svg = format!(
209 r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1920" height="1080">
210 <image x="0" y="0" width="400" height="400" xlink:href="data:image/png;base64,{png}"/>
211</svg>"##
212 );
213 let pdf = slides_pdf::render(&[svg.as_bytes()], &[FONT]).expect("render");
214 assert!(
215 find_either(&pdf, b"/Subtype /Image", b"/Subtype/Image"),
216 "no image XObject in the PDF"
217 );
218}
219