diff --git a/.github/workflows/pdf.yml b/.github/workflows/pdf.yml index 2880b831..e0d9fcf1 100644 --- a/.github/workflows/pdf.yml +++ b/.github/workflows/pdf.yml @@ -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 diff --git a/crates/pdf/README.md b/crates/pdf/README.md index 0af3d8b2..6f246ff3 100644 --- a/crates/pdf/README.md +++ b/crates/pdf/README.md @@ -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. diff --git a/crates/pdf/tests/upstream_reading.rs b/crates/pdf/tests/upstream_reading.rs index 778cc2b4..f203a7a9 100644 --- a/crates/pdf/tests/upstream_reading.rs +++ b/crates/pdf/tests/upstream_reading.rs @@ -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, } 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"), "•"); + } +} diff --git a/scripts/fetch-pdf-test-fonts.py b/scripts/fetch-pdf-test-fonts.py index e21a47a4..821a5e74 100644 --- a/scripts/fetch-pdf-test-fonts.py +++ b/scripts/fetch-pdf-test-fonts.py @@ -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) diff --git a/scripts/test-fetch-pdf-test-fonts.py b/scripts/test-fetch-pdf-test-fonts.py new file mode 100644 index 00000000..94531e62 --- /dev/null +++ b/scripts/test-fetch-pdf-test-fonts.py @@ -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()