test(pdf): pin extraction policy and retry fixtures (PDFR)

This commit is contained in:
skylar taylor-barrick
2026-10-13 13:24:54 -06:00
parent c07b3fada6
commit 89dcbb3060
5 changed files with 144 additions and 7 deletions
+4 -2
View File
@@ -3,9 +3,9 @@ name: Shared PDF
on:
push:
branches: [main]
paths: ["crates/pdf/**", "vendor/hayro*/**", "vendor/krilla/**", "scripts/test-pdf-backends.sh", "scripts/fetch-pdf-test-fonts.py", "Cargo.toml", "Cargo.lock", ".github/workflows/pdf.yml"]
paths: ["crates/pdf/**", "vendor/hayro*/**", "vendor/krilla/**", "scripts/test-pdf-backends.sh", "scripts/*fetch-pdf-test-fonts.py", "Cargo.toml", "Cargo.lock", ".github/workflows/pdf.yml"]
pull_request:
paths: ["crates/pdf/**", "vendor/hayro*/**", "vendor/krilla/**", "scripts/test-pdf-backends.sh", "scripts/fetch-pdf-test-fonts.py", "Cargo.toml", "Cargo.lock", ".github/workflows/pdf.yml"]
paths: ["crates/pdf/**", "vendor/hayro*/**", "vendor/krilla/**", "scripts/test-pdf-backends.sh", "scripts/*fetch-pdf-test-fonts.py", "Cargo.toml", "Cargo.lock", ".github/workflows/pdf.yml"]
workflow_dispatch:
permissions:
@@ -39,6 +39,7 @@ jobs:
- name: Provision licensed PDF test fonts
shell: bash
run: |
python scripts/test-fetch-pdf-test-fonts.py
python scripts/fetch-pdf-test-fonts.py "$RUNNER_TEMP/craft-pdf-test-fonts"
echo "CRAFT_PDF_TEST_FONT_DIR=$RUNNER_TEMP/craft-pdf-test-fonts" >> "$GITHUB_ENV"
- run: cargo fmt --check
@@ -75,6 +76,7 @@ jobs:
- name: Provision licensed PDF test fonts
shell: bash
run: |
python scripts/test-fetch-pdf-test-fonts.py
python scripts/fetch-pdf-test-fonts.py "$RUNNER_TEMP/craft-pdf-test-fonts"
echo "CRAFT_PDF_TEST_FONT_DIR=$RUNNER_TEMP/craft-pdf-test-fonts" >> "$GITHUB_ENV"
- name: Install the matching WASM test runner
+4 -1
View File
@@ -194,5 +194,8 @@ and enables the contracts on native and WASM. Locally, run
`python3 scripts/fetch-pdf-test-fonts.py /absolute/path/to/licensed-fonts` to provision
them. `tests/upstream_reading.rs` uses the same Inter fixture to verify TrueType
WinAnsi fallback with missing or partial ToUnicode maps, explicit Encoding and
Differences precedence, and a ToUnicode override. Without fixtures those reader
Differences precedence, and a ToUnicode override. The fallback changes extraction
only: existing nonsymbolic glyph selection can still choose StandardEncoding glyphs
or `.notdef`. Tests pin that distinction and the upstream WinAnsi unused-code bullet
mapping; fallback text is not proof of the original author's text. Without fixtures those reader
contracts are also compiled and explicitly ignored; provisioned CI executes them.
+38 -2
View File
@@ -16,6 +16,7 @@ const INTER: &[u8] = include_bytes!(concat!(env!("CRAFT_PDF_TEST_FONT_DIR"), "/I
struct TextDevice {
text: String,
embedded_glyphs: usize,
glyph_ids: Vec<u32>,
}
impl Device<'_> for TextDevice {
@@ -28,6 +29,7 @@ impl Device<'_> for TextDevice {
let Glyph::Outline(outline) = glyph else { panic!("expected an embedded TrueType font") };
assert!(outline.font_data().unwrap().data.as_ref().as_ref() == fixture_font(), "a fallback font must not satisfy this contract");
self.embedded_glyphs += 1;
self.glyph_ids.push(outline.glyph_id().to_u32());
match glyph.as_unicode() {
Some(BfString::Char(c)) => self.text.push(c),
Some(BfString::String(s)) => self.text.push_str(&s),
@@ -50,7 +52,7 @@ fn fixture_font() -> &'static [u8] {
}
}
fn encoded_text(flags: u8, encoding: &str, unicode_map: Option<&str>, codes: &[u8]) -> String {
fn interpreted_text(flags: u8, encoding: &str, unicode_map: Option<&str>, codes: &[u8]) -> TextDevice {
let font = fixture_font();
let mut content = b"BT /F1 14 Tf 1 0 0 1 20 200 Tm (".to_vec();
content.extend_from_slice(codes);
@@ -92,7 +94,11 @@ fn encoded_text(flags: u8, encoding: &str, unicode_map: Option<&str>, codes: &[u
let mut device = TextDevice::default();
craft_pdf::interpret_page(page, &mut context, &mut device).unwrap();
assert_eq!(device.embedded_glyphs, codes.len());
device.text
device
}
fn encoded_text(flags: u8, encoding: &str, unicode_map: Option<&str>, codes: &[u8]) -> String {
interpreted_text(flags, encoding, unicode_map, codes).text
}
#[cfg_attr(not(craft_pdf_unicode_fixtures), ignore = "requires hash-verified external fonts and craft_pdf_unicode_fixtures cfg")]
@@ -119,3 +125,33 @@ fn explicit_encoding_differences_and_to_unicode_override_the_fallback() {
assert_eq!(encoded_text(flags, differences, Some(map), b"\xe9"), "Ω");
}
}
#[cfg_attr(not(craft_pdf_unicode_fixtures), ignore = "requires hash-verified external fonts and craft_pdf_unicode_fixtures cfg")]
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn unicode_fallback_preserves_the_existing_nonsymbolic_glyph_selection() {
let fallback = interpreted_text(32, "", None, b"\xe9\xfc");
let standard = interpreted_text(32, "/Encoding /StandardEncoding", None, b"\xe9\xfc");
let ansi = interpreted_text(32, "/Encoding /WinAnsiEncoding", None, b"\xe9\xfc");
assert_eq!(fallback.text, "éü");
// The upstream port repairs extraction only. With no encoding, drawing still uses
// StandardEncoding: Oslash for 0xE9 and .notdef for 0xFC. Do not claim a rendering fix.
assert_eq!(fallback.glyph_ids, standard.glyph_ids);
assert_ne!(fallback.glyph_ids[0], 0);
assert_eq!(fallback.glyph_ids[1], 0);
assert_eq!(ansi.text, "éü");
assert!(ansi.glyph_ids.iter().all(|id| *id != 0));
assert_ne!(fallback.glyph_ids[0], ansi.glyph_ids[0]);
}
#[cfg_attr(not(craft_pdf_unicode_fixtures), ignore = "requires hash-verified external fonts and craft_pdf_unicode_fixtures cfg")]
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn unicode_fallback_uses_the_upstream_win_ansi_bullet_mapping() {
// The upstream fallback uses the complete WinAnsi mapping, including its unused-code
// bullet rule. This is a fallback policy, not proof of the original author's text.
for flags in [4, 32] {
assert_eq!(encoded_text(flags, "", None, b"\x81"), "•");
assert_eq!(encoded_text(flags, "/Encoding /WinAnsiEncoding", None, b"\x81"), "•");
}
}
+21 -2
View File
@@ -5,9 +5,29 @@ import argparse
import hashlib
import json
from pathlib import Path
import time
import urllib.error
import urllib.request
def fetch(url):
for attempt in range(3):
try:
with urllib.request.urlopen(url, timeout=60) as response:
return response.read(16 * 1024 * 1024 + 1)
except urllib.error.HTTPError as error:
if error.code not in (408, 429) and not 500 <= error.code <= 599:
raise
if attempt == 2:
raise
error.close()
except (urllib.error.URLError, TimeoutError, ConnectionError):
if attempt == 2:
raise
time.sleep(attempt + 1)
raise RuntimeError("fixture download retries exhausted")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("destination", type=Path)
@@ -26,8 +46,7 @@ def main():
if path.is_file() and hashlib.sha256(path.read_bytes()).hexdigest() == digest:
print(f"Verified {name}")
continue
with urllib.request.urlopen(url, timeout=60) as response:
data = response.read(16 * 1024 * 1024 + 1)
data = fetch(url)
if len(data) > 16 * 1024 * 1024 or hashlib.sha256(data).hexdigest() != digest:
raise ValueError(f"fixture size or SHA-256 mismatch: {name}")
path.write_bytes(data)
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Exercise fixture-download retries without network access."""
import importlib.util
import io
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
import urllib.error
sys.dont_write_bytecode = True
spec = importlib.util.spec_from_file_location("fetch_pdf_fonts", Path(__file__).with_name("fetch-pdf-test-fonts.py"))
fetcher = importlib.util.module_from_spec(spec)
spec.loader.exec_module(fetcher)
URL = "https://example.invalid/font.ttf"
class FetchTests(unittest.TestCase):
def test_success_needs_no_retry(self):
with patch.object(fetcher.urllib.request, "urlopen", return_value=io.BytesIO(b"font")) as request:
with patch.object(fetcher.time, "sleep") as sleep:
self.assertEqual(fetcher.fetch(URL), b"font")
request.assert_called_once_with(URL, timeout=60)
sleep.assert_not_called()
def test_transient_failures_retry_then_succeed(self):
errors = [
urllib.error.HTTPError(URL, 429, "rate limit", {}, None),
urllib.error.HTTPError(URL, 503, "unavailable", {}, None),
urllib.error.URLError("temporary DNS failure"),
TimeoutError("timed out"),
ConnectionResetError("reset"),
]
for error in errors:
if isinstance(error, urllib.error.HTTPError):
self.addCleanup(error.close)
with self.subTest(error=error):
with patch.object(fetcher.urllib.request, "urlopen", side_effect=[error, io.BytesIO(b"font")]) as request:
with patch.object(fetcher.time, "sleep") as sleep:
self.assertEqual(fetcher.fetch(URL), b"font")
self.assertEqual(request.call_count, 2)
sleep.assert_called_once_with(1)
def test_permanent_http_error_is_not_retried(self):
error = urllib.error.HTTPError(URL, 404, "missing", {}, None)
self.addCleanup(error.close)
with patch.object(fetcher.urllib.request, "urlopen", side_effect=error) as request:
with patch.object(fetcher.time, "sleep") as sleep:
with self.assertRaises(urllib.error.HTTPError):
fetcher.fetch(URL)
self.assertEqual(request.call_count, 1)
sleep.assert_not_called()
def test_transient_failure_stops_after_three_attempts(self):
error = urllib.error.URLError("unavailable")
with patch.object(fetcher.urllib.request, "urlopen", side_effect=error) as request:
with patch.object(fetcher.time, "sleep") as sleep:
with self.assertRaises(urllib.error.URLError):
fetcher.fetch(URL)
self.assertEqual(request.call_count, 3)
self.assertEqual([call.args[0] for call in sleep.call_args_list], [1, 2])
def test_response_read_remains_bounded(self):
class Response(io.BytesIO):
def read(self, size=-1):
self.size = size
return super().read(size)
response = Response(b"font")
with patch.object(fetcher.urllib.request, "urlopen", return_value=response):
self.assertEqual(fetcher.fetch(URL), b"font")
self.assertEqual(response.size, 16 * 1024 * 1024 + 1)
if __name__ == "__main__":
unittest.main()