Files
pdfcraft/crates/render/src/raster.rs
T
ef9b553f55 M2: kanji that Adobe-Japan1 maps to a variation sequence draw in non-embedded fonts (#662)
Adobe-Japan1-UCS2 maps the JIS X 0208 forms of the kanji that
JIS X 0213:2004 redrew to a variation sequence (CID 1247 is 噂 U+E0100).
hayro only looked up single characters in a substitute font, so a
non-embedded Japanese font drew 噂, 逢, 溢, 祇, 箸 and over 200 other
common kanji as unrelated glyphs. A character with a variation selector,
from Adobe-Japan1-UCS2 or a /ToUnicode, now draws the substitute's glyph
for that form when its cmap has one, else its glyph for the character.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 22:05:06 -04:00

3646 lines
191 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Background page rasterization.
//!
//! A `RenderPool` parses lazily once and shares that immutable parser between workers. Render
//! caches stay worker-local. Requests carry a caller tag that is echoed back.
//!
//! **Robustness:** every page render runs under `catch_unwind`. A panic inside the renderer on a
//! malformed page yields an error and permanently retires the pool's shared parser generation.
//! Workers discard its caches/results and fall back to private parsers. There is no shared
//! rebuild loop, and a timed-out shared initialization is never started again in that pool.
use std::panic::{AssertUnwindSafe, catch_unwind};
#[cfg(not(target_arch = "wasm32"))]
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::SyncSender;
#[cfg(not(target_arch = "wasm32"))]
use std::sync::mpsc::{Receiver, sync_channel};
mod work_queue;
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use work_queue::{ResultReceiver, WorkQueue};
use hayro::hayro_interpret::InterpreterSettings;
use hayro::hayro_interpret::font::{FontData, FontQuery};
use hayro::hayro_interpret::hayro_cmap::CidFamily;
use hayro::hayro_syntax::Pdf;
use hayro::vello_cpu::color::palette::css::WHITE;
use hayro::{RenderCache, RenderSettings, render_into, render_size};
use crate::Pixels;
/// Monotonic-ish timer that is safe on wasm32 (where `std::time::Instant` panics).
#[derive(Clone, Copy)]
struct Stopwatch(#[cfg(not(target_arch = "wasm32"))] std::time::Instant);
impl Stopwatch {
fn start() -> Self {
#[cfg(not(target_arch = "wasm32"))]
return Self(std::time::Instant::now());
#[cfg(target_arch = "wasm32")]
return Self();
}
fn millis(self) -> u32 {
#[cfg(not(target_arch = "wasm32"))]
return self.0.elapsed().as_millis() as u32;
#[cfg(target_arch = "wasm32")]
return 0;
}
}
/// Per-document rendering configuration.
#[derive(Clone, Debug, Default)]
pub struct RenderConfig {
/// User or owner password for encrypted documents.
pub password: Option<Arc<str>>,
/// Layer (optional content group) visibility overrides: (object number, generation, visible).
pub layers: Arc<Vec<(i32, i32, bool)>>,
/// View ▸ Hide all comments: markup annotations aren't drawn (fields and links still are).
pub hide_comments: bool,
}
impl RenderConfig {
fn settings(&self) -> InterpreterSettings {
let standard = InterpreterSettings::default().font_resolver;
InterpreterSettings {
ocg_overrides: self.layers.clone(),
hide_comments: self.hide_comments,
font_resolver: Arc::new(move |query| japanese_fallback(query).or_else(|| standard(query))),
..InterpreterSettings::default()
}
}
}
/// A Japanese face from craft-fonts for a CID font of the Adobe-Japan1 collection that the PDF
/// doesn't embed (`HeiseiMin-W3`, `KozGoPro-Medium`, …). hayro's own substitutes for fonts that
/// aren't embedded are the Latin standard 14, so such text drew nothing. `None` for every other
/// font, and when PdfCraft was built without craft-fonts. Only Japanese: the pinned craft-fonts
/// has no other CJK faces, and its later Chinese face is Noto CJK, which AGENTS.md §1.1 rules out.
fn japanese_fallback(query: &FontQuery) -> Option<(FontData, u32)> {
let FontQuery::Fallback(f) = query else { return None };
if f.character_collection.as_ref()?.family != CidFamily::AdobeJapan1 {
return None;
}
let face = match japanese_face(f.post_script_name.as_deref().unwrap_or_default(), f.is_serif, f.is_bold || f.font_weight >= 600) {
// BIZ UDMincho before the document face (Shippori Mincho): it covers half-width katakana
// (U+FF61–U+FF9F), which Shippori Mincho lacks, and a character the substitute lacks is
// drawn as .notdef.
JapaneseFace::Mincho => pdfcraft_fonts::ui_japanese_fonts()
.into_iter()
.find(|c| c.family == "BIZ UDMincho" && c.style == "Regular")
.or_else(pdfcraft_fonts::document_japanese_font),
JapaneseFace::Gothic { bold } => {
let faces = pdfcraft_fonts::ui_japanese_fonts();
let style = if bold { "Bold" } else { "Regular" };
faces.iter().find(|c| c.family == "BIZ UDPGothic" && c.style == style).or(faces.first()).copied()
}
}?;
Some((Arc::new(face.bytes), 0))
}
#[derive(Debug, PartialEq)]
enum JapaneseFace {
Mincho,
Gothic { bold: bool },
}
/// Which kind of Japanese face stands in for the font named `name`: Mincho names (`HeiseiMin`,
/// `KozMin`, `Ryumin`, `MS-Mincho`) a serif Mincho; Gothic names (`…Gothic…`, `HeiseiKakuGo`,
/// `KozGo`, `…Maru…`) a sans Gothic; any other name by the font descriptor's serif flag.
fn japanese_face(name: &str, serif: bool, bold: bool) -> JapaneseFace {
let name = name.to_ascii_lowercase();
if name.contains("min") {
JapaneseFace::Mincho
} else if ["goth", "kakugo", "kozgo", "kaku", "maru"].iter().any(|k| name.contains(k)) || !serif {
JapaneseFace::Gothic { bold }
} else {
JapaneseFace::Mincho
}
}
/// Hard cap on a rendered side, to bound memory at extreme zoom levels (tiling arrives in M3.3).
pub const MAX_SIDE: f32 = 8192.0;
/// Hard cap on rendered pixels per page (~64 MP ≈ 256 MB RGBA).
pub const MAX_PIXELS: f32 = 64.0e6;
/// What a request asks for.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
pub enum RequestKind {
/// A raster of the page.
#[default]
Pixels,
/// The page's text layer (glyphs with Unicode and boxes); `scale` is ignored.
Text,
}
/// A region of a scaled page, in device pixels (for tiled rendering of large pages).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Tile {
pub x: u32,
pub y: u32,
pub w: u32,
pub h: u32,
}
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub struct RenderRequest {
pub page: usize,
pub kind: RequestKind,
/// Render only this region (device pixels at `scale`). `None` renders the whole page.
pub tile: Option<Tile>,
/// Device pixels per PDF point.
pub scale: f32,
/// Caller-defined tag, echoed back (used for zoom generations / thumbnail vs page).
pub tag: u64,
}
#[derive(Debug)]
pub struct RenderedPage {
pub request: RenderRequest,
pub width: u32,
pub height: u32,
/// Premultiplied RGBA8, row-major. Empty when `error` is set.
pub rgba: Pixels,
/// Why the page could not be rendered (renderer panic, empty page box, …).
pub error: Option<String>,
/// For `RequestKind::Text`.
pub text: Option<Arc<crate::text::PageText>>,
pub millis: u32,
}
/// Clamp a requested scale so the output respects `MAX_SIDE` and `MAX_PIXELS`.
pub fn effective_scale(width_pt: f32, height_pt: f32, scale: f32) -> f32 {
let (w, h) = (width_pt.max(1.0), height_pt.max(1.0));
let by_side = MAX_SIDE / w.max(h);
let by_area = (MAX_PIXELS / (w * h)).sqrt();
// The caps always win: a floor here once let a 934-million-point-wide page (a fuzzed file)
// render 9 million pixels wide. Only guard against a zero or non-finite scale.
let capped = scale.min(by_side).min(by_area);
if capped.is_finite() && capped > 0.0 { capped } else { by_side.min(by_area).max(f32::MIN_POSITIVE) }
}
/// Device pixels that cover `pt` points at `scale`: rounded up, as poppler's `pdftoppm` does, so
/// the last partial row or column of a page is drawn (anti-aliased against the background) rather
/// than cut off. Float noise up to 1/100 px is not rounded up, so 612 pt at 150 dpi stays 1275 px.
/// At least 1; saturates instead of overflowing.
pub fn device_pixels(pt: f32, scale: f32) -> u32 {
let px = pt * scale - 0.01;
if px.is_finite() { px.ceil().max(1.0) as u32 } else { 1 }
}
/// Render one page with a caller-owned parser and cache. Panics inside the renderer are caught
/// and reported as `Err((message, panicked))`.
type Output = (u32, u32, Pixels, Option<Arc<crate::text::PageText>>);
fn render_page<'a>(pdf: &'a Pdf, cache: &RenderCache<'a>, settings: &InterpreterSettings, req: RenderRequest) -> Result<Output, (String, bool)> {
if req.kind == RequestKind::Text {
return match catch_unwind(AssertUnwindSafe(|| crate::text::extract_page(pdf, req.page, settings))) {
Ok(Some(t)) => Ok((0, 0, Pixels::default(), Some(Arc::new(t)))),
Ok(None) => Err((format!("page {} does not exist", req.page + 1), false)),
Err(panic) => Err((format!("text extraction crashed on page {}: {}", req.page + 1, panic_message(&panic)), true)),
};
}
let result = catch_unwind(AssertUnwindSafe(|| {
let pages = pdf.pages();
let page = pages.get(req.page).ok_or_else(|| format!("page {} does not exist", req.page + 1))?;
let (w, h) = page.render_dimensions();
if !(w.is_finite() && h.is_finite()) || w < 0.5 || h < 0.5 {
return Err(format!("page {} has an empty or invalid page box ({w}×{h} pt)", req.page + 1));
}
let rs = match req.tile {
// Tiles are bounded by construction, so the page itself may be arbitrarily large.
Some(t) => {
let scale = req.scale.clamp(0.01, 400.0);
let (tw, th) = (t.w.clamp(1, 4096), t.h.clamp(1, 4096));
RenderSettings {
x_scale: scale,
y_scale: scale,
width: Some(tw as u16),
height: Some(th as u16),
x_offset: t.x as f32,
y_offset: t.y as f32,
bg_color: WHITE,
}
}
None => {
let scale = effective_scale(w, h, req.scale);
// hayro floors the size when none is given, losing the partial edge pixels. The
// scale keeps each side within MAX_SIDE (give or take float noise), so it fits u16.
let side = |pt: f32| device_pixels(pt, scale).min(MAX_SIDE as u32) as u16;
RenderSettings { x_scale: scale, y_scale: scale, width: Some(side(w)), height: Some(side(h)), bg_color: WHITE, ..Default::default() }
}
};
// Rendered straight into a buffer of whole pixels, which the GUI takes over as is.
let (w, h) = render_size(page, &rs);
let mut pixels = Pixels::zeroed(w.into(), h.into()).ok_or_else(|| format!("page {} is too large to render", req.page + 1))?;
render_into(page, cache, settings, &rs, pixels.bytes_mut());
Ok((w.into(), h.into(), pixels, None))
}));
match result {
Ok(Ok(v)) => Ok(v),
Ok(Err(e)) => Err((e, false)),
Err(panic) => Err((format!("renderer crashed on page {}: {}", req.page + 1, panic_message(&panic)), true)),
}
}
fn finish(req: RenderRequest, start: Stopwatch, r: Result<Output, (String, bool)>) -> RenderedPage {
let millis = start.millis();
match r {
Ok((width, height, rgba, text)) => RenderedPage { request: req, width, height, rgba, error: None, text, millis },
Err((e, _)) => RenderedPage { request: req, width: 0, height: 0, rgba: Pixels::default(), error: Some(e), text: None, millis },
}
}
/// A single-threaded renderer over one parsed document (used by the CLI and tests).
pub struct PageRenderer {
bytes: Arc<Vec<u8>>,
config: RenderConfig,
pdf: Option<Pdf>,
settings: InterpreterSettings,
}
impl PageRenderer {
pub fn new(bytes: Arc<Vec<u8>>, config: RenderConfig) -> Self {
let pdf = parse(&bytes, config.password.as_deref());
let settings = config.settings();
Self { bytes, config, pdf, settings }
}
pub fn page_count(&self) -> usize {
self.pdf.as_ref().map(|p| p.pages().len()).unwrap_or(0)
}
/// Render one page. Never panics.
pub fn render(&mut self, req: RenderRequest) -> RenderedPage {
let start = Stopwatch::start();
let Some(pdf) = self.pdf.as_ref() else { return finish(req, start, Err(("the document could not be parsed".into(), false))) };
let cache = RenderCache::new();
let r = render_page(pdf, &cache, &self.settings, req);
if matches!(r, Err((_, true))) {
self.pdf = parse(&self.bytes, self.config.password.as_deref());
}
finish(req, start, r)
}
}
fn parse(bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Option<Pdf> {
catch_unwind(AssertUnwindSafe(|| Pdf::new_with_password(bytes.clone(), password.unwrap_or("")).ok())).ok().flatten()
}
pub(crate) fn panic_message(p: &Box<dyn std::any::Any + Send>) -> String {
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "unknown panic".into())
}
/// How long one page may render before the pool gives up on it (the watchdog).
pub const STUCK_AFTER: std::time::Duration = std::time::Duration::from_secs(20);
#[cfg(not(target_arch = "wasm32"))]
#[derive(Clone, Copy, PartialEq, Eq)]
enum BusyStage {
/// Parsing the document, shared or private. Not timed: a huge or damaged file is slow to
/// open, not stuck, and giving up on it would fail every page of the pool. (Parsing has
/// its own limits.) Rendering is timed from the moment parsing ends.
Parsing,
SharedRender,
Private,
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Clone, Copy)]
struct Busy {
request: RenderRequest,
since: std::time::Instant,
stage: BusyStage,
}
/// State shared by the pool and its workers.
#[derive(Default)]
struct Shared {
/// Desired requests, active jobs and bounded completed results share one deduplication key.
queue: Arc<WorkQueue>,
/// Per worker id: the request it is rendering and since when.
#[cfg(not(target_arch = "wasm32"))]
busy: Mutex<Vec<Option<Busy>>>,
/// Pages (and request kinds) the watchdog gave up on: answered with an error at once, so a
/// pathological page cannot trap every worker in turn.
stuck: Mutex<std::collections::HashSet<(usize, RequestKind)>>,
/// Per worker id: set to stop that worker's render at its next content operator once nobody
/// can receive its answer (the pool was dropped, or the watchdog gave up on the render).
/// Lock `busy` first when holding both.
stop: Mutex<Vec<Arc<std::sync::atomic::AtomicBool>>>,
/// Test hook: make one page slow.
#[cfg(test)]
slow_page: Mutex<Option<(usize, std::time::Duration)>>,
/// Test hook: count parser initializations, including failed parses.
#[cfg(all(test, not(target_arch = "wasm32")))]
parses: std::sync::atomic::AtomicUsize,
#[cfg(not(target_arch = "wasm32"))]
parser: PoolParser,
#[cfg(all(test, not(target_arch = "wasm32")))]
panic_page: Mutex<Option<usize>>,
#[cfg(all(test, not(target_arch = "wasm32")))]
slow_parse: Mutex<Option<std::time::Duration>>,
#[cfg(all(test, not(target_arch = "wasm32")))]
render_started: std::sync::atomic::AtomicUsize,
#[cfg(all(test, not(target_arch = "wasm32")))]
shared_render_delay: Mutex<Option<std::time::Duration>>,
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[cfg(not(target_arch = "wasm32"))]
fn set_busy_stage(shared: &Shared, id: usize, stage: BusyStage) -> bool {
let mut slots = lock(&shared.busy);
let Some(Some(busy)) = slots.get_mut(id) else { return false };
if busy.stage != stage {
busy.stage = stage;
busy.since = std::time::Instant::now();
}
true
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Default)]
enum ParserState {
#[default]
Empty,
Loading,
Ready(Option<Arc<Pdf>>),
/// A page failed after initialization: isolate subsequent work per worker.
Private,
/// Retired before it became ready (defensive: parsing is not timed, and only a shared
/// render retires the parser). Retrying it on every worker cannot help.
Failed,
}
#[cfg(not(target_arch = "wasm32"))]
struct PoolParser {
state: Mutex<ParserState>,
ready: std::sync::Condvar,
valid: Arc<AtomicBool>,
}
#[cfg(not(target_arch = "wasm32"))]
impl Default for PoolParser {
fn default() -> Self {
Self { state: Mutex::default(), ready: std::sync::Condvar::new(), valid: Arc::new(AtomicBool::new(true)) }
}
}
#[cfg(not(target_arch = "wasm32"))]
impl PoolParser {
/// `None` means use a private parser. `Some(None)` is a terminal parse failure.
fn acquire(&self, load: impl FnOnce() -> Option<Pdf>, cancelled: impl Fn() -> bool) -> Option<Option<Arc<Pdf>>> {
let mut state = lock(&self.state);
loop {
if cancelled() {
return Some(None);
}
match &*state {
ParserState::Ready(pdf) => return Some(pdf.clone()),
ParserState::Private => return None,
ParserState::Failed => return Some(None),
ParserState::Loading => {
// Drop never joins a parser. A waiting worker must still notice
// its pool's disconnected wake channel while another thread is
// stuck in initialization and cannot signal this condition.
state = self.ready.wait_timeout(state, std::time::Duration::from_millis(50)).unwrap_or_else(|p| p.into_inner()).0;
}
ParserState::Empty => {
*state = ParserState::Loading;
break;
}
}
}
drop(state);
// Parsing cannot hold the lifecycle mutex: watchdog/drop must remain nonblocking.
// Even a panic in initialization publishes one terminal failure and wakes waiters.
let pdf = catch_unwind(AssertUnwindSafe(load)).ok().flatten().map(Arc::new);
let mut state = lock(&self.state);
let result = match &*state {
ParserState::Loading => {
*state = ParserState::Ready(pdf.clone());
Some(pdf)
}
ParserState::Private => None,
_ => Some(None), // the watchdog retired initialization while it ran
};
self.ready.notify_all();
result
}
fn abandon(&self) {
self.retire();
}
/// After a shared render failed or timed out: later work parses privately, per worker
/// (for the rest of the pool's life: the old one-parser-per-worker memory use). A parser
/// that never became ready stays failed.
fn retire(&self) {
let mut state = lock(&self.state);
self.valid.store(false, Ordering::Release);
*state = match &*state {
ParserState::Loading | ParserState::Empty | ParserState::Failed => ParserState::Failed,
_ => ParserState::Private,
};
self.ready.notify_all();
}
}
/// Renders pages on worker threads, most urgent request first.
///
/// **Watchdog:** a render running longer than `stuck_after` (default [`STUCK_AFTER`]) is given
/// up on. A timed-out *shared* render retires that parser and retries still-desired work
/// privately: the page may only have been waiting behind another page's decode. A private
/// timeout is reported as an error for that page and the page is not attempted again. Threads
/// cannot be killed: the stuck worker is told to stop at its next content operator, exits when
/// its render returns, and its late result is dropped; a single long operator, such as decoding
/// a huge image, still runs to its end. At most `threads` replacements are started per pool, so
/// a document full of pathological pages cannot spawn threads without bound. If every worker is
/// retired after that budget is spent, pending and future requests fail explicitly instead of
/// waiting forever.
pub struct RenderPool {
shared: Arc<Shared>,
wake: Mutex<Vec<SyncSender<()>>>,
results: ResultReceiver,
results_tx: Arc<WorkQueue>,
bytes: Arc<Vec<u8>>,
config: RenderConfig,
_workers: Mutex<Vec<JoinHandle<()>>>,
stuck_after: std::time::Duration,
replacements_left: Mutex<usize>,
retired_workers: Mutex<usize>,
/// Used when threads are unavailable (wasm32 without atomics, or spawn failure): requests are
/// rendered on the calling thread inside `try_recv`, one per call.
inline: Option<std::cell::RefCell<PageRenderer>>,
}
impl RenderPool {
pub fn new(bytes: Arc<Vec<u8>>, threads: usize, config: RenderConfig) -> Self {
Self::with_mode(bytes, threads, config, false)
}
fn with_mode(bytes: Arc<Vec<u8>>, threads: usize, config: RenderConfig, inline: bool) -> Self {
let threads = if inline || cfg!(target_arch = "wasm32") { 0 } else { threads.max(1) };
let shared = Arc::new(Shared::default());
let results_tx = shared.queue.clone();
let results = ResultReceiver(shared.queue.clone());
let mut pool = Self {
shared,
wake: Mutex::new(Vec::new()),
results,
results_tx,
bytes: bytes.clone(),
config: config.clone(),
_workers: Mutex::new(Vec::new()),
stuck_after: STUCK_AFTER,
replacements_left: Mutex::new(threads),
retired_workers: Mutex::new(0),
inline: None,
};
for _ in 0..threads {
pool.spawn_worker();
}
if lock(&pool._workers).is_empty() {
pool.inline = Some(std::cell::RefCell::new(PageRenderer::new(bytes, config)));
}
pool
}
/// Start one worker thread; returns whether it started.
fn spawn_worker(&self) -> bool {
#[cfg(target_arch = "wasm32")]
return false;
#[cfg(not(target_arch = "wasm32"))]
{
let (id, stop) = {
let mut busy = lock(&self.shared.busy);
busy.push(None);
let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
lock(&self.shared.stop).push(stop.clone());
(busy.len() - 1, stop)
};
let (wtx, wrx) = sync_channel::<()>(1);
let (shared, out, bytes, config) = (self.shared.clone(), self.results_tx.clone(), self.bytes.clone(), self.config.clone());
match std::thread::Builder::new().name(format!("pdfcraft-render-{id}")).spawn(move || worker(id, bytes, config, shared, wrx, out, stop)) {
Ok(h) => {
lock(&self.wake).push(wtx);
lock(&self._workers).push(h);
true
}
Err(e) => {
log::error!("could not spawn render worker {id}: {e}");
false
}
}
}
}
/// A pool without worker threads: renders inside `try_recv` (the web path; also used in tests).
pub fn new_inline(bytes: Arc<Vec<u8>>, config: RenderConfig) -> Self {
Self::with_mode(bytes, 0, config, true)
}
/// `true` when rendering happens on the caller's thread (no worker threads available).
pub fn is_inline(&self) -> bool {
self.inline.is_some()
}
/// Change the watchdog limit (tests and benchmarks).
pub fn set_stuck_after(&mut self, limit: std::time::Duration) {
self.stuck_after = limit;
}
/// Replace desired work (most urgent first), retaining matching in-flight/completed jobs.
/// Obsolete results are released before they reach the caller. In-flight work is not killed.
///
/// The contract: a result is delivered only while its request is in the latest queue.
/// A caller still waiting for a page (or text, or a thumbnail) must keep listing it in
/// every call, or the finished result is dropped to save memory.
pub fn set_queue(&self, requests: Vec<RenderRequest>) {
self.shared.queue.replace(requests);
self.wake_workers();
}
fn wake_workers(&self) {
for w in lock(&self.wake).iter() {
// One pending wakeup is sufficient; UI polling must not grow wake channels.
let _ = w.try_send(());
}
}
pub fn try_recv(&self) -> Option<RenderedPage> {
if let Some(r) = &self.inline {
let req = self.shared.queue.pop()?;
let page = r.borrow_mut().render(req);
self.shared.queue.finish_inline(req);
return Some(page);
}
self.watchdog();
let page = self.results.try_recv().ok();
// Consuming a result releases byte/count capacity. An invalid parser generation
// can also requeue work without returning a page, so wake in both cases.
self.wake_workers();
page.or_else(|| {
let workers = lock(&self._workers).len();
if workers == 0 || *lock(&self.retired_workers) < workers {
return None;
}
// Count successful handles, not reserved busy slots: thread spawning
// can fail. No worker remains able to service these pending requests.
let request = self.shared.queue.pop()?;
self.shared.queue.finish_inline(request);
Some(RenderedPage {
request,
width: 0,
height: 0,
rgba: Pixels::default(),
error: Some("all render workers exceeded their time limits; close and reopen the document to try again".into()),
text: None,
millis: 0,
})
})
}
/// Give up on renders that exceeded `stuck_after` (see the type docs).
fn watchdog(&self) {
#[cfg(not(target_arch = "wasm32"))]
{
let mut gave_up = Vec::new();
{
let mut busy = lock(&self.shared.busy);
// Lock order: `busy`, then `stop`.
let stop = lock(&self.shared.stop);
for (id, slot) in busy.iter_mut().enumerate() {
if let Some(busy) = *slot
&& busy.stage != BusyStage::Parsing
&& busy.since.elapsed() > self.stuck_after
{
*slot = None; // the worker sees this and exits when it returns
// Told to stop at its next operator; its replacement retries the page.
if let Some(s) = stop.get(id) {
s.store(true, std::sync::atomic::Ordering::Relaxed);
}
gave_up.push(busy);
}
}
}
{
let mut retired = lock(&self.retired_workers);
*retired = retired.saturating_add(gave_up.len());
}
for busy in gave_up {
let req = busy.request;
self.shared.parser.retire();
if busy.stage == BusyStage::SharedRender {
// The shared cold-decode gate can make a healthy page wait
// behind another page. Only its private retry can attribute
// the timeout to this page; do not blacklist it here.
self.shared.queue.retry(req);
} else {
lock(&self.shared.stuck).insert((req.page, req.kind));
let what = if req.kind == RequestKind::Text { "text extraction for page" } else { "page" };
let error = format!(
"{what} {} took longer than {:.0} s and was skipped; the page may be damaged or extremely complex",
req.page + 1,
self.stuck_after.as_secs_f32().max(1.0)
);
self.shared.queue.abandon(RenderedPage {
request: req,
width: 0,
height: 0,
rgba: Pixels::default(),
error: Some(error),
text: None,
millis: 0,
});
}
let mut left = lock(&self.replacements_left);
if *left > 0 && self.spawn_worker() {
*left -= 1;
}
}
}
}
}
impl Drop for RenderPool {
fn drop(&mut self) {
for s in lock(&self.shared.stop).iter() {
s.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
}
/// A worker's interpreter settings: the document's, stopping once `stop` is set.
#[cfg(not(target_arch = "wasm32"))]
fn worker_settings(config: &RenderConfig, stop: &Arc<std::sync::atomic::AtomicBool>) -> InterpreterSettings {
InterpreterSettings { cancelled: Some(stop.clone()), ..config.settings() }
}
#[cfg(not(target_arch = "wasm32"))]
fn worker(
id: usize,
bytes: Arc<Vec<u8>>,
config: RenderConfig,
shared: Arc<Shared>,
wake: Receiver<()>,
out: Arc<WorkQueue>,
stop: Arc<std::sync::atomic::AtomicBool>,
) {
let settings = worker_settings(&config, &stop);
// A local cell keeps Pdf borrows used by RenderCache stable. Drop both on an
// epoch change/panic before publishing an error or taking another request.
loop {
let reset_result = {
let parsed = std::cell::OnceCell::new();
loop {
if parsed.get().is_some_and(|(_, _, shared_generation)| *shared_generation) && !shared.parser.valid.load(Ordering::Acquire) {
break None;
}
let next = shared.queue.pop();
let Some(req) = next else {
if wake.recv().is_err() {
return; // pool dropped
}
continue;
};
if shared.queue.cancel_obsolete(req) {
continue;
}
if lock(&shared.stuck).contains(&(req.page, req.kind)) {
let error = format!("page {} was skipped earlier because rendering failed or took too long", req.page + 1);
let page = RenderedPage { request: req, width: 0, height: 0, rgba: Pixels::default(), error: Some(error), text: None, millis: 0 };
if out.send_valid(page, None).is_err() {
return;
}
continue;
}
let start = Stopwatch::start();
let stage = parsed.get().map_or(
BusyStage::Parsing,
|(_, _, shared_generation)| {
if *shared_generation { BusyStage::SharedRender } else { BusyStage::Private }
},
);
if let Some(slot) = lock(&shared.busy).get_mut(id) {
*slot = Some(Busy { request: req, since: std::time::Instant::now(), stage });
}
let (pdf, cache, shared_generation) = parsed.get_or_init(|| {
let load = || {
#[cfg(test)]
{
shared.parses.fetch_add(1, Ordering::Relaxed);
let delay = *lock(&shared.slow_parse);
if let Some(delay) = delay {
std::thread::sleep(delay);
}
}
parse(&bytes, config.password.as_deref())
};
match shared.parser.acquire(load, || matches!(wake.try_recv(), Err(std::sync::mpsc::TryRecvError::Disconnected))) {
Some(pdf) => {
let shared_generation = pdf.is_some();
(pdf, RenderCache::new(), shared_generation)
}
None => {
let pdf = if set_busy_stage(&shared, id, BusyStage::Parsing) { load().map(Arc::new) } else { None };
(pdf, RenderCache::new(), false)
}
}
});
// Acquisition may have waited or parsed privately after shared
// fallback. Retire before any render work if the watchdog gave
// this request to a replacement while initialization ran.
let stage = if *shared_generation { BusyStage::SharedRender } else { BusyStage::Private };
if !set_busy_stage(&shared, id, stage) {
return;
}
if shared.queue.cancel_obsolete(req) {
if lock(&shared.busy).get_mut(id).is_none_or(|slot| slot.take().is_none()) {
return;
}
continue;
}
if *shared_generation && !shared.parser.valid.load(Ordering::Acquire) {
if lock(&shared.busy).get_mut(id).is_none_or(|slot| slot.take().is_none()) {
return;
}
break Some((finish(req, start, Err(("the shared parser was retired".into(), false))), Some(shared.parser.valid.clone())));
}
#[cfg(test)]
{
shared.render_started.fetch_add(1, Ordering::Relaxed);
let shared_delay = *lock(&shared.shared_render_delay);
if *shared_generation && let Some(delay) = shared_delay {
std::thread::sleep(delay);
}
// Copy out first: a guard held in the `if let` would block the other workers.
let slow = *lock(&shared.slow_page);
if let Some((page, delay)) = slow
&& page == req.page
{
std::thread::sleep(delay);
}
}
let r = catch_unwind(AssertUnwindSafe(|| {
#[cfg(test)]
if *lock(&shared.panic_page) == Some(req.page) {
panic!("injected page panic");
}
match pdf {
Some(pdf) => render_page(pdf, cache, &settings, req),
None => Err(("the document could not be parsed".into(), false)),
}
}))
.unwrap_or_else(|panic| Err((format!("renderer crashed on page {}: {}", req.page + 1, panic_message(&panic)), true)));
// If the watchdog cleared our slot meanwhile, it already answered for this request
// and started a replacement: drop the late result and retire.
let abandoned = lock(&shared.busy).get_mut(id).is_none_or(|slot| slot.take().is_none());
if abandoned {
return;
}
let panicked = matches!(r, Err((_, true)));
if panicked {
shared.parser.abandon();
lock(&shared.stuck).insert((req.page, req.kind));
break Some((finish(req, start, r), None));
}
let valid = shared_generation.then(|| shared.parser.valid.clone());
if *shared_generation && !shared.parser.valid.load(Ordering::Acquire) {
break Some((finish(req, start, r), valid));
}
if out.send_valid(finish(req, start, r), valid).is_err() {
return;
}
}
}; // drop the parser and its borrowing cache before delivering reset_result
if let Some((page, valid)) = reset_result
&& out.send_valid(page, valid).is_err()
{
return;
}
}
}
#[cfg(test)]
mod tests {
#[cfg(not(target_arch = "wasm32"))]
fn receive_before_deadline(pool: &RenderPool) -> RenderedPage {
// Generous: a loaded CI machine runs many render tests at once.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
if let Some(page) = pool.try_recv() {
return page;
}
assert!(std::time::Instant::now() < deadline, "no worker result");
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn shared_parser_is_lazy_and_initializes_once_under_contention() {
use std::sync::{Barrier, atomic::AtomicUsize};
let parser = Arc::new(PoolParser::default());
let loads = Arc::new(AtomicUsize::new(0));
let barrier = Arc::new(Barrier::new(16));
assert_eq!(loads.load(Ordering::Relaxed), 0);
let jobs: Vec<_> = (0..16)
.map(|_| {
let (parser, loads, barrier) = (parser.clone(), loads.clone(), barrier.clone());
std::thread::spawn(move || {
barrier.wait();
parser
.acquire(
|| {
loads.fetch_add(1, Ordering::Relaxed);
Pdf::new(ONE_PAGE.to_vec()).ok()
},
|| false,
)
.unwrap()
.unwrap()
})
})
.collect();
let pdfs: Vec<_> = jobs.into_iter().map(|job| job.join().unwrap()).collect();
assert_eq!(loads.load(Ordering::Relaxed), 1);
assert!(pdfs.iter().all(|pdf| Arc::ptr_eq(pdf, &pdfs[0])));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn initializer_panic_publishes_one_failure_and_wakes_waiters() {
use std::sync::{Barrier, atomic::AtomicUsize};
let parser = Arc::new(PoolParser::default());
let loads = Arc::new(AtomicUsize::new(0));
let barrier = Arc::new(Barrier::new(8));
let jobs: Vec<_> = (0..8)
.map(|_| {
let (parser, loads, barrier) = (parser.clone(), loads.clone(), barrier.clone());
std::thread::spawn(move || {
barrier.wait();
assert!(
parser
.acquire(
|| {
loads.fetch_add(1, Ordering::Relaxed);
panic!("injected parser panic");
},
|| false
)
.unwrap()
.is_none()
);
})
})
.collect();
for job in jobs {
job.join().unwrap();
}
assert_eq!(loads.load(Ordering::Relaxed), 1);
assert!(parser.acquire(|| panic!("must not retry"), || false).unwrap().is_none());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn waiting_worker_can_cancel_before_initializer_returns() {
use std::{
sync::{atomic::AtomicUsize, mpsc},
time::Duration,
};
let parser = Arc::new(PoolParser::default());
let (started_tx, started_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel();
let initializer = {
let parser = parser.clone();
std::thread::spawn(move || {
parser.acquire(
|| {
started_tx.send(()).unwrap();
release_rx.recv().unwrap();
Pdf::new(ONE_PAGE.to_vec()).ok()
},
|| false,
)
})
};
started_rx.recv_timeout(Duration::from_secs(2)).unwrap();
let cancelled = Arc::new(AtomicBool::new(false));
let checks = Arc::new(AtomicUsize::new(0));
let (done_tx, done_rx) = mpsc::channel();
let waiter = {
let (parser, cancelled, checks) = (parser.clone(), cancelled.clone(), checks.clone());
std::thread::spawn(move || {
let result = parser.acquire(
|| panic!("must not initialize twice"),
|| {
checks.fetch_add(1, Ordering::Relaxed);
cancelled.load(Ordering::Acquire)
},
);
done_tx.send(result.unwrap().is_none()).unwrap();
})
};
let deadline = std::time::Instant::now() + Duration::from_secs(2);
while checks.load(Ordering::Relaxed) == 0 {
assert!(std::time::Instant::now() < deadline);
std::thread::yield_now();
}
cancelled.store(true, Ordering::Release);
assert!(done_rx.recv_timeout(Duration::from_secs(2)).unwrap());
waiter.join().unwrap();
release_tx.send(()).unwrap();
assert!(initializer.join().unwrap().unwrap().is_some());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn page_panic_retires_shared_generation_and_healthy_pages_use_private_parsers() {
let pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 3, RenderConfig::default());
*lock(&pool.shared.panic_page) = Some(0);
*lock(&pool.shared.slow_page) = Some((1, std::time::Duration::from_millis(50)));
pool.set_queue(vec![
RenderRequest { page: 0, scale: 1.0, ..Default::default() },
RenderRequest { page: 1, scale: 1.0, ..Default::default() },
]);
let mut pages = [receive_before_deadline(&pool), receive_before_deadline(&pool)];
pages.sort_by_key(|page| page.request.page);
assert!(pages[0].error.as_deref().is_some_and(|message| message.contains("crashed")));
assert!(pages[1].error.is_none(), "{:?}", pages[1].error);
assert_eq!((pages[1].width, pages[1].height), (100, 50));
assert!(!pool.shared.parser.valid.load(Ordering::Acquire));
assert!(matches!(*lock(&pool.shared.parser.state), ParserState::Private));
assert!((2..=4).contains(&pool.shared.parses.load(Ordering::Relaxed)));
let before = pool.shared.parses.load(Ordering::Relaxed);
pool.set_queue(vec![RenderRequest { page: 0, tag: 1, scale: 1.0, ..Default::default() }]);
assert!(receive_before_deadline(&pool).error.is_some());
assert_eq!(pool.shared.parses.load(Ordering::Relaxed), before, "a failed page does not trigger repeated parsing");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn results_queued_before_invalidation_are_retried_privately() {
let pool = RenderPool::new(Arc::new(ONE_PAGE.to_vec()), 1, RenderConfig::default());
pool.set_queue(vec![RenderRequest { tag: 77, scale: 1.0, ..Default::default() }]);
let mut old = pool.results.recv_timeout(std::time::Duration::from_secs(8)).unwrap();
old.width = 999; // a stale surface must never reach the caller
pool.shared.parser.abandon();
pool.results_tx.send_valid(old, Some(pool.shared.parser.valid.clone())).unwrap();
let page = receive_before_deadline(&pool);
assert_eq!((page.width, page.height, page.request.tag), (100, 50, 77));
assert!(page.error.is_none());
assert_eq!(pool.shared.parses.load(Ordering::Relaxed), 2);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_slow_parse_is_not_a_timeout_and_its_pages_render() {
use std::time::{Duration, Instant};
// Shared, then the private parsers used after a shared failure.
for private in [false, true] {
let mut pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 2, RenderConfig::default());
if private {
*lock(&pool.shared.parser.state) = ParserState::Private;
pool.shared.parser.valid.store(false, Ordering::Release);
}
// The parse takes 2 s against a 10 ms limit: the watchdog must leave it alone.
*lock(&pool.shared.slow_parse) = Some(Duration::from_secs(2));
pool.set_stuck_after(Duration::from_millis(10));
pool.set_queue(vec![
RenderRequest { page: 0, scale: 1.0, tag: 60, ..Default::default() },
RenderRequest { page: 1, scale: 1.0, tag: 61, ..Default::default() },
]);
let started = Instant::now();
while pool.shared.parses.load(Ordering::Relaxed) == 0 {
assert!(started.elapsed() < Duration::from_secs(5), "parsing did not start");
std::thread::sleep(Duration::from_millis(2));
}
// Run the watchdog well past its limit while the parse is still sleeping.
for _ in 0..20 {
assert!(pool.try_recv().is_none(), "nothing may be given up on while parsing");
std::thread::sleep(Duration::from_millis(10));
}
assert!(lock(&pool.shared.stuck).is_empty());
assert_eq!(*lock(&pool.retired_workers), 0);
assert!(!matches!(*lock(&pool.shared.parser.state), ParserState::Failed));
// Rendering is timed from the end of parsing: give it a limit it can't miss.
pool.set_stuck_after(Duration::from_secs(60));
let mut pages = [receive_before_deadline(&pool), receive_before_deadline(&pool)];
pages.sort_by_key(|page| page.request.page);
for (page, tag) in pages.iter().zip([60, 61]) {
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!((page.width, page.height, page.request.tag), (100, 50, tag));
}
if !private {
assert_eq!(pool.shared.parses.load(Ordering::Relaxed), 1, "one shared parse");
assert!(matches!(*lock(&pool.shared.parser.state), ParserState::Ready(Some(_))));
}
}
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn shared_timeouts_retry_healthy_pages_privately_before_blacklisting() {
use std::time::Duration;
let mut pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 2, RenderConfig::default());
// Limits and delays far apart, so a loaded machine can't flip the outcome: a healthy
// page renders privately well within 1 s, the injected delays are 3 s and 6 s.
pool.set_stuck_after(Duration::from_secs(1));
// Both shared workers wait as if behind the same cold decode. Only page0
// remains pathological in an independent parser; page1 must recover.
*lock(&pool.shared.shared_render_delay) = Some(Duration::from_secs(3));
*lock(&pool.shared.slow_page) = Some((0, Duration::from_secs(6)));
pool.set_queue(vec![
RenderRequest { page: 0, scale: 1.0, tag: 40, ..Default::default() },
RenderRequest { page: 1, scale: 1.0, tag: 41, ..Default::default() },
]);
let mut pages = [receive_before_deadline(&pool), receive_before_deadline(&pool)];
pages.sort_by_key(|page| page.request.page);
assert!(pages[0].error.as_deref().is_some_and(|message| message.contains("took longer")));
assert!(pages[1].error.is_none(), "{:?}", pages[1].error);
assert_eq!((pages[1].width, pages[1].height, pages[1].request.tag), (100, 50, 41));
assert!(lock(&pool.shared.stuck).contains(&(0, RequestKind::Pixels)));
assert!(!lock(&pool.shared.stuck).contains(&(1, RequestKind::Pixels)));
assert!((2..=3).contains(&pool.shared.parses.load(Ordering::Relaxed)), "one shared parser and one or two reusable private parsers");
assert!(lock(&pool._workers).len() <= 4, "the existing replacement cap is unchanged");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn exhausted_private_retry_budget_reports_pending_and_future_requests() {
use std::time::Duration;
let mut pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default());
pool.set_stuck_after(Duration::from_millis(300));
*lock(&pool.shared.slow_page) = Some((0, Duration::from_secs(3)));
let bad = RenderRequest { page: 0, scale: 1.0, tag: 50, ..Default::default() };
let healthy = RenderRequest { page: 1, scale: 1.0, tag: 51, ..Default::default() };
pool.set_queue(vec![bad, healthy]);
let bad_result = receive_before_deadline(&pool);
assert_eq!(bad_result.request, bad);
assert!(bad_result.error.as_deref().is_some_and(|message| message.contains("took longer")));
let exhausted = receive_before_deadline(&pool);
assert_eq!(exhausted.request, healthy);
assert!(exhausted.error.as_deref().is_some_and(|message| message.contains("all render workers") && message.contains("reopen")));
assert_eq!(pool.shared.parses.load(Ordering::Relaxed), 2);
assert_eq!(lock(&pool._workers).len(), 2, "no threads beyond the original replacement cap");
assert!(!lock(&pool.shared.stuck).contains(&(1, RequestKind::Pixels)), "the healthy page itself is not blacklisted");
let later = RenderRequest { tag: 52, ..healthy };
pool.set_queue(vec![later]);
let result = receive_before_deadline(&pool);
assert_eq!(result.request, later);
assert!(result.error.as_deref().is_some_and(|message| message.contains("all render workers")));
assert_eq!(pool.shared.parses.load(Ordering::Relaxed), 2, "exhaustion performs no parser work on the consumer");
let workers = std::mem::take(&mut *lock(&pool._workers));
for worker in workers {
worker.join().unwrap();
}
assert!(pool.try_recv().is_none());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn replacing_a_request_during_initialization_skips_its_obsolete_render() {
use std::time::{Duration, Instant};
let pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default());
*lock(&pool.shared.slow_parse) = Some(Duration::from_millis(200));
*lock(&pool.shared.slow_page) = Some((0, Duration::from_secs(10)));
pool.set_queue(vec![RenderRequest { page: 0, scale: 1.0, tag: 10, ..Default::default() }]);
let deadline = Instant::now() + Duration::from_secs(2);
while pool.shared.parses.load(Ordering::Relaxed) == 0 {
assert!(Instant::now() < deadline, "first request did not start parsing");
std::thread::sleep(Duration::from_millis(2));
}
let replaced = Instant::now();
pool.set_queue(vec![RenderRequest { page: 1, scale: 1.0, tag: 11, ..Default::default() }]);
let page = receive_before_deadline(&pool);
assert_eq!((page.request.page, page.request.tag), (1, 11));
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!((page.width, page.height), (100, 50));
assert!(replaced.elapsed() < Duration::from_secs(8), "obsolete page (10 s) delayed the replacement request");
assert_eq!(pool.shared.parses.load(Ordering::Relaxed), 1, "the useful parser is retained");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn idle_and_skipped_workers_release_source_without_parsing() {
for skipped in [false, true] {
let shared = Arc::new(Shared::default());
if skipped {
let req = RenderRequest { page: 0, ..Default::default() };
shared.queue.replace(vec![req]);
lock(&shared.stuck).insert((req.page, req.kind));
}
let bytes = Arc::new(ONE_PAGE.to_vec());
let source = Arc::downgrade(&bytes);
let (wake_tx, wake_rx) = sync_channel(1);
let out = shared.queue.clone();
let results = ResultReceiver(shared.queue.clone());
// A dropped pool disconnects this channel. Running synchronously makes this check
// deterministic even when a worker starts after the pool has already been replaced.
drop(wake_tx);
worker(0, bytes, RenderConfig::default(), shared.clone(), wake_rx, out, Arc::new(std::sync::atomic::AtomicBool::new(false)));
assert_eq!(shared.parses.load(std::sync::atomic::Ordering::Relaxed), 0);
assert!(source.upgrade().is_none(), "retired workers release their source bytes");
if skipped {
assert!(results.try_recv().unwrap().error.as_deref().is_some_and(|e| e.contains("skipped earlier")));
}
assert!(results.try_recv().is_err());
}
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn worker_reuses_parser_and_releases_source_after_rendering() {
let bytes = Arc::new(ONE_PAGE.to_vec());
let source = Arc::downgrade(&bytes);
let pool = RenderPool::new(bytes, 1, RenderConfig::default());
assert!(!pool.is_inline(), "the lifecycle check needs a native worker");
for (tag, kind) in [RequestKind::Pixels, RequestKind::Text, RequestKind::Pixels].into_iter().enumerate() {
pool.set_queue(vec![RenderRequest { page: 0, kind, scale: 1.0, tag: tag as u64, ..Default::default() }]);
let page = pool.results.recv_timeout(std::time::Duration::from_secs(8)).expect("worker result");
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!(page.request.tag, tag as u64);
match kind {
RequestKind::Pixels => assert_eq!((page.width, page.height), (100, 50)),
RequestKind::Text => assert!(page.text.is_some()),
}
assert_eq!(pool.shared.parses.load(std::sync::atomic::Ordering::Relaxed), 1, "requests reuse one parser");
}
drop(pool);
let until = std::time::Instant::now() + std::time::Duration::from_secs(8);
while source.upgrade().is_some() {
assert!(std::time::Instant::now() < until, "dropped pools retire their workers and release the source");
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
/// Public-API regressions run in normal workspace CI; dependency unit tests do not.
fn function_limits_calculator_eval(program: &str) -> Option<Vec<f32>> {
use hayro::hayro_interpret::Function;
use hayro_syntax::object::{FromBytes, Object};
let data = format!("<< /FunctionType 4 /Domain [] /Length {} >> stream\n{program}\nendstream", program.len());
let object = Object::from_bytes(data.as_bytes()).unwrap();
Function::new(&object)?.eval(Default::default()).map(|values| values.to_vec())
}
#[test]
fn function_limits_idiv_zero_is_refused() {
assert!(function_limits_calculator_eval("{ 1 0 idiv }").is_none());
}
#[test]
fn function_limits_idiv_overflow_is_refused() {
assert!(function_limits_calculator_eval("{ -2147483648 -1 idiv }").is_none());
}
#[test]
fn function_limits_large_logical_shifts_discard_all_bits() {
for shift in [32, -32, 2147483647, -2147483648] {
assert_eq!(function_limits_calculator_eval(&format!("{{ 7 {shift} bitshift }}")), Some(vec![0.0]));
}
assert_eq!(function_limits_calculator_eval("{ 1073741824 1 bitshift }"), Some(vec![-2147483648.0]));
assert_eq!(function_limits_calculator_eval("{ -2147483648 -1 bitshift }"), Some(vec![1073741824.0]));
}
#[test]
fn function_limits_rotation_accepts_the_most_negative_integer() {
assert_eq!(function_limits_calculator_eval("{ 1 2 3 3 -2147483648 roll }"), Some(vec![3.0, 1.0, 2.0]));
}
#[test]
fn function_limits_large_indices_are_refused() {
assert!(function_limits_calculator_eval("{ 1 4294967295 index }").is_none());
}
#[test]
fn function_limits_operand_stack_boundary() {
assert_eq!(function_limits_calculator_eval(&format!("{{ 1 {} }}", "dup ".repeat(63))).unwrap().len(), 64);
assert!(function_limits_calculator_eval(&format!("{{ 1 {} }}", "dup ".repeat(64))).is_none());
}
#[test]
fn function_limits_calculator_arithmetic_and_depth() {
for (program, expected) in [
("{ -5 2 idiv }", vec![-2.0]),
("{ 7 3 bitshift }", vec![56.0]),
("{ 142 -3 bitshift }", vec![17.0]),
("{ 1 2 3 3 -1 roll }", vec![2.0, 3.0, 1.0]),
] {
assert_eq!(function_limits_calculator_eval(program), Some(expected));
}
let mut program = "{ 0 }".to_owned();
for _ in 1..64 {
program = format!("{{ true {program} if }}");
}
assert_eq!(function_limits_calculator_eval(&program), Some(vec![0.0]));
assert!(function_limits_calculator_eval(&format!("{{ true {program} if }}")).is_none());
}
#[test]
fn function_limits_calculator_work_boundary() {
// With no loops, each admitted operator can execute at most once. The exact parse
// boundary can be evaluated, while construction rejects the next operator.
assert_eq!(function_limits_calculator_eval(&format!("{{ {} }}", "0 pop ".repeat(5000))), Some(vec![]));
assert!(function_limits_calculator_eval(&format!("{{ {} 0 }}", "0 pop ".repeat(5000))).is_none());
}
#[test]
fn function_limits_calculator_work_counts_unselected_branches() {
// 9,996 branch operators + two procedure openings + true + ifelse = 10,000 tokens.
let branch = "0 pop ".repeat(2499);
assert_eq!(function_limits_calculator_eval(&format!("{{ true {{ {branch} }} {{ {branch} }} ifelse }}")), Some(vec![]));
assert!(function_limits_calculator_eval(&format!("{{ true {{ {branch} }} {{ {branch} 0 }} ifelse }}")).is_none());
}
#[test]
fn function_limits_public_construction_work_and_depth() {
use hayro::hayro_interpret::Function;
use hayro_syntax::object::{FromBytes, Object};
let mut data = "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>".to_owned();
for _ in 1..64 {
data = format!("<< /FunctionType 3 /Domain [0 1] /Functions [{data}] /Bounds [] /Encode [0 1] >>");
}
let object = Object::from_bytes(data.as_bytes()).unwrap();
assert_eq!(Function::new(&object).unwrap().eval([0.5].into_iter().collect()).unwrap().as_slice(), &[0.5]);
let over = format!("<< /FunctionType 3 /Domain [0 1] /Functions [{data}] /Bounds [] /Encode [0 1] >>");
assert!(Function::new(&Object::from_bytes(over.as_bytes()).unwrap()).is_none());
// One root plus 10,000 leaves is one node past the common budget. This input remains
// below a megabyte and does not attempt excessive recursion or an allocation failure.
let leaf = "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> ";
for children in [9999, 10_000] {
let data = format!(
"<< /FunctionType 3 /Domain [0 1] /Functions [{}] /Bounds [{}] /Encode [{}] >>",
leaf.repeat(children),
"0.5 ".repeat(children - 1),
"0 1 ".repeat(children)
);
let object = Object::from_bytes(data.as_bytes()).unwrap();
assert_eq!(Function::new(&object).is_some(), children == 9999);
}
}
#[test]
fn function_limits_stitching_cycles_are_refused_and_shared_children_work() {
use hayro::hayro_interpret::Function;
use hayro_syntax::object::{Object, ObjectIdentifier};
let pdf = |functions: &str| {
hayro_syntax::Pdf::new(
format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 20 20] >> endobj\n\
{functions}\ntrailer << /Root 1 0 R >>\n%%EOF"
)
.into_bytes(),
)
.unwrap()
};
for functions in [
"4 0 obj << /FunctionType 3 /Domain [0 1] /Functions [4 0 R] /Bounds [] /Encode [0 1] >> endobj",
"4 0 obj << /FunctionType 3 /Domain [0 1] /Functions [5 0 R] /Bounds [] /Encode [0 1] >> endobj\n5 0 obj << /FunctionType 3 /Domain [0 1] /Functions [4 0 R] /Bounds [] /Encode [0 1] >> endobj",
"4 0 obj << /FunctionType 3 /Domain [0 1] /Functions [] /Bounds [] /Encode [] >> endobj",
"4 0 obj << /FunctionType 3 /Domain [0 1] /Functions [99 0 R] /Bounds [] /Encode [0 1] >> endobj",
"4 0 obj << /FunctionType 3 /Domain [0 1] /Functions [5 0 R 99 0 R] /Bounds [0.5] /Encode [0 1 0 1] >> endobj\n5 0 obj << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> endobj",
] {
let parsed = pdf(functions);
let object = parsed.xref().get::<Object<'_>>(ObjectIdentifier::new(4, 0)).unwrap();
assert!(Function::new(&object).is_none(), "accepted invalid children: {functions}");
}
let parsed = pdf(
"4 0 obj << /FunctionType 3 /Domain [0 1] /Functions [5 0 R 5 0 R] /Bounds [0.5] /Encode [0 1 0 1] >> endobj\n5 0 obj << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> endobj",
);
let object = parsed.xref().get::<Object<'_>>(ObjectIdentifier::new(4, 0)).unwrap();
let function = Function::new(&object).unwrap();
let output = function.eval([0.75].into_iter().collect()).unwrap();
assert!((output[0] - 0.5).abs() < 0.001);
}
#[test]
fn function_limits_shared_references_count_toward_construction_work() {
use hayro::hayro_interpret::Function;
use hayro_syntax::object::{Object, ObjectIdentifier};
for children in [9999, 10_000] {
let bytes = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 20 20] >> endobj\n\
4 0 obj << /FunctionType 3 /Domain [0 1] /Functions [{}] /Bounds [{}] /Encode [{}] >> endobj\n\
5 0 obj << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> endobj\n\
trailer << /Root 1 0 R >>\n%%EOF",
"5 0 R ".repeat(children),
"0.5 ".repeat(children - 1),
"0 1 ".repeat(children)
)
.into_bytes();
let parsed = hayro_syntax::Pdf::new(bytes).unwrap();
let object = parsed.xref().get::<Object<'_>>(ObjectIdentifier::new(4, 0)).unwrap();
assert_eq!(Function::new(&object).is_some(), children == 9999);
}
}
#[test]
fn function_limits_invalid_calculator_keeps_the_page_renderable() {
use super::*;
let program = "{ pop 1 0 idiv }";
let content = "/S sh 0 0 1 rg 0 0 20 20 re f";
let bytes = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 20 20] /Resources << /Shading << /S 4 0 R >> >> /Contents 6 0 R >> endobj\n\
4 0 obj << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 20 0] /Function 5 0 R >> endobj\n\
5 0 obj << /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Length {} >> stream\n{program}\nendstream endobj\n\
6 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
trailer << /Root 1 0 R >>\n%%EOF",
program.len(),
content.len()
)
.into_bytes();
let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!((page.width, page.height), (20, 20));
let (pixels, remainder) = page.rgba.as_chunks::<4>();
assert!(remainder.is_empty());
assert!(pixels.iter().all(|pixel| *pixel == [0, 0, 255, 255]));
}
#[test]
fn watchdog_skips_a_stuck_page_and_keeps_rendering() {
use super::*;
let mut pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default());
// Exercise the original private-worker replacement guarantee. Shared
// contention gets one isolated attempt, covered by the tests above.
*lock(&pool.shared.parser.state) = ParserState::Private;
pool.shared.parser.valid.store(false, Ordering::Release);
pool.set_stuck_after(std::time::Duration::from_millis(1000));
*lock(&pool.shared.slow_page) = Some((0, std::time::Duration::from_secs(4)));
let req = |page| RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0 };
pool.set_queue(vec![req(0)]);
let recv = |pool: &RenderPool| {
let t = std::time::Instant::now();
loop {
if let Some(p) = pool.try_recv() {
return p;
}
assert!(t.elapsed() < std::time::Duration::from_secs(8), "no result");
std::thread::sleep(std::time::Duration::from_millis(5));
}
};
let started = std::time::Instant::now();
let first = recv(&pool);
assert!(started.elapsed() < std::time::Duration::from_millis(3000), "the watchdog answers before the render ends");
assert!(first.error.as_deref().is_some_and(|e| e.contains("took longer")), "{:?}", first.error);
// The single worker is stuck, yet page 2 still renders (on the replacement worker).
pool.set_queue(vec![req(1)]);
let second = recv(&pool);
assert!(second.error.is_none() && second.request.page == 1, "{:?}", second.error);
// Asking for the stuck page again fails fast instead of trapping another worker.
pool.set_queue(vec![req(0)]);
let again = recv(&pool);
assert!(again.error.as_deref().is_some_and(|e| e.contains("skipped earlier")), "{:?}", again.error);
// The stuck worker's late result is dropped, not delivered twice.
std::thread::sleep(std::time::Duration::from_millis(4200));
assert!(pool.try_recv().is_none());
}
#[test]
fn a_request_being_rendered_is_not_rendered_again() {
use super::*;
let pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 2, RenderConfig::default());
*lock(&pool.shared.slow_page) = Some((0, std::time::Duration::from_millis(1000)));
let req = |page| RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0 };
pool.set_queue(vec![req(0)]);
let t = std::time::Instant::now();
while !lock(&pool.shared.busy).iter().flatten().any(|busy| busy.request.page == 0) {
assert!(t.elapsed() < std::time::Duration::from_secs(8), "page 1 never started");
std::thread::sleep(std::time::Duration::from_millis(1));
}
// What the canvas sends next: the page it is still waiting for, then another one.
pool.set_queue(vec![req(0), req(1)]);
let mut answers = Vec::new();
let t = std::time::Instant::now();
while answers.len() < 2 || lock(&pool.shared.busy).iter().any(Option::is_some) {
assert!(t.elapsed() < std::time::Duration::from_secs(8), "answers so far: {answers:?}");
if let Some(p) = pool.try_recv() {
assert!(p.error.is_none(), "{:?}", p.error);
answers.push(p.request.page);
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
while let Some(p) = pool.try_recv() {
answers.push(p.request.page);
}
// Page 2 went to the idle worker instead of waiting behind a second copy of page 1.
assert_eq!(answers, vec![1, 0]);
assert_eq!(pool.shared.render_started.load(Ordering::Relaxed), 2, "each page rendered once");
}
#[test]
fn re_sent_queues_render_each_page_once() {
use super::*;
let pages = 12;
let mut pdf = format!(
"%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [{}] /Count {pages} /MediaBox [0 0 200 200] >> endobj\n",
(0..pages).map(|i| format!("{} 0 R", 4 + i)).collect::<Vec<_>>().join(" ")
);
let body: String = (0..3000).map(|k| format!("{} {} 5 5 re f\n", k * 7 % 195, k * 13 % 195)).collect();
pdf += &format!("3 0 obj << /Length {} >> stream\n{body}endstream endobj\n", body.len());
for i in 0..pages {
pdf += &format!("{} 0 obj << /Type /Page /Parent 2 0 R /Contents 3 0 R >> endobj\n", 4 + i);
}
pdf += "trailer << /Root 1 0 R >>\n%%EOF";
let pool = RenderPool::new(Arc::new(pdf.into_bytes()), 3, RenderConfig::default());
let wanted: Vec<RenderRequest> =
(0..pages).map(|page| RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 2000 }).collect();
// The canvas's loop: take what arrived, then queue every page still missing whenever
// that list changes (requests already rendering included).
let mut got = vec![0; pages];
let mut last = Vec::new();
let t = std::time::Instant::now();
while got.contains(&0) || lock(&pool.shared.busy).iter().any(Option::is_some) {
assert!(t.elapsed() < std::time::Duration::from_secs(30), "rendered so far: {got:?}");
while let Some(p) = pool.try_recv() {
assert!(p.error.is_none(), "{:?}", p.error);
got[p.request.page] += 1;
}
let queue: Vec<RenderRequest> = wanted.iter().copied().filter(|r| got[r.page] == 0).collect();
if queue != last {
pool.set_queue(queue.clone());
last = queue;
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
while let Some(p) = pool.try_recv() {
got[p.request.page] += 1;
}
assert_eq!(got, vec![1; pages]);
assert_eq!(pool.shared.render_started.load(Ordering::Relaxed), pages, "no duplicated work was hidden by delivery filtering");
}
#[test]
fn effective_scale_never_exceeds_the_caps() {
for (w, h, s) in [(934_775_807.0, 792.0, 0.25), (612.0, 792.0, 0.0), (1.0e9, 1.0e9, 1.0), (612.0, 792.0, 2.0)] {
let k = super::effective_scale(w, h, s);
assert!(k > 0.0 && k.is_finite(), "{w}×{h} @ {s}: {k}");
assert!(w * k <= super::MAX_SIDE + 1.0 && h * k <= super::MAX_SIDE + 1.0, "{w}×{h} @ {s}: {k}");
assert!(w * k * h * k <= super::MAX_PIXELS * 1.01, "{w}×{h} @ {s}: {k}");
}
assert_eq!(super::effective_scale(612.0, 792.0, 2.0), 2.0);
}
use super::*;
// Metadata-only: the test budget is 128 pixels and no image buffer is allocated.
#[test]
fn image_resampling_rejects_anisotropic_target_growth() {
assert_eq!(hayro::image_resampling_size(16, 1, 1, 16, 0.5, 32.0, 128), None);
assert_eq!(hayro::image_resampling_size(16, 1, 3, 48, 0.5, 32.0, 128), None);
assert_eq!(hayro::image_resampling_size(16, 1, 4, 64, 0.5, 32.0, 128), None);
}
#[test]
fn image_resampling_bounds_intermediate_before_planning() {
// Source and destination are each 128 pixels, but their crossed dimensions are 256.
assert_eq!(hayro::image_resampling_size(16, 8, 4, 512, 0.5, 2.0, 128), None);
}
#[test]
fn image_resampling_rejects_invalid_sources_and_scales() {
assert_eq!(hayro::image_resampling_size(4, 4, 3, 47, 0.5, 0.5, 128), None);
assert_eq!(hayro::image_resampling_size(4, 4, 3, 49, 0.5, 0.5, 128), None);
for scale in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1.0, 0.0] {
assert_eq!(hayro::image_resampling_size(4, 4, 3, 48, 0.5, scale, 128), None);
}
}
#[test]
fn image_resampling_keeps_valid_sizes_and_exact_limits() {
assert_eq!(hayro::image_resampling_size(16, 8, 1, 128, 0.5, 0.5, 128), Some((8, 4)));
assert_eq!(hayro::image_resampling_size(16, 1, 3, 48, 0.5, 8.0, 128), Some((8, 8)));
assert_eq!(hayro::image_resampling_size(16, 8, 4, 512, 1.0, 1.0, 128), Some((16, 8)));
assert_eq!(hayro::image_resampling_size(0, 8, 1, 0, 1.0, 1.0, 128), None);
assert_eq!(hayro::image_resampling_size(16, 9, 1, 144, 1.0, 1.0, 128), None);
assert_eq!(hayro::image_resampling_size(65_536, 1, 1, 65_536, 1.0, 1.0, u64::MAX), None);
for channels in [1, 3, 4] {
assert_eq!(hayro::image_resampling_size(65_536, 1, channels, 65_536 * channels, 1.0 / 4096.0, 1.0, 65_536), Some((16, 1)));
}
assert_eq!(hayro::image_resampling_size(65_535, 1, 1, 65_535, 1.0, 1.0, 65_535), Some((65_535, 1)));
assert_eq!(hayro::image_resampling_size((1 << 20) + 1, 1, 1, (1 << 20) + 1, 1.0 / 4096.0, 1.0, 1 << 28), None);
}
#[test]
fn image_resampling_padded_backend_dimensions_stay_checked() {
// A Type 3 image's two-pixel frame may reach u16::MAX exactly, never wrap to zero.
assert_eq!(hayro::image_resampling_size(65_531 + 4, 1 + 4, 4, 65_535 * 5 * 4, 1.0, 1.0, 65_535 * 5), Some((65_535, 5)));
assert_eq!(hayro::image_resampling_size(65_531 + 4, 1 + 4, 4, 65_535 * 5 * 4, 1.0, 1.0, 65_535 * 5 - 1), None);
assert_eq!(hayro::image_resampling_size(65_532 + 4, 1 + 4, 4, 65_536 * 5 * 4, 1.0, 1.0, 1 << 28), None);
}
fn strip_image_pdf(width: u32, body: &str, space: &str, encoded: &str, alpha: Option<&str>) -> Vec<u8> {
let (mask_ref, mask_obj) = match alpha {
Some(data) => (
"/SMask 6 0 R",
format!(
"6 0 obj << /Type /XObject /Subtype /Image /Width {width} /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length {} >> stream\n{data}>\nendstream endobj\n",
data.len() + 1
),
),
None => ("", String::new()),
};
format!(
"%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 20 4] /Resources << /XObject << /Im0 5 0 R >> >> /Contents 4 0 R >> endobj\n\
4 0 obj << /Length {} >> stream\n{body}endstream endobj\n\
5 0 obj << /Type /XObject /Subtype /Image /Width {width} /Height 1 /ColorSpace /{space} /BitsPerComponent 8 {mask_ref} /Filter /ASCIIHexDecode /Length {} >> stream\n{encoded}>\nendstream endobj\n\
{mask_obj}trailer << /Root 1 0 R >>\n%%EOF", body.len(), encoded.len() + 1,
).into_bytes()
}
// Safe on the original renderer too: 192 KiB RGB / 64 KiB gray sources shrink to 16 pixels.
#[test]
fn image_resampling_wide_sources_shrink_before_backend_side_limits() {
for (space, encoded, expected) in
[("DeviceRGB", "ff0000".repeat(65_536), [255, 0, 0, 255]), ("DeviceGray", "7f".repeat(65_536), [127, 127, 127, 255])]
{
let pdf = strip_image_pdf(65_536, "q 16 0 0 1 2 2 cm /Im0 Do Q\n", space, &encoded, None);
let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
assert!(page.error.is_none(), "{space}: {:?}", page.error);
assert_eq!((page.width, page.height), (20, 4));
let pixel = |x: usize| &page.rgba[(20 + x) * 4..][..4];
assert_eq!(pixel(1), &[255, 255, 255, 255], "{space}: left placement");
assert_eq!(pixel(3), &expected, "{space}: source image was not dropped");
assert_eq!(pixel(17), &expected, "{space}: right placement");
assert_eq!(pixel(19), &[255, 255, 255, 255], "{space}: right placement");
}
}
#[test]
fn image_resampling_wide_transparent_sources_still_shrink() {
let data = "ff0000".repeat(65_536);
let alpha = "7f".repeat(65_536);
let pdf = strip_image_pdf(65_536, "q 16 0 0 1 2 2 cm /Im0 Do Q\n", "DeviceRGB", &data, Some(&alpha));
let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!((page.width, page.height), (20, 4));
assert_eq!(&page.rgba[(20 + 3) * 4..][..4], &[255, 128, 128, 255]);
}
#[test]
fn image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement() {
let data = "ff0000".repeat(16);
let alpha = "7f".repeat(8);
let pdf = strip_image_pdf(16, "q 16 0 0 1 2 2 cm /Im0 Do Q\n", "DeviceRGB", &data, Some(&alpha));
let pdf = String::from_utf8(pdf)
.unwrap()
.replace("6 0 obj << /Type /XObject /Subtype /Image /Width 16", "6 0 obj << /Type /XObject /Subtype /Image /Width 8");
let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!((page.width, page.height), (20, 4));
let pixel = |x: usize| &page.rgba[(20 + x) * 4..][..4];
assert_eq!(pixel(1), &[255, 255, 255, 255]);
assert_eq!(pixel(3), &[255, 128, 128, 255]);
assert_eq!(pixel(17), &[255, 128, 128, 255]);
assert_eq!(pixel(19), &[255, 255, 255, 255]);
}
// GREEN-only integration: the original renderer would attempt GiB buffers. The source is
// only 96 KiB, and the fixed guard rejects the target before the resampling plan/allocation.
#[test]
fn resampling_fallback_preserves_original_geometry_and_pixels() {
let data = "ff0000".repeat(32_767);
let body = "q 16383.5 0 0 1000000000 2 2 cm /Im0 Do Q\n";
let pdf = format!(
"%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 20 20] /Resources << /XObject << /Im0 5 0 R >> >> /Contents 4 0 R >> endobj\n\
4 0 obj << /Length {} >> stream\n{body}endstream endobj\n\
5 0 obj << /Type /XObject /Subtype /Image /Width 32767 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Interpolate true /Filter /ASCIIHexDecode /Length {} >> stream\n{data}>\nendstream endobj\n\
trailer << /Root 1 0 R >>\n%%EOF",
body.len(),
data.len() + 1,
);
let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!((page.width, page.height), (20, 20));
let pixel = |x: usize, y: usize| &page.rgba[(y * 20 + x) * 4..][..4];
assert_eq!(pixel(0, 5), &[255, 255, 255, 255]);
assert_eq!(pixel(3, 5), &[255, 0, 0, 255]);
assert_eq!(pixel(19, 5), &[255, 0, 0, 255]);
}
const ONE_PAGE: &[u8] = b"%PDF-1.4
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 50] /Contents 4 0 R >> endobj
4 0 obj << /Length 35 >> stream
0 0 1 rg 10 10 30 20 re f
endstream endobj
trailer << /Root 1 0 R >>
%%EOF";
const ONE_PAGE_TWICE: &[u8] = b"%PDF-1.4
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 50] /Contents 4 0 R >> endobj
4 0 obj << /Length 35 >> stream
0 0 1 rg 10 10 30 20 re f
endstream endobj
5 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 50] /Contents 4 0 R >> endobj
trailer << /Root 1 0 R >>
%%EOF";
#[test]
fn large_resource_indexes_preserve_pixels_and_text() {
// #307: opening pages used to retain a hash table for every large resource
// dictionary on every page, even before rendering. The vendored lazy index
// must still resolve late keys, inherited resources and repeated renders.
fn fixture(large: bool) -> Vec<u8> {
let mut fonts = String::new();
let mut forms = String::new();
if large {
for i in 0..100 {
fonts.push_str(&format!("/UnusedFont{i} 6 0 R "));
forms.push_str(&format!("/UnusedForm{i} 7 0 R "));
}
}
let body = "BT /F1 12 Tf 10 55 Td (Shared resources) Tj ET q 1 0 0 1 10 10 cm /Last Do Q";
let form = "0 0 1 rg 0 0 30 20 re f";
format!(
"%PDF-1.7\n\
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R 8 0 R] /Count 2 /MediaBox [0 0 150 100] /Resources 5 0 R >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R >> endobj\n\
4 0 obj << /Length {} >> stream\n{body}\nendstream endobj\n\
5 0 obj << /Font << {fonts}/F1 6 0 R >> /XObject << {forms}/Last 7 0 R >> >> endobj\n\
6 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n\
7 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 30 20] /Length {} >> stream\n{form}\nendstream endobj\n\
8 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources 5 0 R /Rotate 90 >> endobj\n\
trailer << /Root 1 0 R >>\n%%EOF",
body.len(),
form.len()
)
.into_bytes()
}
let mut small = PageRenderer::new(Arc::new(fixture(false)), RenderConfig::default());
let mut large = PageRenderer::new(Arc::new(fixture(true)), RenderConfig::default());
for page in [0, 1, 0] {
for scale in [1.0, 2.0] {
let req = RenderRequest { page, scale, ..Default::default() };
let (a, b) = (small.render(req), large.render(req));
assert!(a.error.is_none() && b.error.is_none());
assert_eq!((a.width, a.height, &a.rgba), (b.width, b.height, &b.rgba));
assert!(b.rgba.as_chunks::<4>().0.contains(&[0, 0, 255, 255]));
}
let req = RenderRequest { page, kind: RequestKind::Text, ..Default::default() };
let (a, b) = (small.render(req), large.render(req));
assert!(a.error.is_none() && b.error.is_none());
assert_eq!(a.text.as_ref().unwrap().plain_text(), b.text.as_ref().unwrap().plain_text());
assert!(b.text.as_ref().unwrap().plain_text().contains("Shared resources"));
}
}
#[test]
fn renders_a_blue_rectangle() {
let pool = RenderPool::new(Arc::new(ONE_PAGE.to_vec()), 1, RenderConfig::default());
pool.set_queue(vec![RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 7 }]);
let page = loop {
if let Some(p) = pool.try_recv() {
break p;
}
std::thread::sleep(std::time::Duration::from_millis(5));
};
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!((page.width, page.height, page.request.tag), (100, 50, 7));
// Pixel (20, 30) in y-down device space is inside the rect drawn at y 10..30 (y-up).
let px = |x: u32, y: u32| &page.rgba[((y * page.width + x) * 4) as usize..][..4];
assert_eq!(px(20, 30), &[0, 0, 255, 255]);
assert_eq!(px(80, 5), &[255, 255, 255, 255]);
}
#[test]
fn pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over() {
let mut r = PageRenderer::new(Arc::new(ONE_PAGE.to_vec()), RenderConfig::default());
let out = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0 });
assert!(out.error.is_none(), "{:?}", out.error);
// The same bytes as hayro's own render to a pixmap, with the same settings.
let pdf = Pdf::new(Arc::new(ONE_PAGE.to_vec())).expect("parses");
let pages = pdf.pages();
let page = pages.first().expect("a page");
let rs = RenderSettings { x_scale: 2.0, y_scale: 2.0, width: Some(200), height: Some(100), bg_color: WHITE, ..Default::default() };
let plain = hayro::render(page, &RenderCache::new(), &RenderConfig::default().settings(), &rs);
assert_eq!((out.width, out.height), (200, 100));
assert_eq!(&*out.rgba, plain.data_as_u8_slice());
// Whole pixels, aligned, handed over without a copy.
let at = out.rgba.as_ptr();
assert_eq!(at as usize % 4, 0);
let words = out.rgba.into_words();
assert_eq!(words.as_ptr().cast::<u8>(), at);
}
#[test]
fn render_into_a_buffer_of_the_wrong_length_leaves_it_untouched() {
let pdf = Pdf::new(Arc::new(ONE_PAGE.to_vec())).expect("parses");
let pages = pdf.pages();
let page = pages.first().expect("a page");
let rs = RenderSettings { width: Some(10), height: Some(10), bg_color: WHITE, ..Default::default() };
let settings = RenderConfig::default().settings();
for len in [0, 399, 401, 4000] {
let mut buf = vec![7u8; len];
render_into(page, &RenderCache::new(), &settings, &rs, &mut buf);
assert!(buf.iter().all(|&b| b == 7), "a {len}-byte buffer was written");
}
}
#[test]
fn missing_page_and_garbage_input_fail_gracefully() {
let mut r = PageRenderer::new(Arc::new(ONE_PAGE.to_vec()), RenderConfig::default());
assert!(r.render(RenderRequest { page: 9, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }).error.is_some());
let mut g = PageRenderer::new(Arc::new(b"not a pdf at all".to_vec()), RenderConfig::default());
assert_eq!(g.page_count(), 0);
assert!(g.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }).error.is_some());
}
#[test]
fn scale_is_clamped_for_huge_pages() {
// A 200-inch-square page at 4x would be 57,600 px per side.
let s = effective_scale(14_400.0, 14_400.0, 4.0);
assert!(14_400.0 * s <= MAX_SIDE + 0.5);
assert!((14_400.0 * s).powi(2) <= MAX_PIXELS * 1.01);
}
/// Issue #102: an A4 page (595.28×841.89 pt) rendered 595×841 at 72 dpi, dropping the last
/// partial row and column. Like pdftoppm it is now 596×842, the edge pixels partly covered.
#[test]
fn fractional_page_sizes_round_up() {
let pdf = b"%PDF-1.4
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 595.28 841.89] /Contents 4 0 R >> endobj
4 0 obj << /Length 31 >> stream
0 0 1 rg 0 0 595.28 841.89 re f
endstream endobj
trailer << /Root 1 0 R >>
%%EOF";
let mut r = PageRenderer::new(Arc::new(pdf.to_vec()), RenderConfig::default());
let req = RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 };
let p = r.render(req);
assert!(p.error.is_none(), "{:?}", p.error);
assert_eq!((p.width, p.height), (596, 842));
let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
assert_eq!(px(594, 840), vec![0, 0, 255, 255]);
// The partial column and row blend the fill into the white page.
for (x, y) in [(595, 400), (300, 841), (595, 841)] {
let c = px(x, y);
assert!(c[0] < 255 && c[2] == 255, "({x}, {y}) is not partly covered: {c:?}");
}
assert_eq!(r.render(RenderRequest { scale: 300.0 / 72.0, ..req }).width, 2481);
// A tile over the corner draws the same edge pixels.
let t = r.render(RenderRequest { tile: Some(Tile { x: 594, y: 840, w: 2, h: 2 }), ..req });
assert_eq!((t.width, t.height), (2, 2));
assert_eq!(&t.rgba[12..16], &px(595, 841)[..]);
// Float noise doesn't add a row: Letter at 150 dpi is 1275 px wide.
assert_eq!((device_pixels(612.0, 150.0 / 72.0), device_pixels(0.2, 1.0), device_pixels(f32::NAN, 1.0)), (1275, 1, 1));
}
/// A checkbox whose `/AP /N` is a state dictionary must draw the `/AS` state; a NoView
/// annotation must not draw at all. (Regression test for the vendored hayro patch.)
#[test]
fn widget_appearance_states() {
let pdf = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Annots [4 0 R 7 0 R] >> endobj
4 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (cb) /V /Yes /AS /Yes /Rect [10 10 30 30]
/AP << /N << /Yes 5 0 R /Off 6 0 R >> >> >> endobj
5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 24 >> stream
1 0 0 rg 0 0 20 20 re f
endstream endobj
6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 24 >> stream
0 0 1 rg 0 0 20 20 re f
endstream endobj
7 0 obj << /Type /Annot /Subtype /Square /F 32 /Rect [60 60 90 90] /AP << /N 8 0 R >> >> endobj
8 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 30 30] /Length 24 >> stream
0 1 0 rg 0 0 30 30 re f
endstream endobj
trailer << /Root 1 0 R >>
%%EOF";
let mut r = PageRenderer::new(Arc::new(pdf.to_vec()), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
assert!(p.error.is_none(), "{:?}", p.error);
let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
assert_eq!(px(20, 80), vec![255, 0, 0, 255], "checkbox must show its /Yes state");
assert_eq!(px(75, 25), vec![255, 255, 255, 255], "NoView annotation must not be drawn");
}
/// Quartz writes `/AP /N` as an indirect dictionary of states, and the next object in
/// the file is often some other stream. That dictionary must not be read as that stream,
/// or the checkbox (whose real appearance is `/AS`) is skipped.
#[test]
fn indirect_appearance_state_dict_is_not_another_stream() {
let pdf = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Annots [4 0 R] >> endobj
4 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (cb) /V /Yes /AS /Yes /Rect [10 10 30 30]
/AP 9 0 R >> endobj
9 0 obj << /N 10 0 R >> endobj
10 0 obj << /Yes 5 0 R /Off 6 0 R >> endobj
11 0 obj << /Length 24 >> stream
0 0 1 rg 0 0 20 20 re f
endstream
endobj
5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 24 >> stream
1 0 0 rg 0 0 20 20 re f
endstream
endobj
6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 24 >> stream
0 1 0 rg 0 0 20 20 re f
endstream
endobj
trailer << /Root 1 0 R >>
%%EOF";
let mut r = PageRenderer::new(Arc::new(pdf.to_vec()), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
assert!(p.error.is_none(), "{:?}", p.error);
let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
assert_eq!(px(20, 80), vec![255, 0, 0, 255], "checkbox must show the /Yes appearance, not the decoy stream");
}
/// An appearance whose /BBox is a line or a point, or whose /Matrix collapses it to one, has
/// no mapping onto the annotation's /Rect: the scale (/Rect size over transformed box size)
/// divided by zero, and the infinite or NaN matrix reached the device with everything the
/// appearance drew. Vendored hayro-interpret patch: such appearances are skipped. Clipped to
/// their box they show nothing, and MuPDF and Poppler draw nothing for them either.
#[test]
fn degenerate_annotation_appearances_are_skipped() {
use hayro::hayro_interpret::font::Glyph;
use hayro::hayro_interpret::hayro_syntax::Pdf;
use hayro::hayro_interpret::{
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, Paint, PathDrawMode, SoftMask, TransformExt, interpret_page,
};
use kurbo::{Affine, BezPath, Rect};
/// Counts the geometry that reaches a device, and how much of it is not finite.
#[derive(Default)]
struct Geometry {
drawn: usize,
non_finite: usize,
}
impl Geometry {
fn see(&mut self, finite: bool) {
self.drawn += 1;
self.non_finite += usize::from(!finite);
}
}
impl<'a> Device<'a> for Geometry {
fn set_soft_mask(&mut self, _: Option<SoftMask<'a>>) {}
fn set_blend_mode(&mut self, _: BlendMode) {}
fn draw_path(&mut self, path: &BezPath, transform: Affine, _: &Paint<'a>, _: &PathDrawMode) {
self.see(path.is_finite() && transform.is_finite());
}
fn push_clip_path(&mut self, clip: &ClipPath) {
self.see(clip.path.is_finite());
}
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'a>>, _: BlendMode) {}
fn draw_glyph(&mut self, _: &Glyph<'a>, transform: Affine, glyph_transform: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {
self.see(transform.is_finite() && glyph_transform.is_finite());
}
fn draw_image(&mut self, _: Image<'a, '_>, transform: Affine) {
self.see(transform.is_finite());
}
fn pop_clip_path(&mut self) {}
fn pop_transparency_group(&mut self) {}
}
let pdf = |form: &str| {
let content = "1 0 0 rg 0 0 20 20 re f BT /F1 12 Tf 2 5 Td (Hi) Tj ET";
format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Annots [4 0 R] >> endobj
4 0 obj << /Type /Annot /Subtype /Square /Rect [10 10 60 40] /AP << /N 5 0 R >> >> endobj
5 0 obj << /Type /XObject /Subtype /Form {form} /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> /Length {} >> stream
{content}
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len()
)
};
let geometry = |bytes: &[u8]| {
let parsed = Pdf::new(Arc::new(bytes.to_vec())).expect("parses");
let page = &parsed.pages()[0];
let cache = InterpreterCache::new();
let initial = page.initial_transform(true).to_kurbo();
let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 100.0, 100.0), &cache, page.xref(), InterpreterSettings::default());
let mut device = Geometry::default();
interpret_page(page, &mut ctx, &mut device);
device
};
let render = |bytes: Vec<u8>| {
let mut r = PageRenderer::new(Arc::new(bytes), RenderConfig::default());
r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 })
};
for (case, form) in [
("zero-width /BBox", "/BBox [0 0 0 20]"),
("zero-height /BBox", "/BBox [0 0 20 0]"),
("point /BBox", "/BBox [5 5 5 5]"),
("/Matrix with a zero column", "/BBox [0 0 20 20] /Matrix [0 0 0 1 0 0]"),
("/Matrix with a zero row", "/BBox [0 0 20 20] /Matrix [1 0 0 0 0 0]"),
] {
let bytes = pdf(form).into_bytes();
let device = geometry(&bytes);
assert_eq!(device.non_finite, 0, "{case}: {} of {} drawing calls were not finite", device.non_finite, device.drawn);
let p = render(bytes);
assert!(p.error.is_none(), "{case}: {:?}", p.error);
assert!(p.rgba.as_chunks::<4>().0.iter().all(|c| *c == [255, 255, 255, 255]), "{case}: nothing is drawn");
}
// A valid appearance still reaches the device, and fills its /Rect and only its /Rect.
let valid = pdf("/BBox [0 0 20 20]").into_bytes();
let device = geometry(&valid);
assert!(device.drawn >= 4 && device.non_finite == 0, "{} drawn, {} non-finite", device.drawn, device.non_finite);
let p = render(valid);
assert!(p.error.is_none(), "{:?}", p.error);
let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
assert_eq!(px(11, 61), vec![255, 0, 0, 255]);
assert_eq!(px(58, 88), vec![255, 0, 0, 255]);
assert_eq!(px(61, 75), vec![255, 255, 255, 255], "right of /Rect");
assert_eq!(px(30, 58), vec![255, 255, 255, 255], "above /Rect");
}
/// ISO 32000-2 §12.5.5: the transformed appearance box is scaled and translated so that its
/// lower-left and upper-right corners land on those of /Rect. The translation ignored the
/// scale, so a box that doesn't start at the origin and differs in size from /Rect was drawn
/// shifted, partly outside /Rect (vendored hayro-interpret patch). MuPDF, Poppler, PDFium and
/// pdf.js fill /Rect exactly for each of these.
#[test]
fn annotation_appearance_box_maps_onto_rect() {
let filled = |form: &str, content: &str| {
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Annots [4 0 R] >> endobj
4 0 obj << /Type /Annot /Subtype /Square /Rect [10 10 60 40] /AP << /N 5 0 R >> >> endobj
5 0 obj << /Type /XObject /Subtype /Form {form} /Length {} >> stream
{content}
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len()
);
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
assert!(p.error.is_none(), "{form}: {:?}", p.error);
// The bounding box of everything drawn, in device pixels.
let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0, 0);
for y in 0..p.height {
for x in 0..p.width {
if p.rgba[((y * p.width + x) * 4) as usize..][..4] != [255, 255, 255, 255] {
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
}
}
}
(x0, y0, x1, y1)
};
// /Rect [10 10 60 40] is device x 10..=59, y 60..=89.
let rect = (10, 60, 59, 89);
for (form, content) in [
("/BBox [10 10 30 30]", "1 0 0 rg 10 10 20 20 re f"),
("/BBox [0 0 20 20] /Matrix [1 0 0 1 10 10]", "1 0 0 rg 0 0 20 20 re f"),
("/BBox [-20 -5 0 15]", "1 0 0 rg -20 -5 20 20 re f"),
// Unchanged: a box at the origin, and an offset box as large as /Rect.
("/BBox [0 0 20 20]", "1 0 0 rg 0 0 20 20 re f"),
("/BBox [100 100 150 130]", "1 0 0 rg 100 100 50 30 re f"),
] {
assert_eq!(filled(form, content), rect, "{form}");
}
}
/// A Highlight annotation without an appearance stream (common in older and generated files)
/// was not drawn at all. Vendored hayro-interpret patch: its /QuadPoints are filled with /C at
/// /CA, blended with Multiply, as PdfCraft draws its own highlights (pdfcraft-annot); MuPDF,
/// Poppler and PDFium draw these fixtures the same way. An /AP still wins, malformed
/// /QuadPoints or no /C draw nothing, and Hidden, NoView and "Hide all comments" still apply.
#[test]
fn highlights_without_an_appearance_are_drawn() {
let render = |annot: &str, config: RenderConfig| {
let content = "0 g 100 40 10 20 re f";
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R /Annots [5 0 R] >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /Annot /Subtype /Highlight /Rect [15 35 120 65] {annot} >> endobj
6 0 obj << /Type /XObject /Subtype /Form /BBox [15 35 120 65] /Length 24 >> stream
0 0 1 rg 15 35 50 30 re f
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len()
);
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), config);
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
assert!(p.error.is_none(), "{annot}: {:?}", p.error);
p
};
let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
let (white, black, yellow) = (vec![255, 255, 255, 255], vec![0, 0, 0, 255], vec![255, 255, 0, 255]);
let quad = "/QuadPoints [15 65 120 65 15 35 120 35]";
let p = render(&format!("{quad} /C [1 1 0]"), RenderConfig::default());
assert_eq!(px(&p, 30, 50), yellow, "the quad is filled with /C");
assert_eq!(px(&p, 105, 50), black, "Multiply keeps what is underneath");
assert_eq!(px(&p, 150, 50), white, "outside the quad");
assert_eq!(px(&p, 30, 30), white, "above the quad");
// Two quads (one per line of text); the gap between them stays white.
let p = render("/QuadPoints [15 65 60 65 15 50 60 50 70 50 120 50 70 35 120 35] /C [0 1 1]", RenderConfig::default());
assert_eq!(px(&p, 30, 40), vec![0, 255, 255, 255]);
assert_eq!(px(&p, 90, 60), vec![0, 255, 255, 255]);
assert_eq!((px(&p, 90, 40), px(&p, 30, 60)), (white.clone(), white.clone()));
// /CA and gray /C.
let p = render(&format!("{quad} /C [1 0 0] /CA 0.5"), RenderConfig::default());
let half = px(&p, 30, 50);
assert!(half[0] == 255 && (126..=129).contains(&half[1]) && half[1] == half[2], "{half:?}");
let p = render(&format!("{quad} /C [0.5]"), RenderConfig::default());
let gray = px(&p, 30, 50);
assert!((126..=129).contains(&gray[0]) && gray[0] == gray[1] && gray[1] == gray[2], "{gray:?}");
// An appearance stream wins: the blue form, not a yellow highlight.
let p = render(&format!("{quad} /C [1 1 0] /AP << /N 6 0 R >>"), RenderConfig::default());
assert_eq!(px(&p, 30, 50), vec![0, 0, 255, 255]);
assert_eq!(px(&p, 90, 50), white, "no highlight outside the appearance's own drawing");
// Nothing is drawn for malformed geometry, no colour, or a hidden annotation.
let hide = RenderConfig { hide_comments: true, ..RenderConfig::default() };
for (annot, config) in [
("/QuadPoints [10 10 20] /C [1 1 0]", RenderConfig::default()),
("/QuadPoints [15 65 120 65 15 35 /x 35] /C [1 1 0]", RenderConfig::default()),
("/QuadPoints [] /C [1 1 0]", RenderConfig::default()),
("/QuadPoints 7 /C [1 1 0]", RenderConfig::default()),
(quad, RenderConfig::default()),
(&format!("{quad} /C []"), RenderConfig::default()),
(&format!("{quad} /C [1 1]"), RenderConfig::default()),
(&format!("{quad} /C [1 1 0] /F 2"), RenderConfig::default()),
(&format!("{quad} /C [1 1 0] /F 32"), RenderConfig::default()),
(&format!("{quad} /C [1 1 0]"), hide),
] {
let p = render(annot, config);
assert_eq!(px(&p, 30, 50), white, "{annot}");
assert_eq!(px(&p, 105, 50), black, "{annot}");
}
}
#[test]
fn hiding_comments_keeps_fields() {
let pdf = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Annots [4 0 R 6 0 R] >> endobj
4 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (t) /Rect [10 10 30 30] /AP << /N 5 0 R >> >> endobj
5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 24 >> stream
1 0 0 rg 0 0 20 20 re f
endstream endobj
6 0 obj << /Type /Annot /Subtype /Square /F 4 /Rect [60 60 90 90] /AP << /N 7 0 R >> >> endobj
7 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 30 30] /Length 24 >> stream
0 1 0 rg 0 0 30 30 re f
endstream endobj
trailer << /Root 1 0 R >>
%%EOF";
let render = |hide: bool| {
let mut r = PageRenderer::new(Arc::new(pdf.to_vec()), RenderConfig { hide_comments: hide, ..RenderConfig::default() });
r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 })
};
let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
let shown = render(false);
assert_eq!(px(&shown, 75, 25), vec![0, 255, 0, 255], "the comment is drawn");
let hidden = render(true);
assert_eq!(px(&hidden, 75, 25), vec![255, 255, 255, 255], "Hide all comments");
assert_eq!(px(&hidden, 20, 80), vec![255, 0, 0, 255], "fields stay");
}
#[test]
fn inline_pool_renders_in_priority_order() {
let pool = RenderPool::new_inline(Arc::new(ONE_PAGE.to_vec()), RenderConfig::default());
assert!(pool.is_inline());
assert!(pool.try_recv().is_none());
pool.set_queue(vec![
RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 1 },
RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 2 },
]);
assert_eq!(pool.try_recv().map(|p| p.request.tag), Some(1));
assert_eq!(pool.try_recv().map(|p| (p.request.tag, p.width)), Some((2, 50)));
assert!(pool.try_recv().is_none());
}
#[test]
fn dropping_the_pool_cancels_its_renders() {
const TEXT: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
4 0 obj << /Length 35 >> stream
BT /F1 12 Tf 20 70 Td (Hello) Tj ET
endstream endobj
5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj
trailer << /Root 1 0 R >>
%%EOF";
let pool = RenderPool::new(Arc::new(ONE_PAGE.to_vec()), 1, RenderConfig::default());
let flag = lock(&pool.shared.stop).first().cloned().expect("one worker");
let settings = worker_settings(&pool.config, &flag);
assert!(settings.cancelled.as_ref().is_some_and(|c| Arc::ptr_eq(c, &flag)));
let shapes = Pdf::new(Arc::new(ONE_PAGE.to_vec())).expect("parses");
let text = Pdf::new(Arc::new(TEXT.to_vec())).expect("parses");
let pages = shapes.pages();
let page = pages.first().expect("a page");
let rs = RenderSettings { bg_color: WHITE, ..Default::default() };
let blue = |s: &InterpreterSettings| {
let p = hayro::render(page, &RenderCache::new(), s, &rs).sample(20, 30);
[p.r, p.g, p.b, p.a] == [0, 0, 255, 255]
};
let glyphs = |s: &InterpreterSettings| crate::text::extract_page(&text, 0, s).map_or(0, |t| t.glyphs.len());
assert!(blue(&settings) && glyphs(&settings) == 5, "a live pool renders and reads everything");
drop(pool);
assert!(flag.load(std::sync::atomic::Ordering::Relaxed));
assert!(!blue(&settings) && glyphs(&settings) == 0, "once the pool is gone, interpretation stops before drawing");
}
#[test]
fn the_watchdog_stops_the_render_it_gives_up_on() {
let mut pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default());
// Private parsers: a shared render's first timeout is retried privately, which would
// give up on (and stop) a second worker too.
*lock(&pool.shared.parser.state) = ParserState::Private;
pool.shared.parser.valid.store(false, Ordering::Release);
pool.set_stuck_after(std::time::Duration::from_millis(100));
*lock(&pool.shared.slow_page) = Some((0, std::time::Duration::from_millis(1500)));
pool.set_queue(vec![RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0 }]);
let t = std::time::Instant::now();
let first = loop {
if let Some(p) = pool.try_recv() {
break p;
}
assert!(t.elapsed() < std::time::Duration::from_secs(30), "no answer");
std::thread::sleep(std::time::Duration::from_millis(5));
};
assert!(first.error.as_deref().is_some_and(|e| e.contains("took longer")), "{:?}", first.error);
// The abandoned worker is told to stop at its next operator; its replacement isn't.
let stop: Vec<bool> = lock(&pool.shared.stop).iter().map(|s| s.load(std::sync::atomic::Ordering::Relaxed)).collect();
assert_eq!(stop, vec![true, false]);
}
#[test]
fn extracts_text_with_positions() {
let pdf = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
4 0 obj << /Length 60 >> stream
BT /F1 12 Tf 20 70 Td (Hello World) Tj 0 -30 Td (Second) Tj ET
endstream endobj
5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj
trailer << /Root 1 0 R >>
%%EOF";
let mut r = PageRenderer::new(Arc::new(pdf.to_vec()), RenderConfig::default());
let out = r.render(RenderRequest { page: 0, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 });
assert!(out.error.is_none(), "{:?}", out.error);
let t = out.text.expect("text");
assert_eq!(t.plain_text(), "Hello World\nSecond");
// "H" sits at x=20, baseline y=70 (y-up) → view y ≈ 100-70 = 30 at the baseline.
let h = &t.glyphs[0].rect;
assert!((h[0] - 20.0).abs() < 0.5 && h[1] < 30.0 && h[3] > 30.0, "{h:?}");
assert_eq!(t.find("world").len(), 1);
}
#[test]
fn layer_override_hides_content() {
let pdf = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D << /Order [5 0 R] >> >> >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /Properties << /L1 5 0 R >> >> >> endobj
4 0 obj << /Length 44 >> stream
/OC /L1 BDC 1 0 0 rg 0 0 100 100 re f EMC
endstream endobj
5 0 obj << /Type /OCG /Name (Red) >> endobj
trailer << /Root 1 0 R >>
%%EOF";
let px = |cfg: RenderConfig| {
let mut r = PageRenderer::new(Arc::new(pdf.to_vec()), cfg);
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0 });
p.rgba[..4].to_vec()
};
assert_eq!(px(RenderConfig::default()), vec![255, 0, 0, 255], "layer is on by default");
let off = RenderConfig { layers: Arc::new(vec![(5, 0, false)]), ..Default::default() };
assert_eq!(px(off), vec![255, 255, 255, 255], "toggled-off layer must not draw");
}
/// From `cargo xtask fuzz`: a tiling pattern with no /Resources inherits the page's, where
/// its own name points back at it. It painted itself until the stack overflowed (vendored
/// hayro-interpret patch: `MAX_PAINT_NESTING`). Ordinary patterns must still paint.
#[test]
fn self_referencing_tiling_pattern_terminates() {
let pdf = |pattern_body: &str| {
format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /Pattern << /P1 5 0 R >> >> >> endobj
4 0 obj << /Length 30 >> stream
/Pattern cs /P1 scn 0 0 40 40 re f
endstream endobj
5 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Length {} >> stream
{pattern_body}
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
pattern_body.len()
)
};
let render = |doc: String| {
let mut r = PageRenderer::new(Arc::new(doc.into_bytes()), RenderConfig::default());
r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 })
};
let looped = render(pdf("/Pattern cs /P1 scn 0 0 10 10 re f"));
assert_eq!((looped.width, looped.height), (40, 40));
let red = render(pdf("1 0 0 rg 0 0 10 10 re f"));
assert!(red.error.is_none(), "{:?}", red.error);
assert_eq!(&red.rgba[((20 * 40 + 20) * 4)..][..4], &[255, 0, 0, 255], "a normal tiling pattern still paints");
}
/// From `cargo xtask fuzz`: a Type 3 font without /Resources inherits the page's, where its
/// own name is defined, and its glyph shows text in itself (vendored patch:
/// `MAX_PAINT_NESTING`). Must terminate; a normal Type 3 glyph must still paint.
#[test]
fn self_referencing_type3_glyph_terminates() {
let pdf = |proc_body: &str| {
format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
4 0 obj << /Length 31 >> stream
BT /F1 20 Tf 10 10 Td (A) Tj ET
endstream endobj
5 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 1 1] /FontMatrix [1 0 0 1 0 0] /FirstChar 65 /LastChar 65 /Widths [1]
/Encoding << /Differences [65 /a] >> /CharProcs << /a 6 0 R >> >> endobj
6 0 obj << /Length {} >> stream
{proc_body}
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
proc_body.len()
)
};
let render = |doc: String| {
let mut r = PageRenderer::new(Arc::new(doc.into_bytes()), RenderConfig::default());
r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 })
};
let looped = render(pdf("1 0 d0 BT /F1 1 Tf (A) Tj ET 0 0 1 1 re f"));
assert_eq!((looped.width, looped.height), (40, 40));
let plain = render(pdf("1 0 d0 0 0 1 1 re f"));
assert!(plain.error.is_none(), "{:?}", plain.error);
assert!(plain.rgba.as_chunks::<4>().0.iter().any(|p| p[0] < 128), "a normal Type 3 glyph still paints");
}
/// From `cargo xtask fuzz`: a tiling pattern whose /XStep and /YStep dwarf its /BBox got a
/// cell pixmap of `step × scale` pixels, bounded only by u16 (2 GB and more). The vendored
/// hayro patch `tiling_cell_scale` keeps the cell within 3000 pixels a side.
#[test]
fn tiling_cells_stay_small_whatever_the_step() {
assert_eq!(hayro::tiling_cell_scale(2.0, 10.0, 3000.0), 2.0, "ordinary cells keep their scale");
for step in [1.0e5f32, -1.0e5, 4.0e9] {
let s = hayro::tiling_cell_scale(2.0, step, 3000.0);
assert!(s * step.abs() <= 3000.5, "{step}: {s}");
}
assert_eq!(hayro::tiling_cell_scale(2.0, f32::INFINITY, 3000.0), 2.0);
assert_eq!(hayro::tiling_cell_scale(2.0, 0.0, 3000.0), 2.0);
// End to end: such a pattern renders, and the page's other content still draws.
let content = "/Pattern cs /P1 scn 0 0 40 40 re f 1 0 0 rg 0 0 4 4 re f";
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /Pattern << /P1 5 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 100000 /YStep 100000 /Length 20 >> stream
0 0 1 rg 0 0 10 10 re f
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len()
);
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255]);
}
/// Contributor-original JPEG 2000: one unsigned 8-bit pixel per component, one tile,
/// no decomposition, reversible wavelet, and one empty packet per component. Empty
/// coefficients decode to the midpoint (128) after the unsigned DC level shift.
fn jpx_midpoint_pdf(bpc: u8, components: u16) -> Vec<u8> {
let mut codestream = vec![0xff, 0x4f, 0xff, 0x51]; // SOC, SIZ.
codestream.extend_from_slice(&(38 + 3 * components).to_be_bytes());
codestream.extend_from_slice(&0_u16.to_be_bytes()); // Rsiz.
for value in [1_u32, 1, 0, 0, 1, 1, 0, 0] {
codestream.extend_from_slice(&value.to_be_bytes());
}
codestream.extend_from_slice(&components.to_be_bytes());
for _ in 0..components {
codestream.extend_from_slice(&[7, 1, 1]); // Eight bits, no subsampling.
}
// COD: LRCP, one layer, no transform between components, 64x64 codeblocks,
// zero decomposition levels, reversible wavelet. QCD: no quantization.
codestream.extend_from_slice(&[0xff, 0x52, 0, 12, 0, 0, 0, 1, 0, 0, 4, 4, 0, 1]);
codestream.extend_from_slice(&[0xff, 0x5c, 0, 4, 0x40, 0x40]);
codestream.extend_from_slice(&[0xff, 0x90, 0, 10, 0, 0]); // SOT, tile zero.
codestream.extend_from_slice(&(14 + u32::from(components)).to_be_bytes());
codestream.extend_from_slice(&[0, 1, 0xff, 0x93]); // One tile part, SOD.
codestream.extend(std::iter::repeat_n(0, usize::from(components)));
codestream.extend_from_slice(&[0xff, 0xd9]); // EOC.
let content = "q 20 0 0 20 5 5 cm /Im1 Do Q 1 0 0 rg 0 0 4 4 re f";
let mut pdf = format!(
"%PDF-1.7\n\
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent {bpc} /Filter /JPXDecode /Length {} >> stream\n",
content.len(),
codestream.len()
)
.into_bytes();
pdf.extend_from_slice(&codestream);
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF");
pdf
}
#[test]
fn jpx_stream_bit_depth_boundaries_do_not_panic() {
use hayro::hayro_syntax::bit_reader::BitReader;
use hayro::hayro_syntax::object::stream::ImageDecodeParams;
for (bpc, expected) in [(1, 1), (8, 128), (16, 32_896), (31, 1_077_952_576), (32, 2_155_905_152)] {
let pdf = Pdf::new(Arc::new(jpx_midpoint_pdf(bpc, 1))).unwrap();
let stream = pdf.objects().into_iter().filter_map(|object| object.into_stream()).find(|stream| !stream.filters().is_empty()).unwrap();
let decoded = stream.decoded_image(&ImageDecodeParams { bpc: Some(bpc), ..Default::default() }).unwrap();
let image = decoded.image_data.unwrap();
assert_eq!((image.width, image.height, image.bits_per_component), (1, 1, bpc));
assert_eq!(decoded.data.len(), usize::from(bpc).div_ceil(8));
assert_eq!(BitReader::new(&decoded.data).read(bpc), Some(expected), "{bpc}-bit midpoint");
}
}
#[test]
fn jpx_streams_reject_out_of_range_bit_depths() {
use hayro::hayro_syntax::object::stream::ImageDecodeParams;
for bpc in [0, 33, 255] {
let pdf = Pdf::new(Arc::new(jpx_midpoint_pdf(bpc, 1))).unwrap();
let stream = pdf.objects().into_iter().filter_map(|object| object.into_stream()).find(|stream| !stream.filters().is_empty()).unwrap();
assert!(stream.decoded_image(&ImageDecodeParams { bpc: Some(bpc), ..Default::default() }).is_err());
}
}
#[test]
fn jpx_gray_rgb_and_alpha_keep_eight_bit_samples() {
use hayro::hayro_syntax::object::stream::ImageDecodeParams;
for components in [1, 3, 4] {
let pdf = Pdf::new(Arc::new(jpx_midpoint_pdf(8, components))).unwrap();
let stream = pdf.objects().into_iter().filter_map(|object| object.into_stream()).find(|stream| !stream.filters().is_empty()).unwrap();
let decoded = stream.decoded_image(&ImageDecodeParams::default()).unwrap();
let expected_colors = usize::from(components.min(3));
assert_eq!(decoded.data.as_ref(), vec![128; expected_colors]);
let alpha = decoded.image_data.unwrap().alpha;
assert_eq!(alpha, (components == 4).then(|| vec![128]));
}
}
#[test]
fn jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering() {
for bpc in [8, 32] {
let mut renderer = PageRenderer::new(Arc::new(jpx_midpoint_pdf(bpc, 1)), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
assert!(page.error.is_none(), "{bpc} bits: {:?}", page.error);
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "other page content draws");
if bpc == 8 {
assert_eq!(&page.rgba[((25 * 40 + 15) * 4)..][..4], &[128, 128, 128, 255], "the valid image paints gray");
}
}
}
/// From the nightly `cargo xtask fuzz` (CI caps each child at 4 GiB): a stencil mask claiming
/// /W 4294967295 and a CCITT image claiming /Columns 4294967295 each allocated 4 GiB while
/// decoding (locally: 9.6 GB and 4.3 GB). Vendored hayro patches `image_size_ok` and
/// `ccitt_size_ok` refuse such sizes before decoding; the rest of the page still draws.
#[test]
fn absurd_mask_and_fax_sizes_are_refused_before_decoding() {
assert!(hayro::hayro_interpret::image_size_ok(8000, 8000));
assert!(!hayro::hayro_interpret::image_size_ok(4_294_967_295, 2));
assert!(!hayro::hayro_interpret::image_size_ok(0, 10));
assert!(hayro::hayro_syntax::ccitt_size_ok(1728, 2200), "a fax page");
assert!(!hayro::hayro_syntax::ccitt_size_ok(4_294_967_295, 26));
assert!(!hayro::hayro_syntax::ccitt_size_ok(1 << 19, 1 << 12));
let page = |content: &str, xobject: &str| {
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj {xobject} endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len()
);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("an absurd image must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws");
};
let red = "1 0 0 rg 0 0 4 4 re f";
page(&format!("q 20 0 0 20 5 5 cm BI /W 4294967295 /H 2 /IM true /BPC 1 ID \u{0}\u{ff}\u{ff}\u{0} EI Q {red}"), "<< >>");
let fax = "<< /Type /XObject /Subtype /Image /Width 81 /Height 26 /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter /CCITTFaxDecode /DecodeParms << /Columns 4294967295 /Rows 26 /K -1 >> /Length 4 >> stream\n\u{0}\u{0}\u{0}\u{0}\nendstream";
page(&format!("q 20 0 0 20 5 5 cm /Im1 Do Q {red}"), fax);
}
/// From the nightly `cargo xtask fuzz`: a FlateDecode predictor with `/Columns
/// 9223372036854775807` wrapped to a 2^61-byte row allocation (an abort on any machine), and
/// a line width of 9223372036854775807 made stroke expansion allocate 10 GB. Vendored hayro
/// patches: saturating predictor rows refused when longer than the data, and stroke widths
/// clamped to a few canvases. The page still renders, with the huge stroke covering it.
#[test]
fn absurd_predictor_columns_and_line_widths_render() {
let render = |streams: &[&str]| {
let mut objs = String::new();
let mut refs = Vec::new();
for (i, s) in streams.iter().enumerate() {
let n = 4 + i;
refs.push(format!("{n} 0 R"));
objs.push_str(&format!("{n} 0 obj {s} endobj\n"));
}
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents [{}] >> endobj
{objs}trailer << /Root 1 0 R >>
%%EOF",
refs.join(" ")
);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
page
};
let plain = |content: &str| format!("<< /Length {} >> stream\n{content}\nendstream", content.len());
// Stored (uncompressed) zlib data, so the stream needs no encoder: header, one final
// stored block of two bytes, checksum.
let predicted = "<< /Filter /FlateDecode /DecodeParms << /Predictor 12 /Columns 9223372036854775807 >> /Length 13 >> stream\nx\u{1}\u{1}\u{2}\u{0}\u{fd}\u{ff}\u{0}\u{0}\u{0}\u{1}\u{0}\u{1}\nendstream";
let page = render(&[predicted, &plain("1 0 0 rg 0 0 4 4 re f")]);
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the other content stream draws");
let page = render(&[&plain("1 0 0 RG 9223372036854775807 w 10 20 m 30 20 l S")]);
// Butt caps: the stroke covers the band over the segment (x 10 to 30) top to bottom.
for (x, y) in [(20, 0), (20, 39), (12, 0), (28, 39)] {
assert_eq!(&page.rgba[((y * 40 + x) * 4)..][..4], &[255, 0, 0, 255], "({x}, {y}) under the huge stroke");
}
assert_eq!(&page.rgba[((20 * 40 + 2) * 4)..][..4], &[255, 255, 255, 255], "beyond the butt cap");
}
/// Render a 40 × 40 pt page at 1:1 on its own thread, with `/GS1` (from `gs1`) in its
/// resources, and fail instead of waiting when the renderer stalls.
fn render_with_gs1(content: &str, gs1: &str) -> RenderedPage {
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /ExtGState << /GS1 {gs1} >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len()
);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
page
}
/// The pixel at device (x, y), y down, of a 40-pixel-wide page.
fn px40(page: &RenderedPage, x: usize, y: usize) -> &[u8] {
&page.rgba[((y * 40 + x) * 4)..][..4]
}
/// From `cargo xtask fuzz`: an ExtGState `/D [[11] 0]` on a line that starts at x =
/// 92234775807 cut it into billions of dashes, and stroke expansion allocated more than
/// 5 GB. A tiny dash on an ordinary line does the same (`[0.000001] 0 d` on 20 pt passed
/// 10 GB), and so does a long pattern on many short subpaths, because every subpath restarts
/// it. Vendored hayro patch: a stroke whose dash pattern could cut it into more than
/// `MAX_DASHES_PER_STROKE` pieces is drawn solid.
#[test]
fn dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render() {
let red = [255, 0, 0, 255];
// The fuzzed shape. A line that far out isn't drawn even undashed (its coordinates are
// beyond the rasterizer's range), so this case only proves the page finishes and the
// content after the stroke is drawn.
let page = render_with_gs1("1 0 0 RG 4 w /GS1 gs 92234775807 20 m 30 20 l S 0 0 1 rg 0 0 4 4 re f", "<< /D [[11] 0] >>");
assert_eq!(px40(&page, 1, 38), &[0, 0, 255, 255], "the square after the stroke is drawn");
for (what, content, gs1) in [
("a tiny dash on an ordinary line", "1 0 0 RG 4 w [0.000001] 0 d 10 20 m 30 20 l S", "<< >>"),
("a tiny dash from an ExtGState", "1 0 0 RG 4 w /GS1 gs 10 20 m 30 20 l S", "<< /D [[0.000001] 0] >>"),
(
"a tiny dash under a shrinking matrix",
"1 0 0 RG 0.0000001 0 0 0.0000001 0 0 cm 40000000 w [1] 0 d 100000000 200000000 m 300000000 200000000 l S",
"<< >>",
),
] {
let page = render_with_gs1(content, gs1);
// Device row 20 is user y 20; the stroke covers x 10 to 30.
for x in [12, 20, 28] {
assert_eq!(px40(&page, x, 20), &red, "{what}: ({x}, 20) is drawn, solid");
}
assert_eq!(px40(&page, 35, 20), &[255, 255, 255, 255], "{what}: beyond the end of the line");
}
// A long pattern on many short subpaths: 9,999 tiny dashes, then a long one, restarted on
// each of 10,000 subpaths 0.01 pt long, is 100 million pieces from a 300 KB stream.
let mut many = String::from("1 0 0 RG 4 w [");
many.push_str(&"0.000001 ".repeat(9_999));
many.push_str("1000] 0 d ");
many.push_str(&"10 20 m 10.01 20 l ".repeat(10_000));
many.push_str("S 0 0 1 rg 0 0 4 4 re f");
let page = render_with_gs1(&many, "<< >>");
assert_eq!(px40(&page, 1, 38), &[0, 0, 255, 255], "a pattern restarted on many subpaths finishes");
// A fine pattern below the cap still dashes: 200,000 pieces of 0.0001 pt cover about
// half of each pixel, so the line is neither solid red nor missing.
let page = render_with_gs1("1 0 0 RG 4 w [0.0001] 0 d 10 20 m 30 20 l S", "<< >>");
let p = px40(&page, 20, 20);
assert!(p[0] == 255 && (40..=215).contains(&p[1]), "a fine dash below the cap is drawn dashed: {p:?}");
// An ordinary dash pattern still dashes: butt-capped 4 pt dashes from x 0.
let page = render_with_gs1("1 0 0 RG 4 w [4 4] 0 d 0 20 m 40 20 l S", "<< >>");
assert_eq!(px40(&page, 2, 20), &red, "inside the first dash");
assert_eq!(px40(&page, 6, 20), &[255, 255, 255, 255], "inside the first gap");
assert_eq!(px40(&page, 10, 20), &red, "inside the second dash");
}
/// From `cargo xtask fuzz` triage: a dash array whose entries sum to less than zero made
/// kurbo's search for the starting dash loop for ever (`[-1 -1] 0 d` never finished).
/// ISO 32000-2 §8.4.3.6 requires nonnegative entries. Vendored hayro patch: a pattern with a
/// negative entry, or a period that isn't positive, is drawn solid.
#[test]
fn invalid_dash_patterns_terminate_and_are_drawn_solid() {
for (what, content, gs1) in [
("[-1 -1]", "1 0 0 RG 4 w [-1 -1] 0 d 10 20 m 30 20 l S", "<< >>"),
("[-1] with a phase", "1 0 0 RG 4 w [-1] 5 d 10 20 m 30 20 l S", "<< >>"),
("[-2 -3] from an ExtGState", "1 0 0 RG 4 w /GS1 gs 10 20 m 30 20 l S", "<< /D [[-2 -3] 0] >>"),
("[-1 3], a negative entry in a positive period", "1 0 0 RG 4 w [-1 3] 0 d 10 20 m 30 20 l S", "<< >>"),
] {
let page = render_with_gs1(content, gs1);
for x in [12, 20, 28] {
assert_eq!(px40(&page, x, 20), &[255, 0, 0, 255], "{what}: ({x}, 20) is drawn, solid");
}
}
}
/// From the nightly `cargo xtask fuzz`: a Type 3 glyph that shows several glyphs of its own
/// font. The nesting cap bounds the depth but not the breadth: eight glyphs per glyph, sixteen
/// deep, is 8^16 paints. Vendored hayro-interpret patch: nested paints (forms, Type 3 glyphs,
/// tiling patterns inside one another) share a per-page budget.
#[test]
fn type3_glyphs_that_fan_out_into_themselves_finish() {
let pdf = "%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
4 0 obj << /Length 53 >> stream
BT /F1 20 Tf 10 10 Td (A) Tj ET 1 0 0 rg 0 0 4 4 re f
endstream endobj
5 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 1 1] /FontMatrix [1 0 0 1 0 0] /FirstChar 65 /LastChar 65 /Widths [1]
/Encoding << /Differences [65 /a] >> /CharProcs << /a 6 0 R >> >> endobj
6 0 obj << /Length 48 >> stream
1 0 d0 BT /F1 1 Tf (AAAAAAAA) Tj ET 0 0 1 1 re f
endstream endobj
trailer << /Root 1 0 R >>
%%EOF";
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf.as_bytes().to_vec()), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("a self-multiplying Type 3 glyph must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws");
}
/// From the nightly `cargo xtask fuzz`: many small inline images, each with "EI" (followed
/// by a space) inside its data, and no text anywhere. To decide whether such an "EI" ends the
/// data, the parser re-read the rest of the stream as content, and that re-read did the same
/// for every inline image it met, nesting through the whole stream: 50 images (3 KB) ran for
/// over ten minutes. Vendored hayro-syntax patch: the re-read doesn't nest, and the search is
/// bounded.
#[test]
fn inline_images_with_ei_in_their_data_parse_in_linear_time() {
let image = b"q 1 0 0 1 0 0 cm\nBI\n/IM true\n/W 8\n/H 2\n/BPC 1\nID \x01 EI \x02\nEI Q\n";
let content: Vec<u8> = image.iter().copied().cycle().take(image.len() * 200).collect();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut iter = hayro::hayro_syntax::content::TypedIter::new(&content);
let mut ops = 0usize;
while iter.next().is_some() {
ops += 1;
}
let _ = tx.send(ops);
});
let ops = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("inline images must not stall the parser");
assert!(ops >= 200 * 4, "every image and its q/cm/Q are read: {ops}");
}
/// From the nightly `cargo xtask fuzz`: a JBIG2 image whose stream declares a page and a
/// generic region of 65535 × 65535 pixels (each side within JBIG2's limit) behind a small
/// /Width and /Height: decoding its 4.3 billion pixels one by one took minutes. Vendored
/// hayro-jbig2 patch: at most 2^28 pixels per bitmap, and 2^26 per symbol dictionary.
#[test]
fn jbig2_regions_of_billions_of_pixels_are_refused() {
// Embedded JBIG2 segments (ISO 14492 §7.2): page information, then an immediate generic
// region, both 65535 × 65535, followed by arithmetic-coded data.
let mut jbig2 = Vec::new();
let mut segment = |number: u32, kind: u8, data: &[u8]| {
jbig2.extend_from_slice(&number.to_be_bytes());
jbig2.extend_from_slice(&[kind, 0, 1]);
jbig2.extend_from_slice(&u32::try_from(data.len()).unwrap().to_be_bytes());
jbig2.extend_from_slice(data);
};
let side = 65535u32.to_be_bytes();
let mut page = Vec::new();
page.extend_from_slice(&side);
page.extend_from_slice(&side);
page.extend_from_slice(&[0; 8]); // resolution
page.extend_from_slice(&[0, 0, 0]); // flags, striping
segment(0, 48, &page);
let mut region = Vec::new();
region.extend_from_slice(&side);
region.extend_from_slice(&side);
region.extend_from_slice(&[0; 9]); // x, y, combination operator
region.push(0); // arithmetic coding, template 0
region.extend_from_slice(&[3, 0xff, 0xfd, 0xff, 2, 0xfe, 0xfe, 0xfe]); // AT pixels
region.extend_from_slice(&[0x5a; 64]);
segment(1, 38, &region);
let content = b"q 20 0 0 20 5 5 cm /Im1 Do Q 1 0 0 rg 0 0 4 4 re f";
let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n".to_vec();
pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes());
pdf.extend_from_slice(content);
pdf.extend_from_slice(b"\nendstream endobj\n");
pdf.extend_from_slice(
format!("5 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 8 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n", jbig2.len())
.as_bytes(),
);
pdf.extend_from_slice(&jbig2);
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("a huge JBIG2 region must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws");
}
/// From `cargo xtask fuzz`: one changed byte in a symbol dictionary's arithmetic-coded data
/// hung the renderer. Two of its loops can stop advancing: an export run of length 0, and a
/// height class whose first width is already the end-of-class marker. Past the end of its
/// data the decoder can return those for ever. Vendored hayro-jbig2 patch: at most one height
/// class per new symbol and `2 × symbols + 1` export runs, each plus `EXTRA_EMPTY_STEPS`.
/// These synthetic dictionaries (two new symbols each, a few bytes of arbitrary data) decode
/// the same way. In the second every class after the first symbol adds 13 to the height, so it
/// only fails when the height overflows `u32`, after 330 million empty classes (about five
/// seconds; a delta of 0 would never end). The page draws it as twenty images, so that the
/// stall without the patch is well past the timeout on any machine.
#[test]
fn jbig2_symbol_dictionaries_that_never_advance_terminate() {
for (what, data, copies) in
[("zero-length export runs", &[0x05, 0x70, 0x05, 0x14, 0x02, 0x1d, 0x56, 0x3f][..], 1), ("empty height classes", &[0x06, 0x99][..], 20)]
{
// Embedded JBIG2 segments (ISO 14492 §7.2): page information, then a symbol dictionary.
let mut jbig2 = Vec::new();
let mut segment = |number: u32, kind: u8, data: &[u8]| {
jbig2.extend_from_slice(&number.to_be_bytes());
jbig2.extend_from_slice(&[kind, 0, 1]);
jbig2.extend_from_slice(&u32::try_from(data.len()).unwrap().to_be_bytes());
jbig2.extend_from_slice(data);
};
let mut page = Vec::new();
page.extend_from_slice(&8u32.to_be_bytes());
page.extend_from_slice(&8u32.to_be_bytes());
page.extend_from_slice(&[0; 8]); // resolution
page.extend_from_slice(&[0, 0, 0]); // flags, striping
segment(0, 48, &page);
let mut dictionary = vec![0, 0]; // arithmetic coding, template 0, no refinement
dictionary.extend_from_slice(&[3, 0xff, 0xfd, 0xff, 2, 0xfe, 0xfe, 0xfe]); // AT pixels
dictionary.extend_from_slice(&1u32.to_be_bytes()); // exported symbols
dictionary.extend_from_slice(&2u32.to_be_bytes()); // new symbols
dictionary.extend_from_slice(data);
segment(1, 0, &dictionary);
// Each copy is its own image XObject (5, 6, …), all drawn by the page.
let images: Vec<usize> = (5..5 + copies).collect();
let mut content: String = images.iter().map(|n| format!("q 20 0 0 20 5 5 cm /Im{n} Do Q ")).collect();
content.push_str("1 0 0 rg 0 0 4 4 re f");
let names: String = images.iter().map(|n| format!("/Im{n} {n} 0 R ")).collect();
let mut pdf = format!("%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << {names}>> >> >> endobj\n").into_bytes();
pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", content.len()).as_bytes());
for n in &images {
pdf.extend_from_slice(
format!("{n} 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 8 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n", jbig2.len())
.as_bytes(),
);
pdf.extend_from_slice(&jbig2);
pdf.extend_from_slice(b"\nendstream endobj\n");
}
pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n");
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
});
let page = rx
.recv_timeout(std::time::Duration::from_secs(20))
.unwrap_or_else(|_| panic!("{what}: a JBIG2 symbol dictionary must not stall the renderer"));
assert!(page.error.is_none(), "{what}: {:?}", page.error);
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "{what}: the rest of the page draws");
}
}
/// Zlib data that inflates to `len` zero bytes.
fn zlib_zeros(len: usize) -> Vec<u8> {
use std::io::Write;
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best());
let chunk = vec![0u8; 1 << 20];
let mut left = len;
while left > 0 {
let n = left.min(chunk.len());
z.write_all(&chunk[..n]).unwrap();
left -= n;
}
z.finish().unwrap()
}
/// A raw deflate stream that flate2 rejects at once and the fallback inflater expands to at
/// least `len` zero bytes: a one-byte stored block whose length check is wrong (flate2 refuses
/// it, the fallback carries on), then a fixed-Huffman block of 258-byte copies.
fn deflate_for_the_fallback(len: usize) -> Vec<u8> {
let mut out = vec![0b000]; // not final, stored; the rest of the byte is padding
out.extend_from_slice(&[1, 0, 0, 0, 0]); // LEN 1, NLEN 0 (should be !1), the byte 0
let (mut acc, mut bits) = (0u64, 0u32);
// Deflate packs bits from the least significant end; Huffman codes go most significant
// bit first.
let mut put = |value: u32, n: u32, huffman: bool| {
for i in 0..n {
let bit = if huffman { (value >> (n - 1 - i)) & 1 } else { (value >> i) & 1 };
acc |= u64::from(bit) << bits;
bits += 1;
if bits == 8 {
out.push(acc as u8);
(acc, bits) = (0, 0);
}
}
};
put(1, 1, false); // final block
put(1, 2, false); // fixed Huffman codes
for _ in 0..len / 258 + 1 {
put(0b1100_0101, 8, true); // length code 285: 258 bytes
put(0, 5, true); // distance code 0: 1 byte back
}
put(0, 7, true); // end of block
put(0, 7, false); // flush
out
}
/// LZW codes (MSB first, early change) for `fills` table fills of zero runs: after a clear,
/// code 0, then every code is the one being defined (the previous entry plus a zero), so
/// entry n holds n - 256 zeros. One fill is about 7 MB of output from 5 KB of codes.
fn lzw_zeros(fills: usize) -> Vec<u8> {
let width = |size: usize| match size + 1 {
2048.. => 12,
1024.. => 11,
512.. => 10,
_ => 9,
};
let (mut out, mut acc, mut bits) = (Vec::new(), 0u64, 0u32);
let mut emit = |code: usize, w: u32| {
acc = (acc << w) | code as u64;
bits += w;
while bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
}
};
let mut size = 258;
for _ in 0..fills {
emit(256, width(size)); // clear
size = 258;
emit(0, width(size));
for code in 258..4096 {
emit(code, width(size));
size = code + 1;
}
}
emit(257, width(size)); // end of data
emit(0, 7); // flush the last partial byte
out
}
/// Found while triaging `cargo xtask fuzz` on Windows, by hand rather than by the fuzzer
/// (random mutations almost never make a high-ratio stream): a 2 MB page whose content
/// stream, or whose 8 × 8 image, inflated to 2 GB took the renderer past 3 GB in under a
/// second. Flate (through flate2 and through the fallback inflater), LZW and RunLength expand
/// without limit. Vendored hayro-syntax patch: each stops at `MAX_DECODED_STREAM`, and an image
/// of known size at its own rows.
#[test]
fn decompression_bombs_stop_at_the_stream_limit() {
use hayro::hayro_syntax::object::stream::ImageDecodeParams;
use hayro::hayro_syntax::object::{Object, Stream};
let limit = hayro::hayro_syntax::MAX_DECODED_STREAM;
let big = limit + (32 << 20);
let mut run_length = [0x81u8, 0].repeat(big / 128 + 1); // runs of 128 zeros
run_length.push(128);
let streams: [(&str, Vec<u8>); 4] = [
("/FlateDecode", zlib_zeros(big)),
("/FlateDecode", deflate_for_the_fallback(big)),
("/LZWDecode", lzw_zeros(big / 7_000_000 + 1)),
("/RunLengthDecode", run_length),
];
let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] >> endobj\n".to_vec();
for (i, (filter, data)) in streams.iter().enumerate() {
pdf.extend_from_slice(format!("{} 0 obj << /Filter {filter} /Length {} >> stream\n", i + 4, data.len()).as_bytes());
pdf.extend_from_slice(data);
pdf.extend_from_slice(b"\nendstream endobj\n");
}
pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n");
let doc = Pdf::new(pdf).expect("parses");
let found: Vec<Stream<'_>> = doc.objects().into_iter().filter_map(|o| if let Object::Stream(s) = o { Some(s) } else { None }).collect();
assert_eq!(found.len(), 4, "all four bomb streams are found");
for stream in &found {
let what = format!("{:?}", stream.filters());
let decoded = stream.decoded().expect("a bomb still decodes, up to the limit");
assert_eq!(decoded.len(), limit, "{what}: stops at MAX_DECODED_STREAM, not at {big} bytes");
// An 8 × 8 one-byte grey image has 64 pixel bytes, plus at most a predictor tag byte
// for each.
let params = ImageDecodeParams { bpc: Some(8), num_components: Some(1), width: 8, height: 8, ..ImageDecodeParams::default() };
let image = stream.decoded_image(&params).expect("decodes as an image");
assert!(image.data.len() <= 128, "{what}: an 8 × 8 image decodes at most 128 bytes, not {}", image.data.len());
}
}
/// Review of the decompression-bomb fix: one stream stopping at `MAX_DECODED_STREAM` isn't
/// enough when streams add up. A page's `/Contents` array can name the same bomb many times,
/// and a form that paints itself nests fifty deep, each level keeping its content while the
/// next runs. Vendored patches: a `/Contents` array shares one `MAX_DECODED_STREAM`
/// (hayro-syntax), and page contents, forms, tiling patterns and Type 3 glyphs share a
/// per-page budget (hayro-interpret `MAX_PAGE_CONTENT`).
#[test]
fn page_contents_and_nested_forms_share_one_budget() {
let limit = hayro::hayro_syntax::MAX_DECODED_STREAM;
let bomb = zlib_zeros(limit + (32 << 20));
// Three references to one bomb in /Contents decode no more than one stream's worth.
let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents [4 0 R 4 0 R 4 0 R] >> endobj\n".to_vec();
pdf.extend_from_slice(format!("4 0 obj << /Filter /FlateDecode /Length {} >> stream\n", bomb.len()).as_bytes());
pdf.extend_from_slice(&bomb);
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
let doc = Pdf::new(pdf).expect("parses");
let page = doc.pages().first().expect("one page");
let contents = page.page_stream().map_or(0, <[u8]>::len);
assert!(contents <= limit + 3, "the /Contents array decodes {contents} bytes, over one stream's {limit}");
// A form that paints itself before 256 MiB of spaces: every level would keep its content.
let mut content = b"/Fm0 Do ".to_vec();
content.resize(limit, b' ');
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best());
std::io::Write::write_all(&mut z, &content).unwrap();
let form = z.finish().unwrap();
drop(content);
let page_content = b"/Fm0 Do 1 0 0 rg 0 0 4 4 re f";
let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Fm0 5 0 R >> >> >> endobj\n".to_vec();
pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", page_content.len()).as_bytes());
pdf.extend_from_slice(page_content);
pdf.extend_from_slice(b"\nendstream endobj\n");
pdf.extend_from_slice(
format!("5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Resources << /XObject << /Fm0 5 0 R >> >> /Filter /FlateDecode /Length {} >> stream\n", form.len()).as_bytes(),
);
pdf.extend_from_slice(&form);
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("a self-painting form must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws");
}
/// The page content budget counts what is held at once, not everything decoded: a form
/// with 2 MiB of content painted three hundred times (600 MiB in all) draws every time.
#[test]
fn content_painted_again_and_again_keeps_drawing() {
let mut content = b"1 0 0 rg 0 0 4 4 re f ".to_vec();
content.resize(2 << 20, b' ');
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best());
std::io::Write::write_all(&mut z, &content).unwrap();
let form = z.finish().unwrap();
let mut page_content = "/Fm0 Do ".repeat(299);
page_content.push_str("q 1 0 0 1 30 30 cm /Fm0 Do Q");
let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Fm0 5 0 R >> >> >> endobj\n".to_vec();
pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n{page_content}\nendstream endobj\n", page_content.len()).as_bytes());
pdf.extend_from_slice(
format!("5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Filter /FlateDecode /Length {} >> stream\n", form.len()).as_bytes(),
);
pdf.extend_from_slice(&form);
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
assert!(page.error.is_none(), "{:?}", page.error);
// The last paint is at user (30, 30)–(34, 34): device row 40 - 32 = 8.
assert_eq!(&page.rgba[((8 * 40 + 32) * 4)..][..4], &[255, 0, 0, 255], "the 300th paint is drawn");
}
/// Review of the decompression-bomb fix: a Type 0 sampled function read every sample of its
/// stream into a 4-byte value, then a table entry each, so 256 MiB of 1-bit samples asked for
/// over 8 GB. Vendored hayro-interpret patch: at most `MAX_SAMPLE_VALUES` samples, read no
/// further than `/Size` asks.
#[test]
fn sampled_functions_with_billions_of_samples_are_refused() {
let bomb = zlib_zeros(hayro::hayro_syntax::MAX_DECODED_STREAM + (32 << 20));
let page_content = b"/Sh0 sh 1 0 0 rg 0 0 4 4 re f";
let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /Shading << /Sh0 5 0 R >> >> >> endobj\n".to_vec();
pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", page_content.len()).as_bytes());
pdf.extend_from_slice(page_content);
pdf.extend_from_slice(
b"\nendstream endobj\n5 0 obj << /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 40 0] /Function 6 0 R >> endobj\n",
);
pdf.extend_from_slice(
format!("6 0 obj << /FunctionType 0 /Domain [0 1] /Range [0 1] /Size [1073741824] /BitsPerSample 1 /Filter /FlateDecode /Length {} >> stream\n", bomb.len()).as_bytes(),
);
pdf.extend_from_slice(&bomb);
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("a sampled-function bomb must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws");
}
/// From `cargo xtask fuzz`: a mesh shading whose points lie far off the page (a 147-byte
/// Coons patch stream decoded onto ±40000) was sampled pixel by pixel over its whole
/// bounding box, up to 65536 × 65536 entries: a synthetic page passed 6 GB in six seconds.
/// Vendored hayro-interpret patch: mesh shadings are sampled only where they are drawn,
/// within eight visits per pixel of that area plus four per triangle in it.
#[test]
fn mesh_shadings_far_off_the_page_render() {
let render = |shading: Vec<u8>| {
let content = b"/Sh0 sh 1 0 0 rg 0 0 4 4 re f";
let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /Shading << /Sh0 5 0 R >> >> >> endobj\n".to_vec();
pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes());
pdf.extend_from_slice(content);
pdf.extend_from_slice(b"\nendstream endobj\n5 0 obj ");
pdf.extend_from_slice(&shading);
pdf.extend_from_slice(b" endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("a mesh shading must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
page
};
let stream = |dict: &str, data: &[u8]| {
let mut s = format!("<< {dict} /Length {} >> stream\n", data.len()).into_bytes();
s.extend_from_slice(data);
s.extend_from_slice(b"\nendstream");
s
};
// One Coons patch (flag, 12 points, 4 colours) with 8-bit coordinates around its square.
let mut coons = vec![0u8];
for p in [(0, 0), (0, 85), (0, 170), (0, 255), (85, 255), (170, 255), (255, 255), (255, 170), (255, 85), (255, 0), (170, 0), (85, 0)] {
coons.extend_from_slice(&[p.0, p.1]);
}
coons.extend_from_slice(&[255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 0]);
// A free-form triangle mesh: three vertices (flag, x, y, colour).
let triangles = [0u8, 0, 0, 255, 0, 0, 0, 255, 0, 0, 255, 0, 0, 0, 255, 0, 0, 255];
let mesh = "/ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8";
let far = "/Decode [-40000 40000 -40000 40000 0 1 0 1 0 1]";
for (what, shading) in [
("a Coons patch", stream(&format!("/ShadingType 6 {mesh} {far}"), &coons)),
("a triangle mesh", stream(&format!("/ShadingType 4 {mesh} {far}"), &triangles)),
] {
let page = render(shading);
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "{what} far off the page: the rest of the page draws");
}
// A patch on the page, and the part of the huge one that covers it, are still shaded:
// the centre is neither white nor transparent.
for (what, decode) in [("on the page", "[0 40 0 40 0 1 0 1 0 1]"), ("covering the page", "[-40000 40000 -40000 40000 0 1 0 1 0 1]")] {
let page = render(stream(&format!("/ShadingType 6 {mesh} /Decode {decode}"), &coons));
let centre = &page.rgba[((20 * 40 + 20) * 4)..][..4];
assert!(centre != [255, 255, 255, 255] && centre[3] == 255, "a patch {what} is shaded: {centre:?}");
}
// A patch filling a 4 × 4 pt rectangle as a pattern is 722 triangles of a fraction of a
// pixel each: far more visits than its 16 pixels give, which the per-triangle allowance
// covers. The rectangle's middle is shaded.
let content = b"/Pattern cs /P0 scn 10 10 4 4 re f";
let shading = stream(&format!("/ShadingType 6 {mesh} /Decode [10 14 10 14 0 1 0 1 0 1]"), &coons);
let mut pdf = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /Pattern << /P0 << /PatternType 2 /Shading 5 0 R >> >> >> >> endobj
".to_vec();
pdf.extend_from_slice(
format!(
"4 0 obj << /Length {} >> stream
",
content.len()
)
.as_bytes(),
);
pdf.extend_from_slice(content);
pdf.extend_from_slice(
b"
endstream endobj
5 0 obj ",
);
pdf.extend_from_slice(&shading);
pdf.extend_from_slice(
b" endobj
trailer << /Root 1 0 R >>
%%EOF
",
);
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
assert!(page.error.is_none(), "{:?}", page.error);
let middle = &page.rgba[((28 * 40 + 12) * 4)..][..4];
assert!(middle != [255, 255, 255, 255] && middle[3] == 255, "a small patch is shaded: {middle:?}");
}
/// The other half of the mesh-shading patch: ten thousand triangles that each cover half
/// of this 1000 × 1000 canvas walked their bounding boxes ten thousand times over (ten
/// billion visits). The visit budget stops that work at eight per pixel plus four per
/// triangle.
#[test]
fn many_overlapping_mesh_triangles_finish() {
// Each triangle: three vertices of flag, x, y, red, green, blue; half of the page.
let one = [0u8, 0, 0, 255, 0, 0, 0, 255, 0, 0, 255, 0, 0, 0, 255, 0, 0, 255];
let triangles = one.repeat(10_000);
let content = b"/Sh0 sh";
let mut pdf = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /Shading << /Sh0 5 0 R >> >> >> endobj
"
.to_vec();
pdf.extend_from_slice(
format!(
"4 0 obj << /Length {} >> stream
",
content.len()
)
.as_bytes(),
);
pdf.extend_from_slice(content);
pdf.extend_from_slice(
format!("
endstream endobj
5 0 obj << /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 40 0 40 0 1 0 1 0 1] /Length {} >> stream
", triangles.len())
.as_bytes(),
);
pdf.extend_from_slice(&triangles);
pdf.extend_from_slice(
b"
endstream endobj
trailer << /Root 1 0 R >>
%%EOF
",
);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 25.0, tag: 0 }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("overlapping mesh triangles must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!((page.width, page.height), (1000, 1000));
// The triangles cover the lower left half: a pixel there is shaded.
let p = &page.rgba[((900 * 1000 + 100) * 4)..][..4];
assert!(p != [255, 255, 255, 255] && p[3] == 255, "the triangles are drawn: {p:?}");
}
/// Review of the mesh-shading patch: every patch was cut into 722 triangles before anything
/// limited them (about 150 KB per patch), and a lattice mesh with `/VerticesPerRow 0` pushed
/// empty rows for ever. Vendored hayro-interpret patch: patches are triangulated one at a
/// time, meshes hold at most `MAX_MESH_PATCHES` patches and `MAX_MESH_TRIANGLES` triangles,
/// and a lattice needs two vertices per row.
#[test]
fn mesh_shadings_with_many_patches_or_no_rows_finish() {
let render = |shading: Vec<u8>| {
let content = b"/Sh0 sh 1 0 0 rg 0 0 4 4 re f";
let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /Shading << /Sh0 5 0 R >> >> >> endobj\n".to_vec();
pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes());
pdf.extend_from_slice(content);
pdf.extend_from_slice(b"\nendstream endobj\n5 0 obj ");
pdf.extend_from_slice(&shading);
pdf.extend_from_slice(b" endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("a mesh shading must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws");
};
let stream = |dict: &str, data: &[u8]| {
let mut s = format!("<< {dict} /Length {} >> stream\n", data.len()).into_bytes();
s.extend_from_slice(data);
s.extend_from_slice(b"\nendstream");
s
};
let mesh = "/ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8";
// Forty thousand Coons patches beside the page: 6 GB of triangles if collected first.
let mut coons = vec![0u8];
for p in [(0, 0), (0, 85), (0, 170), (0, 255), (85, 255), (170, 255), (255, 255), (255, 170), (255, 85), (255, 0), (170, 0), (85, 0)] {
coons.extend_from_slice(&[p.0, p.1]);
}
coons.extend_from_slice(&[255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 0]);
render(stream(&format!("/ShadingType 6 {mesh} /BitsPerFlag 8 /Decode [100 140 100 140 0 1 0 1 0 1]"), &coons.repeat(40_000)));
// A lattice with no vertices per row.
render(stream(&format!("/ShadingType 5 {mesh} /VerticesPerRow 0 /Decode [0 40 0 40 0 1 0 1 0 1]"), &[0u8; 64]));
}
/// From the nightly `cargo xtask fuzz`: an embedded Type 1 font program holding a long run of
/// integers. read-fonts 0.39 (through skrifa 0.42) looked ahead after every integer by
/// parsing the next token, which looked ahead again, recursing through the whole run: a long
/// run hung, and 20,000 numbers overflowed the stack and aborted the process. Fixed upstream:
/// the vendored hayro-interpret now uses skrifa 0.47 (read-fonts 0.44).
#[test]
fn type1_font_programs_with_long_runs_of_numbers_load_quickly() {
let mut font = b"%!PS-AdobeFont-1.0: Fuzz 001\n/FontMatrix [0.001 0 0 0.001 0 0] readonly def\n".to_vec();
for _ in 0..20_000 {
font.extend_from_slice(b"1 ");
}
let content = b"BT /F1 12 Tf 5 20 Td (Hi) Tj ET 1 0 0 rg 0 0 4 4 re f";
let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\n".to_vec();
pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes());
pdf.extend_from_slice(content);
pdf.extend_from_slice(b"\nendstream endobj\n5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Fuzz /FontDescriptor 6 0 R >> endobj\n");
pdf.extend_from_slice(b"6 0 obj << /Type /FontDescriptor /FontName /Fuzz /Flags 32 /FontBBox [0 0 1000 1000] /ItalicAngle 0 /Ascent 800 /Descent -200 /CapHeight 700 /StemV 80 /FontFile 7 0 R >> endobj\n");
pdf.extend_from_slice(format!("7 0 obj << /Length {} /Length1 {} /Length2 0 /Length3 0 >> stream\n", font.len(), font.len()).as_bytes());
pdf.extend_from_slice(&font);
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("a Type 1 font must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws");
}
/// From `cargo xtask fuzz`: an inline image claiming /W 4294967295 over four bytes of data
/// hung in resampling (vendored hayro patch: `MAX_IMAGE_PIXELS`).
#[test]
fn absurd_image_dimensions_are_skipped() {
let content = "q 20 0 0 20 5 5 cm BI /W 4294967295 /H 2 /BPC 8 /CS /G ID \u{0}\u{ff}\u{ff}\u{0} EI Q 1 0 0 rg 0 0 4 4 re f";
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len()
);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("an absurd image must not hang the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
// The rest of the page still draws: the red square at the bottom left (y-down: last rows).
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255]);
}
/// From `cargo xtask fuzz`: a CID font whose /W range spans every u32 inserted billions of
/// widths (vendored hayro-interpret patch: `MAX_CID`). Must finish quickly.
#[test]
fn huge_cid_width_ranges_terminate() {
let pdf = "%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 40] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
4 0 obj << /Length 35 >> stream
BT /F1 12 Tf 10 10 Td <0041> Tj ET
endstream endobj
5 0 obj << /Type /Font /Subtype /Type0 /BaseFont /Helvetica /Encoding /Identity-H /DescendantFonts [6 0 R] >> endobj
6 0 obj << /Type /Font /Subtype /CIDFontType2 /BaseFont /Helvetica /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >>
/FontDescriptor 7 0 R /W [0 4294967295 500] /W2 [0 4294967295 -1000 250 880] >> endobj
7 0 obj << /Type /FontDescriptor /FontName /Helvetica /Flags 32 /FontBBox [0 -200 1000 900] /ItalicAngle 0 /Ascent 800 /Descent -200 /CapHeight 700 /StemV 80 >> endobj
trailer << /Root 1 0 R >>
%%EOF";
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf.as_bytes().to_vec()), RenderConfig::default());
let px = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
let text = r.render(RenderRequest { page: 0, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 });
let _ = tx.send((px.error, text.error));
});
let (px, text) = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("rendering a huge /W range must not hang");
assert!(px.is_none() && text.is_none(), "{px:?} {text:?}");
}
/// "BBBA" at 20 pt in a Type 3 font whose glyph (for both codes) is a solid 300 × 700 box,
/// with the given /FirstChar, /LastChar and /Widths. No font program is needed, and Type 3
/// widths are read like those of every other simple font.
fn type3_boxes(widths: &str) -> Vec<u8> {
let content = "BT /F1 20 Tf 20 40 Td (BBBA) Tj ET";
format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] /FontMatrix [0.001 0 0 0.001 0 0] {widths}
/Encoding << /Differences [65 /a /a] >> /CharProcs << /a 6 0 R >> >> endobj
6 0 obj << /Length 27 >> stream
600 0 d0 100 0 400 700 re f
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len()
)
.into_bytes()
}
/// Simple-font widths (vendored hayro-interpret patch to `read_widths`): an entry of /Widths
/// that isn't a number ended the array, so every later code lost its width, and a /LastChar
/// below /FirstChar (or one that overflowed) made the whole font fail to load, replaced by a
/// standard font. MuPDF, Poppler, PDFium and pdf.js skip just that entry and keep the font.
#[test]
fn malformed_simple_font_widths_keep_later_widths_and_the_font() {
let render = |widths: &str| {
let mut r = PageRenderer::new(Arc::new(type3_boxes(widths)), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
assert!(p.error.is_none(), "{widths}: {:?}", p.error);
p
};
// Glyph boxes span x 22..28 plus 12 pt per advance, and y 46..60 (y down).
let inked = |p: &RenderedPage, x: u32| (47..59).all(|y| p.rgba[((y * p.width + x) * 4) as usize] < 64);
for widths in [
"/FirstChar 65 /LastChar 66 /Widths [600 600]",
"/FirstChar 65 /LastChar 66 /Widths [null 600]",
"/FirstChar 65 /LastChar 66 /Widths [/W 600]",
] {
let p = render(widths);
assert!([25, 37, 49, 61].iter().all(|x| inked(&p, *x)), "{widths}: each B advances 600");
assert!(!inked(&p, 31), "{widths}");
}
for widths in ["/FirstChar 66 /LastChar 65 /Widths [600 600]", "/FirstChar 4294967295 /LastChar 66 /Widths [600 600]"] {
let p = render(widths);
// The font's own box, drawn without advances (no width applies), not a stand-in font.
assert!(inked(&p, 23) && inked(&p, 27), "{widths}: the Type 3 glyph is drawn");
assert!(!inked(&p, 37), "{widths}: no width, no advance");
}
}
/// Simple-font widths (vendored hayro-interpret patch to `read_widths`): /FirstChar sized an
/// allocation, one entry per code below it, before any width was read. 50,000,000 already
/// took 276 MB; 4294967295 asks for 34 GB. Codes of a simple font stop at 255.
#[test]
fn huge_simple_font_first_char_terminates() {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(type3_boxes("/FirstChar 4294967295 /LastChar 4294967295 /Widths [600]")), RenderConfig::default());
let px = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
let text = r.render(RenderRequest { page: 0, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 });
let _ = tx.send((px, text.error));
});
let (px, text) = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("a huge /FirstChar must not exhaust memory");
assert!(px.error.is_none() && text.is_none(), "{:?} {text:?}", px.error);
assert!(px.rgba[((50 * px.width + 25) * 4) as usize] < 64, "the glyph is drawn");
}
#[test]
fn tiles_match_full_render() {
let mut r = PageRenderer::new(Arc::new(ONE_PAGE.to_vec()), RenderConfig::default());
let full = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0 });
let tile = Tile { x: 30, y: 40, w: 50, h: 30 };
let part = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: Some(tile), scale: 2.0, tag: 0 });
assert_eq!((part.width, part.height), (50, 30));
for y in 0..30u32 {
for x in 0..50u32 {
let a = &full.rgba[(((y + 40) * full.width + x + 30) * 4) as usize..][..4];
let b = &part.rgba[((y * 50 + x) * 4) as usize..][..4];
assert_eq!(a, b, "pixel {x},{y}");
}
}
}
/// A Japanese CID font that isn't embedded (Adobe-Japan1, as `HeiseiMin-W3` with
/// `UniJIS-UCS2-H` in #260's test file) drew nothing: hayro's substitutes for fonts that
/// aren't embedded are Latin-only. With craft-fonts (the build input release builds embed),
/// such text now draws in a Japanese face; without it, nothing changes.
#[test]
fn non_embedded_japanese_cid_fonts_draw_with_a_craft_fonts_face() {
let pdf = |base_font: &str| {
let content = "BT /F1 40 Tf 5 15 Td <65E5672C> Tj ET";
format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 60] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /Font /Subtype /Type0 /BaseFont /{base_font} /Encoding /UniJIS-UCS2-H /DescendantFonts [6 0 R] >> endobj
6 0 obj << /Type /Font /Subtype /CIDFontType0 /BaseFont /{base_font} /CIDSystemInfo << /Registry (Adobe) /Ordering (Japan1) /Supplement 2 >>
/FontDescriptor 7 0 R /DW 1000 >> endobj
7 0 obj << /Type /FontDescriptor /FontName /{base_font} /Flags 6 /FontBBox [0 -141 1000 859] /ItalicAngle 0 /Ascent 859 /Descent -141 /CapHeight 700 /StemV 80 >> endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len()
)
};
for base_font in ["HeiseiMin-W3", "HeiseiKakuGo-W5"] {
let mut r = PageRenderer::new(Arc::new(pdf(base_font).into_bytes()), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
assert!(p.error.is_none(), "{base_font}: {:?}", p.error);
let inked = p.rgba.as_chunks::<4>().0.iter().filter(|c| c[0] < 128).count();
if pdfcraft_fonts::document_japanese_font().is_none() {
eprintln!("built without craft-fonts (CRAFT_FONTS_DIR unset): no Japanese face to check");
continue;
}
// 日本 at 40 pt covers a few hundred dark pixels; a blank or missing-glyph run doesn't.
assert!(inked > 300, "{base_font}: 日本 is drawn ({inked} dark pixels)");
}
}
/// Half-width katakana are common in Japanese documents set in a non-embedded Mincho font
/// such as HeiseiMin-W3. The Mincho substitute must have those glyphs: one that lacks them
/// (Shippori Mincho) draws them as .notdef.
#[test]
fn mincho_substitute_covers_half_width_katakana() {
use hayro::hayro_interpret::font::FallbackFontQuery;
use hayro::hayro_interpret::hayro_cmap::CharacterCollection;
use skrifa::MetadataProvider;
let query = FontQuery::Fallback(FallbackFontQuery {
post_script_name: Some("HeiseiMin-W3".into()),
character_collection: Some(CharacterCollection { family: CidFamily::AdobeJapan1, supplement: 2 }),
..FallbackFontQuery::default()
});
let Some((data, index)) = super::japanese_fallback(&query) else {
eprintln!("built without craft-fonts (CRAFT_FONTS_DIR unset): no Japanese face to check");
return;
};
let font = skrifa::FontRef::from_index((*data).as_ref(), index).expect("a craft-fonts face parses");
let charmap = font.charmap();
let missing: Vec<char> = ('\u{FF61}'..='\u{FF9F}').chain(['日', '本']).filter(|&c| charmap.map(c).is_none()).collect();
assert!(missing.is_empty(), "the Mincho substitute lacks {missing:?}");
}
/// 𠮷 (U+20BB7, Adobe-Japan1 CID 13706) in a non-embedded Japanese font: neither craft-fonts
/// substitute has it, and hayro drew the substitute's glyph 13706 instead, which is
/// unrelated (in BIZ UDPGothic, "Q"). It draws the substitute's .notdef now, while 吉, which
/// the substitutes have, still draws.
#[test]
fn cids_a_substitute_lacks_are_not_drawn_as_other_glyphs() {
if pdfcraft_fonts::document_japanese_font().is_none() {
eprintln!("built without craft-fonts (CRAFT_FONTS_DIR unset): no Japanese face to check");
return;
}
let render = |base_font: &str, encoding: &str, code: &str| {
let content = format!("BT /F1 40 Tf 5 15 Td <{code}> Tj ET");
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 60 60] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /Font /Subtype /Type0 /BaseFont /{base_font} /Encoding /{encoding} /DescendantFonts [6 0 R] >> endobj
6 0 obj << /Type /Font /Subtype /CIDFontType0 /BaseFont /{base_font} /CIDSystemInfo << /Registry (Adobe) /Ordering (Japan1) /Supplement 6 >>
/FontDescriptor 7 0 R /DW 1000 >> endobj
7 0 obj << /Type /FontDescriptor /FontName /{base_font} /Flags 6 /FontBBox [0 -141 1000 859] /ItalicAngle 0 /Ascent 859 /Descent -141 /CapHeight 700 /StemV 80 >> endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len()
);
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
assert!(p.error.is_none(), "{base_font} <{code}>: {:?}", p.error);
p.rgba
};
let inked = |rgba: &[u8]| rgba.as_chunks::<4>().0.iter().filter(|c| c[0] < 128).count();
for base_font in ["HeiseiMin-W3", "HeiseiKakuGo-W5"] {
let notdef = render(base_font, "Identity-H", "0000");
let missing = render(base_font, "UniJIS-UTF16-H", "D842DFB7");
let covered = render(base_font, "UniJIS-UTF16-H", "5409");
assert!(missing == notdef, "{base_font}: 𠮷 draws .notdef ({} dark pixels, .notdef {})", inked(&missing), inked(&notdef));
assert!(inked(&covered) > 300 && covered != notdef, "{base_font}: 吉 is drawn ({} dark pixels)", inked(&covered));
}
}
#[test]
fn japanese_font_names_pick_mincho_or_gothic() {
use super::JapaneseFace::{Gothic, Mincho};
for (name, serif, bold, face) in [
("HeiseiMin-W3", false, false, Mincho),
("KozMinPro-Regular", false, false, Mincho),
("Ryumin-Light", false, false, Mincho),
("MS-PMincho", false, false, Mincho),
("HiraMinProN-W3", false, false, Mincho),
("HeiseiKakuGo-W5", true, false, Gothic { bold: false }),
("KozGoPro-Bold", true, true, Gothic { bold: true }),
("GothicBBB-Medium", true, false, Gothic { bold: false }),
("MS-Gothic", false, false, Gothic { bold: false }),
("HiraKakuProN-W6", false, true, Gothic { bold: true }),
("Unknown-Japanese", true, false, Mincho),
("Unknown-Japanese", false, false, Gothic { bold: false }),
] {
assert_eq!(super::japanese_face(name, serif, bold), face, "{name}");
}
}
/// Adobe-Japan1 maps the JIS X 0208 forms of the kanji that JIS X 0213:2004 redrew (噂, 逢,
/// 溢, …) to a variation sequence: CID 1247 is 噂 U+E0100. A non-embedded Japanese font drew
/// such a CID as an unrelated glyph, since the substitute has no glyph for a sequence. It
/// draws the substitute's glyph for that form now, and its 噂 for a form it doesn't have.
#[test]
fn variation_sequences_draw_their_kanji_in_a_substitute() {
if pdfcraft_fonts::document_japanese_font().is_none() {
eprintln!("built without craft-fonts (CRAFT_FONTS_DIR unset): no Japanese face to check");
return;
}
let render = |base_font: &str, encoding: &str, to_unicode: &str, code: &str| {
let content = format!("BT /F1 40 Tf 5 15 Td <{code}> Tj ET");
let cmap = format!(
"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /Test def /CMapType 2 def 1 begincodespacerange <0000> <FFFF> endcodespacerange 1 beginbfchar <0001> <{to_unicode}> endbfchar endcmap CMapName currentdict /CMap defineresource pop end end"
);
// Code <0001> through `/ToUnicode` when the encoding is Identity-H; otherwise no
// `/ToUnicode`, so the CID goes through Adobe-Japan1-UCS2.
let to_unicode = if encoding == "Identity-H" { "/ToUnicode 8 0 R" } else { "" };
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 60 60] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /Font /Subtype /Type0 /BaseFont /{base_font} /Encoding /{encoding} /DescendantFonts [6 0 R] {to_unicode} >> endobj
6 0 obj << /Type /Font /Subtype /CIDFontType0 /BaseFont /{base_font} /CIDSystemInfo << /Registry (Adobe) /Ordering (Japan1) /Supplement 6 >>
/FontDescriptor 7 0 R /DW 1000 >> endobj
7 0 obj << /Type /FontDescriptor /FontName /{base_font} /Flags 6 /FontBBox [0 -141 1000 859] /ItalicAngle 0 /Ascent 859 /Descent -141 /CapHeight 700 /StemV 80 >> endobj
8 0 obj << /Length {} >> stream
{cmap}
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len(),
cmap.len()
);
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
assert!(p.error.is_none(), "{base_font} <{code}>: {:?}", p.error);
p.rgba
};
let inked = |rgba: &[u8]| rgba.as_chunks::<4>().0.iter().filter(|c| c[0] < 128).count();
for base_font in ["HeiseiMin-W3", "HeiseiKakuGo-W5"] {
let collection = render(base_font, "UniJIS-UCS2-H", "", "5642");
let form = render(base_font, "Identity-H", "5642DB40DD00", "0001");
let character = render(base_font, "Identity-H", "5642", "0001");
let unknown_form = render(base_font, "Identity-H", "5642DB40DD05", "0001");
assert!(inked(&form) > 300, "{base_font}: 噂 U+E0100 is drawn ({} dark pixels)", inked(&form));
assert!(collection == form, "{base_font}: CID 1247 draws 噂 U+E0100");
assert!(form != character, "{base_font}: the form differs from the face's default 噂");
assert!(unknown_form == character, "{base_font}: a form the face lacks draws its 噂");
}
}
/// An alpha soft mask whose transparency group has no /CS (as Chrome writes gradient text)
/// must still mask. Regression test for the vendored hayro-interpret patch.
#[test]
fn soft_mask_without_group_colour_space_is_applied() {
let pdf = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /ExtGState << /M 5 0 R >> >> >> endobj
4 0 obj << /Length 31 >> stream
/M gs 1 0 0 rg 0 0 100 100 re f
endstream endobj
5 0 obj << /Type /ExtGState /SMask << /Type /Mask /S /Alpha /G 6 0 R >> >> endobj
6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /I true >> /Length 20 >> stream
0 g 0 0 50 100 re f
endstream endobj
trailer << /Root 1 0 R >>
%%EOF";
let mut r = PageRenderer::new(Arc::new(pdf.to_vec()), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
assert_eq!(px(25, 50), vec![255, 0, 0, 255], "inside the mask");
assert_eq!(px(75, 50), vec![255, 255, 255, 255], "outside the mask");
}
/// ISO 32000-2 §8.5.4: `W` / `W*` clip with the current path once whichever path-painting
/// operator ends it has painted it, `S`, `f` or `B` as much as `n`. The vendored interpreter
/// applied the clip only on `n`: after `re W* S` everything later painted outside the clip,
/// and the forgotten clip stayed pending, so a later `re n` (even after `Q`) clipped
/// content that should show. MuPDF, Poppler and PDFium clip in every case below.
#[test]
fn clipping_path_applies_after_any_painting_operator() {
let render = |content: &str| {
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len()
);
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
assert!(p.error.is_none(), "{content}: {:?}", p.error);
p
};
let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
let red = vec![255, 0, 0, 255];
// The clip is the square x 20..60, y 20..60 (device rows 40..80); then the page is filled red.
for op in ["n", "S", "s", "f", "F", "f*", "B", "B*", "b", "b*"] {
for clip in ["W", "W*"] {
let p = render(&format!("q 0 0 1 RG 4 w 0.8 g 20 20 40 40 re {clip} {op} 1 0 0 rg 0 0 100 100 re f Q"));
assert_eq!(px(&p, 40, 60), red, "{clip} {op}: inside the clip");
assert_ne!(px(&p, 5, 5), red, "{clip} {op}: outside the clip");
assert_ne!(px(&p, 95, 95), red, "{clip} {op}: outside the clip");
// The operator that ends the path paints under the old clip: the outer half of
// the 4 pt stroke (x 18..20) shows outside the new one.
let stroked = !["n", "f", "F", "f*"].contains(&op);
let edge = if stroked { vec![0, 0, 255, 255] } else { vec![255, 255, 255, 255] };
assert_eq!(px(&p, 18, 60), edge, "{clip} {op}: just outside the clip");
}
}
// A clip is used once: it doesn't linger for a later `n`, inside or outside `q`/`Q`.
for content in [
"q 0 0 1 RG 70 70 10 10 re W S Q q 10 10 20 20 re n 1 0 0 rg 0 0 100 100 re f Q",
"q 0 0 1 RG 0 0 100 100 re W S 10 10 20 20 re n 1 0 0 rg 0 0 100 100 re f Q",
] {
let p = render(content);
assert!([(5, 5), (50, 50), (95, 95)].iter().all(|(x, y)| px(&p, *x, *y) == red), "{content}: no stray clip");
}
}
}