| 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 | |
| 9 | use std::io::Read; |
| 10 | |
| 11 | const FONT: &[u8] = include_bytes!("fonts/LiberationSerif-Regular.ttf"); |
| 12 | |
| 13 | const 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 | |
| 18 | fn 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). |
| 31 | fn 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 | |
| 73 | fn find(hay: &[u8], needle: &[u8]) -> Option<usize> { |
| 74 | hay.windows(needle.len()).position(|w| w == needle) |
| 75 | } |
| 76 | |
| 77 | fn 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. |
| 82 | fn count_either(hay: &[u8], spaced: &[u8], tight: &[u8]) -> usize { |
| 83 | count(hay, spaced) + count(hay, tight) |
| 84 | } |
| 85 | |
| 86 | fn 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. |
| 92 | fn 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] |
| 98 | fn 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] |
| 147 | fn 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] |
| 155 | fn 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] |
| 179 | fn 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] |
| 195 | fn 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] |
| 205 | fn 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 | |