mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-07 12:13:20 +00:00
test(pdf): pin extraction policy and retry fixtures (PDFR)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"), "•");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user