From 2f88c5e192f4d3bd527f1c9876045c4dced24f50 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:18:01 +0000 Subject: [PATCH] refactor(render): move raster.rs's tests to raster/tests.rs --- crates/render/src/raster.rs | 7080 +---------------------------- crates/render/src/raster/tests.rs | 7077 ++++++++++++++++++++++++++++ 2 files changed, 7078 insertions(+), 7079 deletions(-) create mode 100644 crates/render/src/raster/tests.rs diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index e906144e4..7dac8cb0d 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -1252,7082 +1252,4 @@ fn worker( } #[cfg(test)] -mod tests { - #[cfg(not(target_arch = "wasm32"))] - fn render_gate(page: usize, shared_generation: bool) -> super::RenderGate { - let barrier = || std::sync::Arc::new(std::sync::Barrier::new(2)); - super::RenderGate { - page, - shared_generation, - claimed: Arc::new(std::sync::atomic::AtomicBool::new(false)), - entered: barrier(), - release: barrier(), - finished: barrier(), - } - } - - #[cfg(not(target_arch = "wasm32"))] - fn parse_gate() -> super::ParseGate { - let barrier = || std::sync::Arc::new(std::sync::Barrier::new(2)); - super::ParseGate { entered: barrier(), release: barrier() } - } - - #[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()); - } - - /// The waits here only guard against a hang; they don't time anything. The initializer is - /// held until the end of the test, so a waiter that can't cancel waits forever and fails the - /// guard however long it is, and a correct one returns as soon as its thread runs. Two - /// seconds was too short for a busy FreeBSD VM in CI to start the threads (#1970). - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn waiting_worker_can_cancel_before_initializer_returns() { - use std::{ - sync::{atomic::AtomicUsize, mpsc}, - time::Duration, - }; - const HANG_GUARD: Duration = Duration::from_secs(600); - 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(); - // A failed test drops the sender: that ends the hold, it is not a - // second failure. - let _ = release_rx.recv(); - Pdf::new(ONE_PAGE.to_vec()).ok() - }, - || false, - ) - }) - }; - started_rx.recv_timeout(HANG_GUARD).expect("the initializer starts"); - 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() + HANG_GUARD; - while checks.load(Ordering::Relaxed) == 0 { - assert!(std::time::Instant::now() < deadline, "the waiter never checked for cancellation"); - std::thread::sleep(Duration::from_millis(1)); - } - cancelled.store(true, Ordering::Release); - assert!(done_rx.recv_timeout(HANG_GUARD).expect("the waiter cancels while the initializer is held")); - waiter.join().unwrap(); - release_tx.send(()).unwrap(); - assert!(initializer.join().unwrap().unwrap().is_some()); - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn a_retired_pool_counts_its_threads_until_they_exit() { - // Idle: its thread leaves as soon as the pool is gone. - let mut idle = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default()).retire(); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); - while idle.running() > 0 && std::time::Instant::now() < deadline { - std::thread::sleep(std::time::Duration::from_millis(5)); - } - assert_eq!(idle.running(), 0); - // Busy in a step that can't be interrupted: still counted after the pool is dropped. - let pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default()); - *lock(&pool.shared.slow_page) = Some((0, std::time::Duration::from_secs(2))); - pool.set_queue(vec![RenderRequest { page: 0, scale: 1.0, ..Default::default() }]); - while pool.shared.render_started.load(Ordering::Relaxed) == 0 && std::time::Instant::now() < deadline { - std::thread::sleep(std::time::Duration::from_millis(2)); - } - let mut busy = pool.retire(); - assert_eq!(busy.running(), 1, "the render is still under way"); - while busy.running() > 0 && std::time::Instant::now() < deadline { - std::thread::sleep(std::time::Duration::from_millis(5)); - } - assert_eq!(busy.running(), 0, "and the thread exits when it returns"); - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn a_pool_without_replacements_never_runs_a_second_thread() { - // The watchdog gives up on a render stuck in one long step; with no replacement, the - // request is answered with an error and the stuck thread is the only one. - let mut pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default()).without_replacements(); - pool.set_stuck_after(std::time::Duration::from_millis(50)); - *lock(&pool.shared.slow_page) = Some((0, std::time::Duration::from_secs(2))); - pool.set_queue(vec![RenderRequest { page: 0, scale: 1.0, ..Default::default() }]); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); - let answer = loop { - assert!(pool.threads() <= 1, "{} render threads", pool.threads()); - if let Some(page) = pool.try_recv() { - break page; - } - assert!(std::time::Instant::now() < deadline, "the stuck render is answered"); - std::thread::sleep(std::time::Duration::from_millis(5)); - }; - assert!(answer.error.is_some(), "answered with an error, not a second attempt"); - assert!(pool.threads() <= 1); - let mut retired = pool.retire(); - while retired.running() > 0 { - assert!(std::time::Instant::now() < deadline, "the stuck thread exits when its step ends"); - std::thread::sleep(std::time::Duration::from_millis(5)); - } - } - - #[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); - // Hold page 1 at the shared render boundary until page 0 has crashed, so it can't finish - // in the shared generation first (then only one parse would happen). - let gate = render_gate(1, true); - *lock(&pool.shared.render_gates) = vec![gate.clone()]; - pool.set_queue(vec![ - RenderRequest { page: 0, scale: 1.0, ..Default::default() }, - RenderRequest { page: 1, scale: 1.0, ..Default::default() }, - ]); - let crashed = receive_before_deadline(&pool); - if gate.claimed.load(Ordering::Acquire) { - gate.entered.wait(); - gate.release.wait(); - } - let mut pages = [crashed, 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); - // A new generation (tag) and scale of the failed page is still refused without parsing again. - pool.set_queue(vec![RenderRequest { page: 0, tag: 1, scale: 2.0, ..Default::default() }]); - assert!(receive_before_deadline(&pool).error.as_deref().is_some_and(|message| message.contains("skipped earlier"))); - 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; - // 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); - } - let gate = parse_gate(); - *lock(&pool.shared.parse_gate) = Some(gate.clone()); - pool.set_stuck_after(Duration::ZERO); - let requests = if private { - vec![RenderRequest { page: 0, scale: 1.0, tag: 60, ..Default::default() }] - } else { - vec![ - RenderRequest { page: 0, scale: 1.0, tag: 60, ..Default::default() }, - RenderRequest { page: 1, scale: 1.0, tag: 61, ..Default::default() }, - ] - }; - pool.set_queue(requests.clone()); - gate.entered.wait(); - assert!(pool.try_recv().is_none(), "the watchdog must leave parsing alone"); - assert!(lock(&pool.shared.stuck).is_empty()); - assert_eq!(*lock(&pool.retired_workers), 0); - assert!(!matches!(*lock(&pool.shared.parser.state), ParserState::Failed)); - // Rendering gets its own deadline after initialization completes. - pool.set_stuck_after(Duration::from_secs(60)); - gate.release.wait(); - let mut pages: Vec<_> = (0..requests.len()).map(|_| receive_before_deadline(&pool)).collect(); - pages.sort_by_key(|page| page.request.page); - for (page, request) in pages.iter().zip(&requests) { - assert!(page.error.is_none(), "{:?}", page.error); - assert_eq!((page.width, page.height, page.request.tag), (100, 50, request.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()); - pool.set_stuck_after(Duration::ZERO); - let shared_bad = render_gate(0, true); - let shared_healthy = render_gate(1, true); - let private_bad = render_gate(0, false); - *lock(&pool.shared.render_gates) = vec![shared_bad.clone(), shared_healthy.clone(), private_bad.clone()]; - pool.set_queue(vec![ - RenderRequest { page: 0, scale: 1.0, tag: 40, ..Default::default() }, - RenderRequest { page: 1, scale: 1.0, tag: 41, ..Default::default() }, - ]); - shared_bad.entered.wait(); - shared_healthy.entered.wait(); - // This watchdog pass retries both pages privately. Page 1's retry has no gate, so on a - // loaded machine it can finish before this same call reads the results: that early - // answer is the healthy page, never a timeout error. - let early = pool.try_recv(); - assert!( - early.as_ref().is_none_or(|page| page.request.page == 1 && page.error.is_none()), - "shared timeouts retry instead of answering with an error: {:?}", - early.as_ref().map(|page| (page.request.page, &page.error)) - ); - shared_bad.release.wait(); - shared_healthy.release.wait(); - shared_bad.finished.wait(); - shared_healthy.finished.wait(); - private_bad.entered.wait(); - let healthy = match early { - Some(page) => page, - None => pool.results.recv_timeout(Duration::from_secs(30)).expect("healthy private retry"), - }; - assert!(healthy.error.is_none(), "{:?}", healthy.error); - assert_eq!((healthy.request.page, healthy.width, healthy.height, healthy.request.tag), (1, 100, 50, 41)); - pool.watchdog(); - let bad = pool.results.recv_timeout(Duration::from_secs(30)).expect("the held private retry times out"); - assert!(bad.error.as_deref().is_some_and(|message| message.contains("took longer"))); - assert_eq!(bad.request.page, 0); - private_bad.release.wait(); - private_bad.finished.wait(); - assert!(lock(&pool.shared.stuck).contains(&RequestFailureKey::from(RenderRequest { page: 0, scale: 1.0, tag: 40, ..Default::default() }))); - assert!(!lock(&pool.shared.stuck).contains(&RequestFailureKey::from(RenderRequest { page: 1, scale: 1.0, tag: 41, ..Default::default() }))); - 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::ZERO); - let shared = render_gate(0, true); - let private = render_gate(0, false); - *lock(&pool.shared.render_gates) = vec![shared.clone(), private.clone()]; - 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]); - shared.entered.wait(); - assert!(pool.try_recv().is_none(), "shared timeout schedules the private retry"); - shared.release.wait(); - shared.finished.wait(); - private.entered.wait(); - 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(&RequestFailureKey::from(healthy)), "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)); - private.release.wait(); - private.finished.wait(); - for worker in workers { - worker.join().unwrap(); - } - assert!(pool.try_recv().is_none()); - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn a_failed_page_stays_refused_at_every_scale_and_generation_but_other_pages_render() { - let pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default()); - let bad = RenderRequest { page: 0, scale: 1.0, tag: 70, ..Default::default() }; - lock(&pool.shared.stuck).insert(bad.into()); - for req in [bad, RenderRequest { scale: 0.5, tag: 71, ..bad }, RenderRequest { tile: Some(Tile { x: 0, y: 0, w: 8, h: 8 }), tag: 72, ..bad }] - { - pool.set_queue(vec![req]); - let failed = receive_before_deadline(&pool); - assert_eq!(failed.request, req); - assert!(failed.error.as_deref().is_some_and(|message| message.contains("skipped earlier")), "{req:?}: {:?}", failed.error); - } - let other = RenderRequest { page: 1, scale: 0.5, tag: 73, ..Default::default() }; - pool.set_queue(vec![other]); - let rendered = receive_before_deadline(&pool); - assert_eq!(rendered.request, other); - assert!(rendered.error.is_none(), "another page still renders: {:?}", rendered.error); - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn replacing_a_request_during_initialization_skips_its_obsolete_render() { - use std::time::Duration; - let mut pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default()); - let gate = parse_gate(); - *lock(&pool.shared.parse_gate) = Some(gate.clone()); - pool.set_stuck_after(Duration::ZERO); - pool.set_queue(vec![RenderRequest { page: 0, scale: 1.0, tag: 10, ..Default::default() }]); - gate.entered.wait(); - pool.set_queue(vec![RenderRequest { page: 1, scale: 1.0, tag: 11, ..Default::default() }]); - assert!(pool.try_recv().is_none(), "parsing is still held"); - pool.set_stuck_after(Duration::from_secs(60)); - gate.release.wait(); - 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_eq!(pool.shared.parses.load(Ordering::Relaxed), 1, "the useful parser is retained"); - assert_eq!(pool.shared.render_started.load(Ordering::Relaxed), 1, "obsolete page was skipped before rendering"); - } - - #[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.into()); - } - 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"); - } - assert_eq!( - pool.stats(), - RenderStats { - parser_builds: 1, - page_interpretations: 2, - render_requests: 3, - tile_requests: 0, - text_interpretations: 1, - rasterizations: 2 - } - ); - 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> { - 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] >>"); - // The 64 functions above nest exactly 128 levels (each dictionary and its /Functions - // array, then the leaf's /Domain array), the parser's nesting cap. 65 nest 130, so the - // parser refuses them before the function-depth guard sees them (a refusal either way). - // `function_limits_depth_guard_refuses_a_chain_of_references` tests the guard itself. - assert!(Object::from_bytes(over.as_bytes()).is_none(), "the parser refuses direct nesting past its cap"); - - // 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); - } - } - /// Stitching functions that each name the next through `N 0 R` nest no direct objects, so - /// the parser's nesting cap doesn't stop them and the function-depth guard does. - #[test] - fn function_limits_depth_guard_refuses_a_chain_of_references() { - use hayro::hayro_interpret::Function; - use hayro_syntax::object::{Object, ObjectIdentifier}; - for functions in [64, 65] { - // Objects 4 onwards: each stitching function names the next one; the last is a leaf. - let objects: String = (4..4 + functions) - .map(|number| { - let body = if number < 3 + functions { - format!("<< /FunctionType 3 /Domain [0 1] /Functions [{} 0 R] /Bounds [] /Encode [0 1] >>", number + 1) - } else { - "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>".to_owned() - }; - format!("{number} 0 obj {body} endobj\n") - }) - .collect(); - let parsed = 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\ - {objects}trailer << /Root 1 0 R >>\n%%EOF" - ) - .into_bytes(), - ) - .unwrap(); - let object = parsed.xref().get::>(ObjectIdentifier::new(4, 0)).unwrap(); - let function = Function::new(&object); - if functions == 64 { - assert_eq!(function.unwrap().eval([0.5].into_iter().collect()).unwrap().as_slice(), &[0.5]); - } else { - assert!(function.is_none(), "65 functions are one past the function-depth guard"); - } - } - } - #[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::>(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::>(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::>(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, ..Default::default() }); - 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])); - } - /// #1203: a /Lab colour space must honour its own /WhitePoint. The old path - /// fed every Lab value to the ICC transform as if the white point were D50, - /// shifting non-D50 pages several levels per channel (for example a D65 - /// blue came out 45 levels too red). Conversion and expected values follow - /// ISO 32000-2 §8.6.5.4 with the space's own /WhitePoint [0.9505 1 1.089]. - #[test] - fn lab_honours_the_space_white_point() { - use super::*; - let render = |l: f32, a: f32, b: f32| -> [u8; 4] { - let content = format!("/L cs {l} {a} {b} sc 0 0 200 200 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 200 200] /Resources << /ColorSpace << /L [/Lab << /WhitePoint [0.9505 1 1.089] /Range [-100 100 -100 100] >>] >> >> /Contents 4 0 R >> endobj\n\ - 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF", - content.len() - ) - .into_bytes(); - let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { - page: 0, - kind: RequestKind::Pixels, - tile: None, - scale: 1.0, - tag: 0, - preview: false, - }); - assert!(page.error.is_none(), "Lab ({l} {a} {b}): {:?}", page.error); - assert_eq!((page.width, page.height), (200, 200)); - page.rgba[(100 * page.width as usize + 100) * 4..][..4].try_into().unwrap() - }; - - for (l, a, b, expected) in [ - (40.0, 20.0, -60.0, [19, 90, 194, 255]), - (70.0, -50.0, 30.0, [77, 193, 114, 255]), - (60.0, 0.0, -40.0, [87, 148, 215, 255]), - (50.0, 60.0, 40.0, [217, 61, 55, 255]), - (30.0, 40.0, 20.0, [130, 37, 42, 255]), - ] { - let got = render(l, a, b); - for (channel, (got, want)) in got.iter().zip(expected).enumerate() { - assert!((*got).abs_diff(want) <= 2, "Lab ({l} {a} {b}) channel {channel}: got {got:?}, expected {expected:?}"); - } - } - } - /// #1203: an `/Indexed` palette over Lab looks its entries up as - /// `byte / 255`, which must map onto L* 0..100 and the a*/b* /Range. They - /// were read as raw L*a*b* (L* below 1), so such palettes drew near black. - #[test] - fn indexed_palettes_over_lab_keep_their_colours() { - use super::*; - let lab = "[/Lab << /WhitePoint [0.9505 1 1.089] /Range [-100 100 -100 100] >>]"; - let render = |content: &str| -> [u8; 4] { - 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 200 200] /Resources << /ColorSpace << /L {lab} /I [/Indexed {lab} 1 <000000 80CCB2>] >> >> /Contents 4 0 R >> endobj\n\ - 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF", - content.len() - ) - .into_bytes(); - let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { - page: 0, - kind: RequestKind::Pixels, - tile: None, - scale: 1.0, - tag: 0, - preview: false, - }); - assert!(page.error.is_none(), "{content}: {:?}", page.error); - assert_eq!((page.width, page.height), (200, 200)); - let start = (100 * page.width as usize + 100) * 4; - let mut pixel = [0; 4]; - pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel")); - pixel - }; - - // Palette entry 1 is <80 CC B2>: L* = 128/255·100, a* = -100 + 204/255·200, - // b* = -100 + 178/255·200, about Lab (50.2 60 39.6): a warm red. - let indexed = render("/I cs 1 sc 0 0 200 200 re f"); - let direct = render(&format!( - "/L cs {} {} {} sc 0 0 200 200 re f", - 128.0 / 255.0 * 100.0, - -100.0 + 204.0 / 255.0 * 200.0, - -100.0 + 178.0 / 255.0 * 200.0 - )); - for (channel, (got, want)) in indexed.iter().zip(direct).enumerate() { - assert!(got.abs_diff(want) <= 1, "channel {channel}: indexed {indexed:?}, direct {direct:?}"); - } - assert!(indexed[0] > 180, "the palette red must not render near black: {indexed:?}"); - } - #[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::ZERO); - let gate = render_gate(0, false); - *lock(&pool.shared.render_gates) = vec![gate.clone()]; - let req = |page| RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0, ..Default::default() }; - pool.set_queue(vec![req(0)]); - gate.entered.wait(); - let first = pool.try_recv().expect("the watchdog answers while the render is held"); - assert!(first.error.as_deref().is_some_and(|e| e.contains("took longer")), "{:?}", first.error); - // A zero allowance would give up on the healthy page 2 as well. - pool.set_stuck_after(std::time::Duration::from_secs(60)); - // The single worker is stuck, yet page 2 still renders (on the replacement worker). - pool.set_queue(vec![req(1)]); - let second = receive_before_deadline(&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 = receive_before_deadline(&pool); - assert!(again.error.as_deref().is_some_and(|e| e.contains("skipped earlier")), "{:?}", again.error); - // The released worker notices its slot was cleared; its late result is dropped. - gate.release.wait(); - gate.finished.wait(); - 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()); - let gate = render_gate(0, true); - *lock(&pool.shared.render_gates) = vec![gate.clone()]; - let req = |page| RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0, ..Default::default() }; - pool.set_queue(vec![req(0)]); - gate.entered.wait(); - // What the canvas sends next: the page it is still waiting for, then another one. - pool.set_queue(vec![req(0), req(1)]); - let other = receive_before_deadline(&pool); - assert!(other.error.is_none(), "{:?}", other.error); - assert_eq!(other.request.page, 1); - // Page 2 went to the idle worker instead of waiting behind a second copy of page 1. - assert_eq!(pool.shared.render_started.load(Ordering::Relaxed), 2, "each page rendered once"); - gate.release.wait(); - let first = receive_before_deadline(&pool); - assert!(first.error.is_none(), "{:?}", first.error); - assert_eq!(first.request.page, 0); - assert_eq!(pool.shared.render_started.load(Ordering::Relaxed), 2, "each page rendered once"); - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn replacing_an_in_flight_request_does_not_delay_the_replacement() { - use super::*; - use std::time::{Duration, Instant}; - - let pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default()); - let a = RenderRequest { page: 0, scale: 0.5, tag: 101, ..Default::default() }; - let b = RenderRequest { page: 1, scale: 0.5, tag: 102, ..Default::default() }; - *lock(&pool.shared.slow_page) = Some((a.page, Duration::from_secs(10))); - pool.set_queue(vec![a]); - - let started = Instant::now(); - while pool.shared.render_started.load(Ordering::Relaxed) == 0 { - assert!(started.elapsed() < Duration::from_secs(2), "request A never started"); - std::thread::sleep(Duration::from_millis(1)); - } - - let replaced = Instant::now(); - pool.set_queue(vec![b]); - assert!(lock(&pool.shared.busy).iter().flatten().any(|busy| busy.cancelled.load(Ordering::Acquire)), "request A was not cancelled"); - let page = receive_before_deadline(&pool); - assert_eq!(page.request, b); - assert!(page.error.is_none(), "replacement request failed: {:?}", page.error); - assert!(replaced.elapsed() < Duration::from_secs(3), "obsolete request A delayed B"); - - let deadline = Instant::now() + Duration::from_millis(100); - while Instant::now() < deadline { - assert!(pool.try_recv().is_none(), "obsolete request produced a result"); - std::thread::sleep(Duration::from_millis(5)); - } - } - - /// A request cancelled while its worker waited for the shared parse must not leave that - /// worker unable to render: the abort is not a parse result. - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn a_request_cancelled_while_waiting_for_the_parse_leaves_its_worker_usable() { - use super::*; - use std::time::{Duration, Instant}; - - let pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 2, RenderConfig::default()); - *lock(&pool.shared.slow_parse) = Some(Duration::from_millis(500)); - let a = RenderRequest { page: 0, scale: 0.5, tag: 301, ..Default::default() }; - let c = RenderRequest { page: 1, scale: 0.5, tag: 302, ..Default::default() }; - pool.set_queue(vec![a, c]); - let started = Instant::now(); - while lock(&pool.shared.busy).iter().flatten().count() < 2 { - assert!(started.elapsed() < Duration::from_secs(2), "both requests never started"); - std::thread::sleep(Duration::from_millis(1)); - } - *lock(&pool.shared.slow_parse) = None; - for round in 0..4u64 { - let x = RenderRequest { page: 0, scale: 0.5, tag: 400 + 2 * round, ..Default::default() }; - let y = RenderRequest { page: 1, scale: 0.5, tag: 401 + 2 * round, ..Default::default() }; - pool.set_queue(vec![x, y]); - for _ in 0..2 { - let page = receive_before_deadline(&pool); - assert!(page.request == x || page.request == y, "round {round}: obsolete {:?}", page.request); - assert!(page.error.is_none(), "round {round}: {:?}", page.error); - } - } - } - - #[cfg(not(target_arch = "wasm32"))] - fn check_cancelled_parse_request_requeue(renew_before_abort: bool) { - use super::*; - use std::{sync::mpsc, time::Duration}; - - let pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default()); - let a = RenderRequest { page: 0, kind: RequestKind::Text, scale: 1.0, tag: 501, ..Default::default() }; - let b = RenderRequest { page: 1, scale: 1.0, tag: 502, ..Default::default() }; - let parse = parse_gate(); - *lock(&pool.shared.parse_gate) = Some(parse.clone()); - let (entered_tx, entered_rx) = mpsc::channel(); - let (release_tx, release_rx) = mpsc::channel(); - *lock(&pool.shared.busy_publish_gate) = Some(BusyPublishGate { request: b, entered: entered_tx, release: Arc::new(Mutex::new(release_rx)) }); - - pool.set_queue(vec![a]); - parse.entered.wait(); - pool.set_queue(vec![b]); - if renew_before_abort { - // B stays first so the worker reaches its gate even when aborting A requeues it. - pool.set_queue(vec![b, a]); - } - parse.release.wait(); - entered_rx.recv_timeout(Duration::from_secs(2)).expect("the worker finished aborting A and reached B"); - // The parse hook's lock can span its barrier waits; clear it only after parsing returned. - *lock(&pool.shared.parse_gate) = None; - if !renew_before_abort { - pool.set_queue(vec![a]); - } - // B holds the sole worker, so this observes queue admission without racing a worker pop. - let pending = pool.shared.queue.pop(); - if let Some(request) = pending { - pool.shared.queue.retry(request); - } - pool.set_queue(vec![a]); - // Release the worker before the regression assertion, including on the unfixed baseline. - release_tx.send(()).unwrap(); - assert_eq!(pending, Some(a), "aborting a parse must release its active request identity"); - - let page = receive_before_deadline(&pool); - assert_eq!(page.request, a); - assert!(page.error.is_none(), "renewed text request failed: {:?}", page.error); - assert!(page.text.is_some(), "the exact cancelled text request completed"); - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn cancelled_parse_request_can_be_requested_again() { - check_cancelled_parse_request_requeue(false); - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn cancelled_parse_request_renewed_before_abort_is_requeued() { - check_cancelled_parse_request_requeue(true); - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn request_removed_before_busy_publication_skips_parser_acquisition() { - use std::{sync::mpsc, time::Duration}; - - let pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default()); - let a = RenderRequest { page: 0, scale: 0.5, tag: 201, ..Default::default() }; - let b = RenderRequest { page: 1, scale: 0.5, tag: 202, ..Default::default() }; - let (entered_tx, entered_rx) = mpsc::channel(); - let (release_tx, release_rx) = mpsc::channel(); - *lock(&pool.shared.busy_publish_gate) = Some(BusyPublishGate { request: a, entered: entered_tx, release: Arc::new(Mutex::new(release_rx)) }); - - pool.set_queue(vec![a]); - entered_rx.recv_timeout(Duration::from_secs(2)).expect("request A passed its first obsolescence check"); - assert!(!lock(&pool.shared.busy).iter().flatten().any(|busy| same_request(busy.request, a))); - pool.set_queue(vec![b]); - release_tx.send(()).unwrap(); - - let page = receive_before_deadline(&pool); - assert_eq!(page.request, b); - assert!(page.error.is_none(), "replacement request failed: {:?}", page.error); - assert_eq!(*lock(&pool.shared.parser_stage_entries), vec![b], "obsolete A must not enter parser acquisition"); - assert_eq!(pool.shared.render_started.load(Ordering::Relaxed), 1, "only replacement B is rasterized"); - assert!(pool.try_recv().is_none(), "obsolete request A did not publish a stale result"); - } - - #[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::>().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 = (0..pages) - .map(|page| RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 2000, ..Default::default() }) - .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 = 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, true), None); - assert_eq!(hayro::image_resampling_size(16, 1, 3, 48, 0.5, 32.0, 128, true), None); - assert_eq!(hayro::image_resampling_size(16, 1, 4, 64, 0.5, 32.0, 128, true), 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, true), 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, true), None); - assert_eq!(hayro::image_resampling_size(4, 4, 3, 49, 0.5, 0.5, 128, true), 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, true), 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, true), Some((8, 4))); - assert_eq!(hayro::image_resampling_size(16, 1, 3, 48, 0.5, 8.0, 128, true), Some((8, 8))); - assert_eq!(hayro::image_resampling_size(16, 8, 4, 512, 1.0, 1.0, 128, true), Some((16, 8))); - assert_eq!(hayro::image_resampling_size(0, 8, 1, 0, 1.0, 1.0, 128, true), None); - assert_eq!(hayro::image_resampling_size(16, 9, 1, 144, 1.0, 1.0, 128, true), None); - assert_eq!(hayro::image_resampling_size(65_536, 1, 1, 65_536, 1.0, 1.0, u64::MAX, true), 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, true), Some((16, 1))); - } - assert_eq!(hayro::image_resampling_size(65_535, 1, 1, 65_535, 1.0, 1.0, 65_535, true), 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, true), 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, true), 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, true), None); - assert_eq!(hayro::image_resampling_size(65_532 + 4, 1 + 4, 4, 65_536 * 5 * 4, 1.0, 1.0, 1 << 28, true), None); - } - - /// Nearest-neighbour copies source pixels and needs no filter tables, so a source wider than the - /// Catmull-Rom side cap still shrinks. The pixel budget applies to it all the same. - #[test] - fn image_resampling_nearest_accepts_wide_sources_the_filter_refuses() { - let wide: u32 = (1 << 20) + 1; - assert_eq!(hayro::image_resampling_size(wide, 1, 1, wide as usize, 1.0 / 4096.0, 1.0, 1 << 28, true), None); - assert_eq!(hayro::image_resampling_size(wide, 1, 1, wide as usize, 1.0 / 4096.0, 1.0, 1 << 28, false), Some((257, 1))); - assert_eq!(hayro::image_resampling_size(wide, 1, 1, wide as usize, 1.0 / 4096.0, 1.0, 256, false), None); - } - - fn strip_image_pdf(width: u32, body: &str, space: &str, encoded: &str, alpha: Option<&str>) -> Vec { - 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]); - } - - /// A source wider than the filter's 2^20-pixel side cap is shrunk by nearest-neighbour rather - /// than refused and skipped (the default, `/Interpolate` omitted, is nearest). - #[test] - fn wide_non_interpolated_sources_draw_after_shrinking() { - let data = "7f".repeat(1_100_000); - let pdf = strip_image_pdf(1_100_000, "q 16 0 0 1 2 2 cm /Im0 Do Q\n", "DeviceGray", &data, 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(), "{:?}", 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), &[127, 127, 127, 255], "source image was not dropped"); - assert_eq!(pixel(17), &[127, 127, 127, 255]); - assert_eq!(pixel(19), &[255, 255, 255, 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"; - - fn heavy_vector_pdf(commands: usize, text_at_end: bool) -> Vec { - let mut body = String::with_capacity(commands * 42 + 64); - for i in 0..commands { - let x = (i % 280) as u32; - let y = ((i / 280) % 280) as u32; - body.push_str(&format!("0 0 0 rg {x} {y} 1 1 re f\n")); - } - if text_at_end { - body.push_str("BT /F1 18 Tf 20 280 Td (TRAILING_MARKER) Tj ET\n"); - } - format!( - "%PDF-1.4\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 300 300] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >> endobj\n\ - 4 0 obj << /Length {} >> stream\n{body}endstream endobj\n\ - 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF", - body.len() - ) - .into_bytes() - } - - #[test] - fn heavy_vector_fixture_preserves_trailing_text_and_raster_output() { - let mut renderer = PageRenderer::new(Arc::new(heavy_vector_pdf(5_000, true)), RenderConfig::default()); - let pixels = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); - assert!(pixels.error.is_none(), "heavy vector render failed: {:?}", pixels.error); - assert!(pixels.rgba.as_chunks::<4>().0.iter().any(|pixel| *pixel != [255, 255, 255, 255])); - let text = renderer.render(RenderRequest { page: 0, kind: RequestKind::Text, ..Default::default() }); - assert!(text.error.is_none(), "heavy vector text failed: {:?}", text.error); - assert!(text.text.is_some_and(|page| page.plain_text().contains("TRAILING_MARKER"))); - assert_eq!( - renderer.stats(), - RenderStats { - parser_builds: 1, - page_interpretations: 1, - render_requests: 2, - tile_requests: 0, - text_interpretations: 1, - rasterizations: 1 - } - ); - } - - #[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 { - 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 shared_form_xobject_uses_each_pages_resource_context() { - let form = "/CS1 cs 0.5 0.5 0.5 sc 10 10 30 30 re f"; - let page_content = "/F Do"; - let 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 6 0 R] /Count 2 /MediaBox [0 0 50 50] >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /Resources << /XObject << /F 4 0 R >> /ColorSpace << /CS1 /DeviceRGB >> >> /Contents 5 0 R >> endobj\n\ - 4 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 50 50] /Length {} >> stream\n{form}\nendstream endobj\n\ - 5 0 obj << /Length {} >> stream\n{page_content}\nendstream endobj\n\ - 6 0 obj << /Type /Page /Parent 2 0 R /Resources << /XObject << /F 4 0 R >> /ColorSpace << /CS1 [/CalRGB << /WhitePoint [0.9505 1 1.089] /Gamma [2 2 2] /Matrix [1 0 0 0 1 0 0 0 1] >>] >> >> /Contents 7 0 R >> endobj\n\ - 7 0 obj << /Length {} >> stream\n{page_content}\nendstream endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF", - form.len(), - page_content.len(), - page_content.len() - ) - .into_bytes(); - let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); - let first = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); - let second = renderer.render(RenderRequest { page: 1, scale: 1.0, ..Default::default() }); - assert!(first.error.is_none() && second.error.is_none(), "{:?} / {:?}", first.error, second.error); - - let pixel = |page: &RenderedPage| { - let offset = ((20 * page.width + 20) * 4) as usize; - [page.rgba[offset], page.rgba[offset + 1], page.rgba[offset + 2], page.rgba[offset + 3]] - }; - let first_pixel = pixel(&first); - let second_pixel = pixel(&second); - assert_ne!(first_pixel, second_pixel, "the same Form XObject must resolve /CS1 in each page's resources"); - assert_ne!(first_pixel, [255, 255, 255, 255]); - assert_ne!(second_pixel, [255, 255, 255, 255]); - } - - #[test] - fn shared_soft_mask_uses_each_inherited_resource_context() { - let page_body = "/A Do /B Do"; - let form_a = "/GS gs 1 0 0 rg 0 0 40 40 re f"; - let form_b = "/GS gs 0 0 1 rg 60 0 40 40 re f"; - let mask_body = "/FILL Do"; - let white = "1 g 0 0 100 100 re f"; - let black = "0 g 0 0 100 100 re f"; - let 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 /MediaBox [0 0 100 100] >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /Resources << /XObject << /A 4 0 R /B 5 0 R >> >> /Contents 6 0 R >> endobj\n\ - 4 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << /ExtGState << /GS 8 0 R >> /XObject << /FILL 9 0 R >> >> /Length {} >> stream\n{form_a}\nendstream endobj\n\ - 5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << /ExtGState << /GS 8 0 R >> /XObject << /FILL 10 0 R >> >> /Length {} >> stream\n{form_b}\nendstream endobj\n\ - 6 0 obj << /Length {} >> stream\n{page_body}\nendstream endobj\n\ - 8 0 obj << /Type /ExtGState /SMask 11 0 R >> endobj\n\ - 9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Length {} >> stream\n{white}\nendstream endobj\n\ - 10 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Length {} >> stream\n{black}\nendstream endobj\n\ - 11 0 obj << /Type /Mask /S /Luminosity /G 12 0 R >> endobj\n\ - 12 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS /DeviceGray >> /Length {} >> stream\n{mask_body}\nendstream endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF", - form_a.len(), - form_b.len(), - page_body.len(), - white.len(), - black.len(), - mask_body.len() - ) - .into_bytes(); - let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); - let page = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); - assert!(page.error.is_none(), "{:?}", page.error); - let pixel = |x: u32, y: u32| { - let offset = ((y * page.width + x) * 4) as usize; - [page.rgba[offset], page.rgba[offset + 1], page.rgba[offset + 2], page.rgba[offset + 3]] - }; - assert_eq!(pixel(20, 80), [255, 0, 0, 255], "the white inherited mask makes form A visible"); - assert_eq!(pixel(80, 80), [255, 255, 255, 255], "the black inherited mask must not reuse form A's cached pixels"); - } - - #[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, ..Default::default() }]); - 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]); - } - - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn page_pixels_are_invariant_to_render_worker_count() { - let blue = "0 0 1 rg 10 10 30 20 re f"; - let red = "1 0 0 rg 20 5 40 35 re f"; - let pdf = format!( - "%PDF-1.4\n\ - 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 /MediaBox [0 0 100 50] >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R >> endobj\n\ - 4 0 obj << /Length {} >> stream\n{blue}\nendstream endobj\n\ - 5 0 obj << /Type /Page /Parent 2 0 R /Contents 6 0 R >> endobj\n\ - 6 0 obj << /Length {} >> stream\n{red}\nendstream endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF", - blue.len(), - red.len() - ) - .into_bytes(); - let bytes = Arc::new(pdf); - let mut reference = PageRenderer::new(bytes.clone(), RenderConfig::default()); - let expected: Vec<_> = (0..2).map(|page| reference.render(RenderRequest { page, scale: 1.0, ..Default::default() })).collect(); - assert!(expected.iter().all(|page| page.error.is_none())); - - for workers in [1, 2, 4] { - let pool = RenderPool::new(bytes.clone(), workers, RenderConfig::default()); - pool.set_queue((0..2).map(|page| RenderRequest { page, scale: 1.0, ..Default::default() }).collect()); - let mut actual = [receive_before_deadline(&pool), receive_before_deadline(&pool)]; - actual.sort_by_key(|page| page.request.page); - for (page, (actual, expected)) in actual.iter().zip(&expected).enumerate() { - assert!(actual.error.is_none(), "{workers} workers, page {page}: {:?}", actual.error); - assert_eq!( - (actual.width, actual.height, &actual.rgba), - (expected.width, expected.height, &expected.rgba), - "{workers} workers, page {page}" - ); - } - } - } - - #[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, ..Default::default() }); - 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::(), 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, ..Default::default() }).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, ..Default::default() }).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); - } - - #[test] - fn strict_whole_page_render_refuses_cap_reduction_but_tiles_remain_available() { - 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 14400 14400] /Contents 4 0 R >> endobj -4 0 obj << /Length 0 >> stream -endstream endobj -trailer << /Root 1 0 R >> -%%EOF"; - let config = RenderConfig { reject_oversize: true, ..RenderConfig::default() }; - let mut renderer = PageRenderer::new(Arc::new(pdf.to_vec()), config); - let whole = renderer.render(RenderRequest { page: 0, scale: 4.0, ..Default::default() }); - assert!(whole.error.as_deref().is_some_and(|error| error.contains("exceeds renderer limits"))); - let tile = renderer.render(RenderRequest { page: 0, tile: Some(Tile { x: 0, y: 0, w: 32, h: 32 }), scale: 4.0, ..Default::default() }); - assert!(tile.error.is_none(), "tiled rendering must remain available: {:?}", tile.error); - assert_eq!((tile.width, tile.height), (32, 32)); - } - - fn allocation_budget_page(width: f32, height: f32, content: &str) -> Arc> { - Arc::new(format!("%PDF-1.4\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 {width} {height}] /Contents 4 0 R >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF", content.len()).into_bytes()) - } - - #[test] - fn rounded_pixel_budget_rejects_before_actual_allocation() { - use crate::pixels::allocation_probe::{Attempts, observe}; - for reject_oversize in [false, true] { - let mut renderer = PageRenderer::new(allocation_budget_page(8000.5, 7999.5, ""), RenderConfig { reject_oversize, ..Default::default() }); - assert_eq!(renderer.page_count(), 1); - let (out, attempts) = observe(|| renderer.render(RenderRequest { scale: 1.0, ..Default::default() })); - assert_eq!(attempts, Attempts::default(), "rounded over-cap raster reached allocator; strict={reject_oversize}"); - assert_eq!(out.error.as_deref(), Some("page 1 raster size 8001x8000 exceeds renderer pixel limit")); - assert_eq!((out.width, out.height), (0, 0)); - assert!(out.rgba.is_empty()); - assert_eq!(renderer.stats().parser_builds, 1, "normal rejection must not trigger panic recovery"); - } - } - - #[test] - fn rounded_pixel_budget_allows_exact_cap_to_reach_allocator() { - use crate::pixels::allocation_probe::{Attempts, observe}; - for reject_oversize in [false, true] { - let mut renderer = PageRenderer::new(allocation_budget_page(8000.0, 8000.0, ""), RenderConfig { reject_oversize, ..Default::default() }); - let (out, attempts) = observe(|| renderer.render(RenderRequest { scale: 1.0, ..Default::default() })); - assert_eq!(attempts, Attempts { calls: 1, first: Some((8000, 8000)) }); - // The test probe refuses the actual allocation: this is admission evidence, - // not a successful giant-raster rendering or a 256MB test allocation. - assert_eq!(out.error.as_deref(), Some("page 1 is too large to render")); - assert_eq!((out.width, out.height), (0, 0)); - assert!(out.rgba.is_empty()); - assert_eq!(renderer.stats().parser_builds, 1); - } - } - - #[test] - fn rounded_pixel_budget_preserves_small_pixels_and_large_page_tiles() { - let mut previous = None; - for reject_oversize in [false, true] { - let config = RenderConfig { reject_oversize, ..Default::default() }; - let mut small = PageRenderer::new(allocation_budget_page(16.0, 12.0, "0 0 1 rg 4 3 8 6 re f"), config.clone()); - let out = small.render(RenderRequest { scale: 1.0, ..Default::default() }); - assert!(out.error.is_none(), "{:?}", out.error); - assert_eq!((out.width, out.height), (16, 12)); - let expected: Vec = (0..12) - .flat_map(|y| { - (0..16).flat_map(move |x| if (4..12).contains(&x) && (3..9).contains(&y) { [0, 0, 255, 255] } else { [255, 255, 255, 255] }) - }) - .collect(); - assert_eq!(&*out.rgba, expected.as_slice()); - if let Some(previous) = &previous { - assert_eq!(previous, &out.rgba); - } - previous = Some(out.rgba); - let mut large = PageRenderer::new(allocation_budget_page(8000.5, 7999.5, ""), config); - let tile = large.render(RenderRequest { scale: 1.0, tile: Some(Tile { x: 0, y: 0, w: 32, h: 32 }), ..Default::default() }); - assert!(tile.error.is_none(), "{:?}", tile.error); - assert_eq!((tile.width, tile.height), (32, 32)); - assert_eq!(&*tile.rgba, &[255; 32 * 32 * 4]); - assert_eq!(large.stats().parser_builds, 1); - } - } - - #[test] - fn rounded_pixel_budget_invalid_requests_never_reach_allocator() { - use crate::pixels::allocation_probe::{Attempts, observe}; - for reject_oversize in [false, true] { - let mut renderer = PageRenderer::new(allocation_budget_page(16.0, 12.0, ""), RenderConfig { reject_oversize, ..Default::default() }); - let (out, attempts) = observe(|| renderer.render(RenderRequest { page: 1, scale: 1.0, ..Default::default() })); - assert_eq!(attempts, Attempts::default()); - assert!(out.error.is_some()); - assert_eq!((out.width, out.height), (0, 0)); - assert!(out.rgba.is_empty()); - assert_eq!(renderer.stats().parser_builds, 1); - } - let mut renderer = PageRenderer::new(allocation_budget_page(16.0, 12.0, ""), RenderConfig { reject_oversize: true, ..Default::default() }); - for scale in [0.0, -1.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { - let (out, attempts) = observe(|| renderer.render(RenderRequest { scale, ..Default::default() })); - assert_eq!(attempts, Attempts::default()); - assert!(out.error.as_deref().is_some_and(|error| error.contains("exceeds renderer limits"))); - assert_eq!((out.width, out.height), (0, 0)); - assert!(out.rgba.is_empty()); - assert_eq!(renderer.stats().parser_builds, 1); - } - } - - /// 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, ..Default::default() }; - 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, ..Default::default() }); - 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, ..Default::default() }); - 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"); - } - - /// Microsoft Reporting Services (bank statements, invoices) writes `stream \r\n`, a space - /// before the line break. The content stream was then taken to start at the space and the - /// page rendered blank, while Preview, Chrome and Poppler showed it. Vendored hayro-syntax - /// patch: spaces and tabs before the line break are skipped, with the right `/Length` and - /// with a wrong one. Spaces with no line break after them never start stream data. - #[test] - fn space_before_the_line_break_after_stream_still_renders() { - let pdf = |eol: &str, length: u32| { - format!( - "%PDF-1.3\r\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\r\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\r\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R >> endobj\r\n\ - 4 0 obj << /Length {length} >> stream{eol}0 0 0 rg 20 20 60 60 re f\r\nendstream\r\nendobj\r\n\ - trailer << /Root 1 0 R >>\r\n%%EOF" - ) - .into_bytes() - }; - for (eol, length) in [(" \r\n", 25), (" \n", 25), ("\t \r", 25), (" \r\n", 999), ("\r\n", 25)] { - let mut r = PageRenderer::new(Arc::new(pdf(eol, length)), RenderConfig::default()); - let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); - assert!(p.error.is_none(), "{eol:?}: {:?}", p.error); - let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec(); - assert_eq!(px(50, 50), vec![0, 0, 0, 255], "stream{eol:?} (/Length {length}) must draw the square"); - assert_eq!(px(5, 5), vec![255, 255, 255, 255], "outside the square stays white"); - } - } - - /// 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>) {} - 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>, _: 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| { - 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, ..Default::default() }) - }; - 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, ..Default::default() }); - 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}"); - } - } - - /// #2583, #1869: a sticky note carries the NoRotate flag (`/F` bit 5), so on a page with - /// `/Rotate` its icon stays upright as displayed, turned about the upper-left corner of its - /// `/Rect`, which stays where it is on the page (ISO 32000-2 §12.5.3). It was drawn turned with - /// the page, in `/Rect` as displayed (vendored hayro-interpret patch 37). A note without the - /// flag still turns with the page. `Annotation::shown_rect` is where the UI hit-tests the - /// note, and must be where it is drawn. - #[test] - fn no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages() { - // A 30 × 20 appearance: red in its top-left quarter, blue elsewhere. - let content = "0 0 1 rg 0 0 30 20 re f 1 0 0 rg 0 10 15 10 re f"; - for rotation in [0u16, 90, 180, 270] { - let objects = [ - "<< /Type /Catalog /Pages 2 0 R >>".to_string(), - "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), - format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 140] /Rotate {rotation} /Annots [4 0 R 5 0 R] >>"), - // NoRotate (and Print), as PdfCraft and Acrobat save notes. - "<< /Type /Annot /Subtype /Text /F 20 /Rect [40 50 70 70] /AP << /N 6 0 R >> >>".to_string(), - // The same appearance without the flag. - "<< /Type /Annot /Subtype /Text /F 4 /Rect [120 50 150 70] /AP << /N 6 0 R >> >>".to_string(), - format!("<< /Type /XObject /Subtype /Form /BBox [0 0 30 20] /Length {} >>\nstream\n{content}\nendstream", content.len()), - ]; - let mut pdf = b"%PDF-1.7\n".to_vec(); - let mut offsets = Vec::new(); - for (i, object) in objects.iter().enumerate() { - offsets.push(pdf.len()); - pdf.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); - } - let xref = pdf.len(); - pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); - for offset in offsets { - pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); - } - pdf.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); - let pdf = Arc::new(pdf); - let info = crate::inspect(pdf.clone(), None).expect("opens"); - let page = &info.pages[0]; - assert_eq!(page.rotation, rotation); - let mut r = PageRenderer::new(pdf, RenderConfig::default()); - let p = r.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); - assert!(p.error.is_none(), "{:?}", p.error); - // Displayed-page points are device pixels at scale 1. - let px = |x: f32, y: f32| -> [u8; 4] { - let (x, y) = (x.clamp(0.0, p.width as f32 - 1.0) as u32, y.clamp(0.0, p.height as f32 - 1.0) as u32); - p.rgba[((y * p.width + x) * 4) as usize..][..4].try_into().unwrap() - }; - let red: fn([u8; 4]) -> bool = |c| c[0] > 200 && c[1] < 60 && c[2] < 60; - let blue: fn([u8; 4]) -> bool = |c| c[2] > 200 && c[0] < 60 && c[1] < 60; - let paper: fn([u8; 4]) -> bool = |c| c.iter().all(|v| *v > 240); - - // The NoRotate note: its upper-left corner (40, 70) is the top-left of an upright - // 30 × 20 icon as displayed, red quarter top-left. - let [cx, cy] = page.user_to_view(40.0, 70.0); - for ((dx, dy), want, what) in [ - ((7.5, 5.0), red, "red top-left quarter"), - ((22.5, 5.0), blue, "blue top-right"), - ((7.5, 15.0), blue, "blue bottom-left"), - ((22.5, 15.0), blue, "blue bottom-right"), - ((-3.0, 10.0), paper, "paper left of the icon"), - ((10.0, -3.0), paper, "paper above the icon"), - ((33.0, 10.0), paper, "paper right of its 30 pt width"), - ((10.0, 23.0), paper, "paper below its 20 pt height"), - ] { - let c = px(cx + dx, cy + dy); - assert!(want(c), "rotation {rotation}: NoRotate note at corner + ({dx}, {dy}) is {c:?}, want {what}"); - } - let note = info.annotations.iter().find(|a| a.no_rotate).expect("the NoRotate note is listed"); - let shown = note.shown_rect(rotation); - let (a, b) = (page.user_to_view(shown[0], shown[1]), page.user_to_view(shown[2], shown[3])); - let box_ = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]; - let want = [cx, cy, cx + 30.0, cy + 20.0]; - assert!(box_.iter().zip(want).all(|(v, w)| (v - w).abs() < 0.01), "rotation {rotation}: shown {box_:?}, drawn {want:?}"); - - // The note without the flag turns with the page: its red quarter stays at the - // user-space top-left of /Rect, at the displayed top-left only when unturned. - let plain = info.annotations.iter().find(|a| !a.no_rotate).expect("the plain note is listed"); - assert_eq!(plain.shown_rect(rotation), plain.rect); - let [rx, ry] = page.user_to_view(127.5, 65.0); - let [bx, by] = page.user_to_view(142.5, 55.0); - assert!(red(px(rx, ry)) && blue(px(bx, by)), "rotation {rotation}: the plain note's appearance turns with the page"); - let (a, b) = (page.user_to_view(120.0, 50.0), page.user_to_view(150.0, 70.0)); - let corner = px(a[0].min(b[0]) + 4.0, a[1].min(b[1]) + 4.0); - assert_eq!(red(corner), rotation == 0, "rotation {rotation}: displayed top-left of the plain note is {corner:?}"); - } - } - - /// #1968: the highlight appearance other editors write, a transparency group whose content is - /// filled with `/BM /Multiply`, drew as an opaque block over the text. Every `/Group` form - /// got a layer of its own, so the Multiply blended with that layer's transparent backdrop - /// and the layer then covered the page. Vendored hayro-interpret patch: a group that is not - /// isolated (ISO 32000-2 §11.4.7: its elements composite with what is already on the page) - /// and is entered at full opacity, with no soft mask and Normal blending, has no backdrop of - /// its own to offer, so its content is drawn straight onto the page. An isolated group - /// (`/I true`) keeps its blank backdrop, and a group entered at partial opacity still gets - /// its layer so that opacity applies to the group as a whole. - #[test] - fn a_multiply_highlight_inside_a_transparency_group_keeps_the_text() { - // `annots`: the form drawn as the highlight's appearance; `page`: drawn from the page - // content instead, with the extra graphics state in front of the `Do`. - let render = |group: &str, annots: &str, page: &str| { - let content = format!("0 g 100 40 10 20 re f {page}"); - let form = "/GS0 gs 1 1 0 rg 15 35 105 30 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} /Resources << /XObject << /Fx 6 0 R >> /ExtGState << /Half << /ca 0.5 >> >> >> >> endobj -4 0 obj << /Length {} >> stream -{content} -endstream endobj -5 0 obj << /Type /Annot /Subtype /Highlight /Rect [15 35 120 65] /QuadPoints [15 65 120 65 15 35 120 35] /C [1 1 0] /AP << /N 6 0 R >> >> endobj -6 0 obj << /Type /XObject /Subtype /Form /BBox [15 35 120 65] {group} /Resources << /ExtGState << /GS0 << /BM /Multiply /CA 1 /ca 1 >> >> >> /Length {} >> stream -{form} -endstream endobj -trailer << /Root 1 0 R >> -%%EOF", - content.len(), - form.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, ..Default::default() }); - assert!(p.error.is_none(), "{group}: {:?}", 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 annots = "/Annots [5 0 R]"; - let plain = "/Group << /S /Transparency /CS /DeviceRGB >>"; - let explicit = "/Group << /S /Transparency /CS /DeviceRGB /I false /K false >>"; - for group in [plain, explicit, ""] { - for (annots, page) in [(annots, ""), ("", "/Fx Do")] { - let p = render(group, annots, page); - assert_eq!(px(&p, 30, 50), yellow, "{group} {page}: filled with the highlight colour"); - assert_eq!(px(&p, 105, 50), black, "{group} {page}: Multiply keeps the text underneath"); - assert_eq!(px(&p, 150, 50), white, "{group} {page}: outside the appearance"); - } - } - // Isolated: the group's own transparent backdrop is all the Multiply sees (§11.4.7). - let p = render("/Group << /S /Transparency /CS /DeviceRGB /I true >>", annots, ""); - assert_eq!(px(&p, 105, 50), yellow, "an isolated group starts from a blank backdrop"); - // Entered at half opacity, the group is still composited as one: half yellow over white. - let p = render(plain, "", "/Half gs /Fx Do"); - let half = px(&p, 30, 50); - assert!(half[0] == 255 && half[1] == 255 && (120..=136).contains(&half[2]), "{half:?}"); - assert_eq!(px(&p, 150, 50), white); - } - - /// 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, ..Default::default() }); - 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, ..Default::default() }) - }; - 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"); - } - - /// ISO 32000-2 Table 167: Invisible hides an annotation of a type that isn't standard and - /// nothing else; §12.5.6.14: a popup has no appearance of its own, so its `/AP` isn't painted. - #[test] - fn invisible_unknown_annotations_and_popups_paint_nothing() { - let annots = [ - "/Subtype /MyCustomType /F 0", - "/Subtype /MyCustomType /F 1", - "/Subtype /MyCustomType /F 5", - "/F 1", - "/Subtype /Square /F 1", - "/Subtype /Popup /F 4 /Open true", - ]; - let mut pdf = String::from("%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n"); - pdf.push_str("2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n"); - let refs: Vec = (0..annots.len()).map(|i| format!("{} 0 R", 10 + i)).collect(); - pdf.push_str(&format!("3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 50] /Annots [{}] >> endobj\n", refs.join(" "))); - pdf.push_str("4 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 30 30] /Length 24 >> stream\n0 1 0 rg 0 0 30 30 re f\nendstream endobj\n"); - for (i, a) in annots.iter().enumerate() { - let x = 10 + 50 * i; - pdf.push_str(&format!("{} 0 obj << /Type /Annot {a} /Rect [{x} 10 {} 40] /AP << /N 4 0 R >> >> endobj\n", 10 + i, x + 30)); - } - pdf.push_str("trailer << /Root 1 0 R >>\n%%EOF"); - 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, ..Default::default() }); - let px = |x: u32| p.rgba[((25 * p.width + x) * 4) as usize..][..4].to_vec(); - let (green, white) = (vec![0, 255, 0, 255], vec![255, 255, 255, 255]); - assert_eq!(px(25), green, "an unknown type without Invisible is drawn"); - assert_eq!(px(75), white, "Invisible hides an unknown type"); - assert_eq!(px(125), white, "even with the Print flag"); - assert_eq!(px(175), white, "no Subtype is no standard type"); - assert_eq!(px(225), green, "Invisible means nothing for a standard type"); - assert_eq!(px(275), white, "a popup's appearance is not painted on the page"); - } - - #[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, ..Default::default() }, - RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 2, ..Default::default() }, - ]); - 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::ZERO); - let gate = render_gate(0, false); - *lock(&pool.shared.render_gates) = vec![gate.clone()]; - pool.set_queue(vec![RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0, ..Default::default() }]); - gate.entered.wait(); - let first = pool.try_recv().expect("the watchdog answers for the held render"); - 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 = lock(&pool.shared.stop).iter().map(|s| s.load(std::sync::atomic::Ordering::Relaxed)).collect(); - assert_eq!(stop, vec![true, false]); - gate.release.wait(); - gate.finished.wait(); - assert!(pool.try_recv().is_none(), "the abandoned render must not publish a late result"); - } - - #[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, ..Default::default() }); - 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, ..Default::default() }); - 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"); - } - - /// #2986: an optional content membership dictionary with a visibility expression (`/VE`, - /// ISO 32000-2 §8.11.2.2) shows its content only when the expression is true, and the - /// expression takes precedence over `/OCGs` and `/P`. It was ignored, so content under it - /// stayed visible when its layers were hidden. A malformed expression falls back to `/OCGs`. - #[test] - fn visibility_expressions_decide_what_a_membership_dictionary_shows() { - let render = |ocmd: &str, off: &[i32]| { - let pdf = format!( - "%PDF-1.7 -1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /Order [5 0 R 6 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 << /M1 7 0 R >> >> >> endobj -4 0 obj << /Length 44 >> stream -/OC /M1 BDC 1 0 0 rg 0 0 100 100 re f EMC -endstream endobj -5 0 obj << /Type /OCG /Name (A) >> endobj -6 0 obj << /Type /OCG /Name (B) >> endobj -7 0 obj {ocmd} endobj -trailer << /Root 1 0 R >> -%%EOF" - ); - let layers = off.iter().map(|n| (*n, 0, false)).collect(); - let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig { layers: Arc::new(layers), ..Default::default() }); - let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0, ..Default::default() }); - p.rgba[..4] == [255, 0, 0, 255] - }; - let and = "<< /Type /OCMD /VE [/And 5 0 R 6 0 R] >>"; - assert!(render(and, &[]), "And: both on"); - assert!(!render(and, &[5]), "And: one off"); - let or = "<< /Type /OCMD /VE [/Or 5 0 R 6 0 R] >>"; - assert!(render(or, &[5]), "Or: one on"); - assert!(!render(or, &[5, 6]), "Or: both off"); - let not = "<< /Type /OCMD /VE [/Not 5 0 R] >>"; - assert!(!render(not, &[]), "Not: the group is on"); - assert!(render(not, &[5]), "Not: the group is off"); - let nested = "<< /Type /OCMD /VE [/And [/Not 5 0 R] 6 0 R] >>"; - assert!(render(nested, &[5]), "nested: A off, B on"); - assert!(!render(nested, &[]), "nested: A on"); - // /VE wins over /OCGs and /P. - let both = "<< /Type /OCMD /OCGs [5 0 R] /P /AllOn /VE [/Not 5 0 R] >>"; - assert!(render(both, &[5]), "the expression, not /P, decides"); - // A malformed expression falls back to /OCGs and /P. - // So is one naming an object that isn't a listed group: a missing one, or the page - // (review on #2987). - for bad in ["[/Xor 5 0 R]", "[/Not 5 0 R 6 0 R]", "[/And]", "[5 0 R]", "[/And (5)]", "[/Or 8 0 R]", "[/Or 3 0 R]"] { - let ocmd = format!("<< /Type /OCMD /OCGs [5 0 R] /VE {bad} >>"); - assert!(render(&ocmd, &[]), "{bad}: /OCGs on"); - assert!(!render(&ocmd, &[5]), "{bad}: /OCGs off"); - } - // Nesting past the limit is malformed too, and does not recurse without end. - let deep = format!("<< /Type /OCMD /OCGs [5 0 R] /VE {}5 0 R{} >>", "[/Not ".repeat(41), "]".repeat(41)); - assert!(!render(&deep, &[5]), "too deep: /OCGs decides"); - } - - /// 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, ..Default::default() }) - }; - 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"); - } - - #[test] - fn tiling_pattern_form_cycles_are_bounded_and_finite_nesting_paints() { - let cases = [ - ("self-cycle", "/Pattern cs /P1 scn 0 0 10 10 re f 0 0 1 rg 0 0 10 10 re f"), - ("pattern-form-cycle", "/F Do 0 0 1 rg 0 0 10 10 re f"), - ("finite-nesting", "/Pattern cs /P2 scn 0 0 10 10 re f"), - ]; - let pattern_b = "0 0 1 rg 0 0 10 10 re f"; - let form = "/Pattern cs /P1 scn 0 0 10 10 re f"; - let page_content = "/Pattern cs /P1 scn 0 0 40 40 re f 1 0 0 rg 32 32 8 8 re f"; - for (case, pattern_a) in cases { - let 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 /MediaBox [0 0 40 40] >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Pattern << /P1 5 0 R /P2 6 0 R >> /XObject << /F 7 0 R >> >> >> endobj\n\ - 4 0 obj << /Length {} >> stream\n{page_content}\nendstream endobj\n\ - 5 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Length {} >> stream\n{pattern_a}\nendstream endobj\n\ - 6 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Length {} >> stream\n{pattern_b}\nendstream endobj\n\ - 7 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Length {} >> stream\n{form}\nendstream endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF", - page_content.len(), - pattern_a.len(), - pattern_b.len(), - form.len() - ) - .into_bytes(); - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); - let _ = tx.send(renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() })); - }); - let page = rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap_or_else(|_| panic!("{case}: render did not finish")); - assert!(page.error.is_none(), "{case}: {:?}", page.error); - assert_eq!((page.width, page.height), (40, 40), "{case}"); - let pixel = |x: u32, y: u32| { - let offset = ((y * page.width + x) * 4) as usize; - [page.rgba[offset], page.rgba[offset + 1], page.rgba[offset + 2], page.rgba[offset + 3]] - }; - assert_eq!(pixel(1, 38), [0, 0, 255, 255], "{case}: nested pattern content paints"); - assert_eq!(pixel(35, 4), [255, 0, 0, 255], "{case}: page content after the pattern still paints"); - } - } - - /// A shaded Type 3 glyph must use bounds in device coordinates at every position. - #[test] - fn shaded_type3_glyph_draws_at_multiple_positions() { - let content = "BT /F 30 Tf 1 0 0 1 50 70 Tm (A) Tj 1 0 0 1 220 70 Tm (A) Tj ET"; - let glyph = "100 0 d0 0 0 100 100 re W n /S sh"; - 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 300 150] /Resources << /Font << /F 5 0 R >> >> /Contents 4 0 R >> endobj -4 0 obj << /Length {} >> stream -{content} -endstream endobj -5 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 100 100] /FontMatrix [0.01 0 0 0.01 0 0] /CharProcs << /A 6 0 R >> /Encoding << /Differences [65 /A] >> /FirstChar 65 /LastChar 65 /Widths [100] /Resources << /Shading << /S 7 0 R >> >> >> endobj -6 0 obj << /Length {} >> stream -{glyph} -endstream endobj -7 0 obj << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> /Extend [true true] >> endobj -trailer << /Root 1 0 R >> -%%EOF", content.len(), glyph.len() - ); - let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); - let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); - assert!(page.error.is_none(), "{:?}", page.error); - for x in [65usize, 235] { - let pixel = &page.rgba[(65 * 300 + x) * 4..][..4]; - assert!(pixel[0] < 240 && pixel[2] < 240 && pixel[1] < 10, "glyph at {x}: {pixel:?}"); - } - } - - /// 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, ..Default::default() }) - }; - 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, ..Default::default() }); - assert!(page.error.is_none(), "{:?}", page.error); - assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255]); - } - - /// One page `w` × `h` points whose content `content` paints with pattern `/P1`: a 10 × 10 cell - /// of blue with `/XStep step /YStep step` and pattern matrix `matrix`. Its RGBA pixels. - fn tiling_page(w: usize, h: usize, content: &str, step: &str, matrix: &str) -> Vec { - let cell = "0 0 1 rg 0 0 10 10 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 {w} {h}] /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 3 /BBox [0 0 10 10] /XStep {step} /YStep {step} /Matrix [{matrix}] /Length {} >> stream -{cell} -endstream endobj -trailer << /Root 1 0 R >> -%%EOF", - content.len(), - cell.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, ..Default::default() }); - assert!(page.error.is_none(), "{:?}", page.error); - page.rgba.to_vec() - } - - /// #3349: PowerPoint (PDFMaker) writes a picture fill as a tiling pattern drawn once, with - /// `/XStep 2147480100 /YStep 2147480100` and a matrix that flips y. Fitting such a step into - /// a cell pixmap shrank the picture below a pixel, so it vanished. The cell's `/BBox` is drawn - /// at full scale, once, filled or stroked: where it is it shows, and around it (left, right, - /// above, below) nothing repeats or smears. - #[test] - fn a_tiling_pattern_drawn_once_shows_its_cell() { - let at = |px: &[u8], w: usize, x: usize, y: usize| px[(y * w + x) * 4..][..3].to_vec(); - let (blue, white) = (vec![0, 0, 255], vec![255, 255, 255]); - // The cell at user x 10..20 and, flipped, y 10..20: device rows 20..30. - let once = tiling_page(40, 40, "/Pattern cs /P1 scn 0 0 40 40 re f", "2147480100", "1 0 0 -1 10 20"); - assert_eq!(at(&once, 40, 15, 25), blue, "the cell"); - for (x, y, side) in [(5, 25, "left"), (25, 25, "right"), (15, 15, "above"), (15, 35, "below")] { - assert_eq!(at(&once, 40, x, y), white, "nothing {side} of it"); - } - // Stroked: a wide line across the cell shows it too. - let stroked = tiling_page(40, 40, "/Pattern CS /P1 SCN 20 w 0 15 m 40 15 l S", "2147480100", "1 0 0 -1 10 20"); - assert_eq!(at(&stroked, 40, 15, 25), blue, "a stroke with the pattern"); - assert_eq!(at(&stroked, 40, 25, 25), white); - // An ordinary step still tiles. - let tiled = tiling_page(40, 40, "/Pattern cs /P1 scn 0 0 40 40 re f", "20", "1 0 0 1 0 0"); - assert_eq!(at(&tiled, 40, 25, 35), blue, "a step of 20 repeats the cell"); - } - - /// #3356 review: a `/BBox` too thin to divide by (not zero, which is refused, but 1e-40 - /// wide) makes the cell scale infinite; it keeps the old path, so no infinite or NaN - /// geometry reaches the cell renderer, the page renders and what follows still draws. - #[test] - fn a_tiling_cell_too_thin_to_scale_keeps_the_old_path() { - assert!(hayro::usable_cell_scale(2.0, 1.0)); - for (xs, ys) in [(f32::INFINITY, 1.0), (1.0, f32::NAN), (0.0, 1.0), (1.0, -2.0)] { - assert!(!hayro::usable_cell_scale(xs, ys), "{xs} {ys}"); - } - let thin = "0.0000000000000000000000000000000000000001"; - let content = "/Pattern cs /P1 scn 0 0 40 40 re f 1 0 0 rg 0 0 4 4 re f"; - let cell = "0 0 1 rg 0 0 10 10 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 3 /BBox [0 0 {thin} 10] /XStep 2147480100 /YStep 2147480100 /Length {} >> stream -{cell} -endstream endobj -trailer << /Root 1 0 R >> -%%EOF", - content.len(), - cell.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, ..Default::default() }); - assert!(page.error.is_none(), "{:?}", page.error); - assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "what follows still draws"); - assert_eq!(&page.rgba[((20 * 40 + 20) * 4)..][..3], &[255, 255, 255], "the degenerate cell draws nothing"); - } - - /// #3349: a step too large for a cell pixmap is drawn as one cell only when the painted area - /// can't reach another: a page wide (or tall) enough to show the next cell, or the one before, - /// still tiles them. - #[test] - fn a_large_step_still_tiles_where_the_next_cell_shows() { - let at = |px: &[u8], w: usize, x: usize, y: usize| px[(y * w + x) * 4..][..3].to_vec(); - let blue = vec![0, 0, 255]; - // Steps of 3100 points: over 3000 pixels at this scale, so the cell is shrunk to fit, - // by 3000/3100; a pixel 4 points into a cell is blue either way. - let wide = "/Pattern cs /P1 scn 0 0 3200 20 re f"; - let next = tiling_page(3200, 20, wide, "3100", "1 0 0 1 0 0"); - assert_eq!(at(&next, 3200, 3104, 15), blue, "the cell to the right"); - let before = tiling_page(3200, 20, wide, "3100", "1 0 0 1 3100 0"); - assert_eq!(at(&before, 3200, 4, 15), blue, "the cell to the left"); - let tall = "/Pattern cs /P1 scn 0 0 20 3200 re f"; - let above = tiling_page(20, 3200, tall, "3100", "1 0 0 1 0 0"); - assert_eq!(at(&above, 20, 4, 3200 - 3104), blue, "the cell above"); - let below = tiling_page(20, 3200, tall, "3100", "1 0 0 1 0 3100"); - assert_eq!(at(&below, 20, 4, 3200 - 4), blue, "the cell below"); - // A stroke counts with its width: this line ends at x 3080, short of the next cell at - // 3100, but its square cap reaches 3110. - let stroked = tiling_page(3200, 20, "/Pattern CS /P1 SCN 60 w 2 J 0 10 m 3080 10 l S", "3100", "1 0 0 1 0 0"); - assert_eq!(at(&stroked, 3200, 3104, 15), blue, "the next cell, under the stroke's cap"); - // A miter reaches further than the line width (review on #3356): this join's vertex is - // at x 3085, 15 short of the next cell with a 10-wide line, but its miter tip, 45 long - // under the default limit of 10, reaches 3130. - let mitered = tiling_page(3200, 20, "/Pattern CS /P1 SCN 10 w 0 j 3000 0 m 3085 10 l 3000 20 l S", "3100", "1 0 0 1 0 0"); - // The tip narrows to a point, so this pixel is partly covered: blue, not white. - let tip = at(&mitered, 3200, 3104, 10); - assert!(tip[0] < 160 && tip[1] < 160 && tip[2] > 200, "the next cell, under the miter's tip: {tip:?}"); - // Under a skew, a direction between the axes stretches more than either axis (review on - // #3356). This join's vertex is at device x 2980 and its miter tip, along that direction, - // at 3107: the larger axis stretch (1.118) put the reach at 3092, short of the next cell; - // the most any direction stretches (1.461) puts it at 3126. - let skewed = tiling_page( - 3200, - 20, - "/Pattern CS /P1 SCN 1 0 1 0.5 0 0 cm 20 w 0 j 2783.936 -366.118 m 3033.6 -53.6 l 2721.082 -303.264 l S", - "3100", - "1 0 0 1 0 0", - ); - // The tip narrows to a point, so its pixels are partly covered. - let under_tip = (3100..3108).flat_map(|x| (10..20).map(move |y| (x, y))).map(|(x, y)| at(&skewed, 3200, x, y)); - assert!( - under_tip.clone().any(|px| px[0] < 200 && px[1] < 200 && px[2] > 240), - "the next cell, under the skewed miter's tip: {:?}", - under_tip.collect::>() - ); - } - - /// 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 { - 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 - } - - /// [`jpx_midpoint_pdf`]'s codestream (every sample 128) wrapped in a JP2 file whose header - /// has a colour specification naming enumerated colour space `enumerated`. - fn jp2_midpoint_pdf(components: u16, enumerated: u32) -> Vec { - let raw = jpx_midpoint_pdf(8, components); - let start = raw.windows(4).position(|w| w == [0xff, 0x4f, 0xff, 0x51]).unwrap(); - let end = raw.windows(2).rposition(|w| w == [0xff, 0xd9]).unwrap() + 2; - let codestream = &raw[start..end]; - let jp2_box = |kind: &[u8], body: &[u8]| [&((8 + body.len()) as u32).to_be_bytes()[..], kind, body].concat(); - let mut ihdr = Vec::new(); - ihdr.extend_from_slice(&1_u32.to_be_bytes()); // height - ihdr.extend_from_slice(&1_u32.to_be_bytes()); // width - ihdr.extend_from_slice(&components.to_be_bytes()); - ihdr.extend_from_slice(&[7, 7, 0, 0]); // 8 bits, wavelet, colour known, no IPR - let colr = [&[1, 0, 0][..], &enumerated.to_be_bytes()].concat(); - let jp2 = [ - vec![0, 0, 0, 12, b'j', b'P', b' ', b' ', 0x0d, 0x0a, 0x87, 0x0a], - jp2_box(b"ftyp", b"jp2 \0\0\0\0jp2 "), - jp2_box(b"jp2h", &[jp2_box(b"ihdr", &ihdr), jp2_box(b"colr", &colr)].concat()), - jp2_box(b"jp2c", codestream), - ] - .concat(); - let content = "q 20 0 0 20 5 5 cm /Im1 Do Q"; - 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 8 /Filter /JPXDecode /Length {} >> stream\n", - content.len(), - jp2.len() - ) - .into_bytes(); - pdf.extend_from_slice(&jp2); - pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF"); - pdf - } - - /// Review on #3184: a palette image whose indices are 1-bit samples (`ihdr` BPC 0, `Ssiz` 0) - /// and whose palette holds 8-bit colours. Its colours are decoded to 8 bits; rescaling them - /// to the indices' 1-bit precision kept only 0 or 255 per channel. Contributor-original: - /// one 1×1 tile with an empty packet, so its one sample is the 1-bit midpoint (index 1). - #[test] - fn a_jp2_palette_keeps_its_colours_whatever_the_index_precision() { - let mut codestream = vec![0xff, 0x4f, 0xff, 0x51, 0, 41, 0, 0]; // SOC, SIZ (Lsiz 41), 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(&[0, 1, 0, 1, 1]); // One component: 1 bit, no subsampling. - 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, 0, 0, 0, 15, 0, 1, 0xff, 0x93, 0]); - codestream.extend_from_slice(&[0xff, 0xd9]); - let jp2_box = |kind: &[u8], body: &[u8]| [&((8 + body.len()) as u32).to_be_bytes()[..], kind, body].concat(); - let ihdr = [&1_u32.to_be_bytes()[..], &1_u32.to_be_bytes(), &[0, 1, 0, 7, 0, 0]].concat(); - let colr = [&[1, 0, 0][..], &16_u32.to_be_bytes()].concat(); // sRGB - // pclr: two entries, three 8-bit columns: red, then (64, 128, 192). - let pclr = [0, 2, 3, 7, 7, 7, 255, 0, 0, 64, 128, 192]; - // cmap: channels 0..2 come from component 0 through palette columns 0..2. - let cmap = [0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 1, 2]; - let jp2 = [ - vec![0, 0, 0, 12, b'j', b'P', b' ', b' ', 0x0d, 0x0a, 0x87, 0x0a], - jp2_box(b"ftyp", b"jp2 \0\0\0\0jp2 "), - jp2_box(b"jp2h", &[jp2_box(b"ihdr", &ihdr), jp2_box(b"colr", &colr), jp2_box(b"pclr", &pclr), jp2_box(b"cmap", &cmap)].concat()), - jp2_box(b"jp2c", &codestream), - ] - .concat(); - let content = "q 20 0 0 20 5 5 cm /Im1 Do Q"; - // No /ColorSpace (and no /BitsPerComponent): the JP2 header gives the colours, through its - // palette (ISO 32000-2 §7.4.9). With an /Indexed space the image holds raw indices at - // their own precision, so index 1 picks the space's entry 1, not entry 255. - let lut = format!("FF00004080C0{}00FF00", "000000".repeat(253)); - for colour_space in [String::new(), format!("/ColorSpace [/Indexed /DeviceRGB 255 <{lut}>] ")] { - 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 {colour_space}/Filter /JPXDecode /Length {} >> stream\n", - content.len(), - jp2.len() - ) - .into_bytes(); - pdf.extend_from_slice(&jp2); - pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%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, ..Default::default() }); - assert!(page.error.is_none(), "{:?}", page.error); - let px = &page.rgba[((40 - 15) * 40 + 15) * 4..][..3]; - assert!(px.iter().zip([64u8, 128, 192]).all(|(a, b)| a.abs_diff(b) <= 2), "{colour_space:.30}: entry 1: {px:?}"); - } - } - - /// #3306: a JP2 image whose colour specification names an enumerated colour space the - /// decoder refuses (CIEJab in a veraPDF fixture, or YPbPr, or bilevel for one component) is - /// drawn as if it had none, by its component count, as MuPDF and Poppler draw it: the - /// midpoint image shows mid-grey instead of nothing. A space the decoder knows (sRGB) is - /// drawn as before. - #[test] - fn a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn() { - for (components, enumerated) in [(3, 19), (3, 22), (1, 15), (3, 16)] { - let mut r = PageRenderer::new(Arc::new(jp2_midpoint_pdf(components, enumerated)), RenderConfig::default()); - let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); - assert!(page.error.is_none(), "{enumerated}: {:?}", page.error); - let px = &page.rgba[((40 - 15) * 40 + 15) * 4..][..3]; - assert!(px.iter().all(|&v| (120..=136).contains(&v)), "enum {enumerated}, {components} component(s): {px:?}"); - } - } - - #[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, ..Default::default() }); - 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, ..Default::default() })); - }); - 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); - } - - /// Scanners and fax services write Group 4 pages with `/Rows 0`, which ISO 32000-2 Table 11 - /// defines as "height not predetermined" (the default), not as zero rows. Such a page used to - /// render blank. The image is an 8 × 4 all-black Group 4 stream, hand-encoded from ITU-T T.6: - /// one horizontal-mode row (white 0, black 8), three V0 rows, then EOFB. - #[test] - fn group4_fax_with_rows_zero_decodes_to_the_image_height() { - const ALL_BLACK_8X4: [u8; 6] = [0x26, 0xa2, 0xfe, 0x00, 0x20, 0x02]; - for rows in ["", "/Rows 4", "/Rows 0"] { - let content = "q 20 0 0 20 5 5 cm /Im1 Do Q"; - let mut 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 << /Type /XObject /Subtype /Image /Width 8 /Height 4 /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter /CCITTFaxDecode /DecodeParms << /K -1 /Columns 8 {rows} >> /Length {} >> stream\n", - content.len(), - ALL_BLACK_8X4.len() - ) - .into_bytes(); - pdf.extend_from_slice(&ALL_BLACK_8X4); - pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF"); - let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); - let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); - assert!(page.error.is_none(), "{rows:?}: {:?}", page.error); - assert_eq!(&page.rgba[((24 * 40 + 15) * 4)..][..4], &[0, 0, 0, 255], "{rows:?}: the fax image paints black"); - assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 255, 255, 255], "{rows:?}: outside the image stays white"); - } - } - - /// #3308: a fax image whose `/Rows` says fewer rows than its `/Height` (a plotter driver wrote - /// `/Rows` equal to `/Columns`) is decoded to its height: only the first `/Rows` rows were - /// decoded and the rest drawn black, as MuPDF and Acrobat don't. `/BlackIs1` turns the fixture - /// white, so rows left undecoded show as black. Also through an `/Indexed` colour space, the - /// filter's other output path. - #[test] - fn group4_fax_with_rows_below_its_height_decodes_to_the_image_height() { - const ALL_BLACK_8X4: [u8; 6] = [0x26, 0xa2, 0xfe, 0x00, 0x20, 0x02]; - for (space, rows) in [("/DeviceGray", "/Rows 2"), ("[/Indexed /DeviceRGB 1 <000000FFFFFF>]", "/Rows 2"), ("/DeviceGray", "/Rows 4")] { - let content = "q 20 0 0 20 5 5 cm /Im1 Do Q"; - let mut 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 << /Type /XObject /Subtype /Image /Width 8 /Height 4 /ColorSpace {space} /BitsPerComponent 1 /Filter /CCITTFaxDecode /DecodeParms << /K -1 /Columns 8 {rows} /BlackIs1 true >> /Length {} >> stream -", - content.len(), - ALL_BLACK_8X4.len() - ) - .into_bytes(); - pdf.extend_from_slice(&ALL_BLACK_8X4); - pdf.extend_from_slice( - b" -endstream endobj -trailer << /Root 1 0 R >> -%%EOF", - ); - let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); - let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); - assert!(page.error.is_none(), "{space} {rows}: {:?}", page.error); - for y in [17, 22, 27, 32] { - assert_eq!(&page.rgba[((y * 40 + 15) * 4)..][..4], &[255, 255, 255, 255], "{space} {rows}: image row at y {y} is decoded"); - } - } - } - - #[test] - fn tiff_predictor_packed_rgb_rows_keep_their_byte_boundaries() { - use hayro_syntax::object::{FromBytes, Object}; - use std::io::Write; - - // Red then green, with only the real sample bits compared: row padding may change. - // The three-column case also checks horizontal differencing: red/green/blue and - // green/red/blue, whose one-bit component differences wrap modulo two. - struct RowCase { - bpc: u8, - columns: u32, - encoded: &'static [u8], - expected: &'static [u8], - masks: &'static [u8], - } - let cases = [ - RowCase { bpc: 1, columns: 1, encoded: &[0x80, 0x40], expected: &[0x80, 0x40], masks: &[0xe0] }, - RowCase { bpc: 2, columns: 1, encoded: &[0xc0, 0x30], expected: &[0xc0, 0x30], masks: &[0xfc] }, - RowCase { bpc: 4, columns: 1, encoded: &[0xf0, 0, 0x0f, 0], expected: &[0xf0, 0, 0x0f, 0], masks: &[0xff, 0xf0] }, - RowCase { bpc: 1, columns: 3, encoded: &[0x99, 0x80, 0x5a, 0x80], expected: &[0x88, 0x80, 0x50, 0x80], masks: &[0xff, 0x80] }, - RowCase { bpc: 8, columns: 1, encoded: &[255, 0, 0, 0, 255, 0], expected: &[255, 0, 0, 0, 255, 0], masks: &[0xff, 0xff, 0xff] }, - ]; - for RowCase { bpc, columns, encoded, expected, masks } in cases { - let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); - encoder.write_all(encoded).unwrap(); - let compressed = encoder.finish().unwrap(); - let mut bytes = format!( - "<< /Filter /FlateDecode /DecodeParms << /Predictor 2 /Colors 3 /BitsPerComponent {bpc} /Columns {columns} >> /Length {} >> stream\n", - compressed.len() - ) - .into_bytes(); - bytes.extend_from_slice(&compressed); - bytes.extend_from_slice(b"\nendstream"); - let Object::Stream(stream) = Object::from_bytes(&bytes).unwrap() else { panic!("stream expected") }; - let decoded = stream.decoded().unwrap(); - assert_eq!(decoded.len(), expected.len()); - for ((actual, expected), mask) in decoded.iter().zip(expected).zip(masks.iter().cycle()) { - assert_eq!(actual & mask, *expected, "{bpc}-bit RGB, {columns} columns"); - } - } - } - - #[test] - fn tiff_predictor_packed_rgb_image_renders_every_row() { - use std::io::Write; - - let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); - encoder.write_all(&[0x80, 0x40]).unwrap(); - let compressed = encoder.finish().unwrap(); - let content = "q 40 0 0 40 0 0 cm /Im1 Do Q"; - 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 2 /ColorSpace /DeviceRGB /BitsPerComponent 1 /Filter /FlateDecode /DecodeParms << /Predictor 2 /Colors 3 /BitsPerComponent 1 /Columns 1 >> /Length {} >> stream\n", - content.len(), - compressed.len() - ) - .into_bytes(); - pdf.extend_from_slice(&compressed); - pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); - 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), (40, 40)); - let pixel = |y: usize| &page.rgba[((y * 40 + 20) * 4)..][..4]; - assert_eq!(pixel(10), &[255, 0, 0, 255]); - assert_eq!(pixel(30), &[0, 255, 0, 255]); - } - - /// 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, ..Default::default() })); - }); - 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, ..Default::default() })); - }); - 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, ..Default::default() })); - }); - 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"); - } - - /// `depth` direct arrays (`array`), dictionaries with a key (`dict`) or both in turn (`mixed`) - /// around `null`. - fn nested(depth: usize, kind: &str) -> String { - let is_dict = |level| kind == "dict" || (kind == "mixed" && level % 2 == 0); - let mut value = String::new(); - for level in 0..depth { - value.push_str(if is_dict(level) { "<< /N " } else { "[" }); - } - value.push_str("null"); - for level in (0..depth).rev() { - value.push_str(if is_dict(level) { " >>" } else { "]" }); - } - value - } - - /// `depth` dictionaries nested without keys (`<< << >> >>`), closed or not. - fn keyless(depth: usize, closed: bool) -> String { - let mut value = "<< ".repeat(depth); - if closed { - value.push_str(&">> ".repeat(depth)); - } - value - } - - /// A 40 × 40 page that runs `content`, with a correct xref table and `trailer` at the end - /// of a trailer that also points at an `/Info` title. - fn nesting_pdf(trailer: &str, content: &str) -> Vec { - let mut bytes = b"%PDF-1.7\n".to_vec(); - let mut offsets = vec![0]; - for (index, body) in [ - "<< /Type /Catalog /Pages 2 0 R >>".to_string(), - "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << >> >>".to_string(), - format!("<< /Length {} >> stream\n{content}\nendstream", content.len()), - "<< /Title (Nested) >>".to_string(), - ] - .iter() - .enumerate() - { - offsets.push(bytes.len()); - bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", index + 1).as_bytes()); - } - let xref = bytes.len(); - bytes.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n"); - for offset in &offsets[1..] { - bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); - } - bytes.extend_from_slice(format!("trailer << /Size 6 /Root 1 0 R /Info 5 0 R {trailer} >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); - bytes - } - - /// Opens `bytes` and renders its one page, which must render without an error. - fn nesting_render(bytes: Vec) -> RenderedPage { - let parsed = Pdf::new(bytes.clone()).expect("xref repair can recover the catalog when the trailer is malformed"); - assert_eq!(parsed.pages().len(), 1); - let mut renderer = PageRenderer::new(Arc::new(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), (40, 40)); - page - } - - /// Direct arrays and dictionaries used to recurse without a bound while skipping even - /// unused trailer values and content operands. Exercise the vendored patch in workspace CI. - #[test] - fn deeply_nested_direct_objects_do_not_overflow_the_stack() { - use hayro_syntax::object::Object; - use hayro_syntax::reader::{Reader, ReaderExt}; - let pdf = |unused: &str, content: &str| nesting_pdf(&format!("/Unused {unused}"), content); - let paint = "1 0 0 rg 0 0 4 4 re f"; - for kind in ["array", "dict", "mixed"] { - // Run the crash reproducer first, so the unpatched red state is a stack overflow. - let page = nesting_render(pdf(&nested(100_000, kind), paint)); - assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "{kind}: repaired trailer"); - for depth in [20, 128, 129] { - let value = nested(depth, kind); - let siblings = format!("{value} [] []"); - let mut reader = Reader::new(siblings.as_bytes()); - assert_eq!(reader.skip::>(false).is_some(), depth <= 128, "{kind}: depth {depth}"); - if depth > 128 { - assert_eq!(reader.offset(), 0, "failed skips restore the offset"); - reader.jump(value.len()); - } - // A failed skip must unwind its depth; successful siblings must not accumulate it. - for _ in 0..2 { - reader.skip_white_spaces(); - assert!(reader.skip::>(false).is_some()); - } - let page = nesting_render(pdf(&value, paint)); - assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "{kind}: depth {depth}"); - } - // Many siblings exercise the offset constructor and ensure depth is per nesting. - let siblings = "[] ".repeat(256); - let mut reader = Reader::new_with(siblings.as_bytes(), 0); - for _ in 0..256 { - assert!(reader.skip::>(false).is_some()); - reader.skip_white_spaces(); - } - } - // Content-stream arrays take the Object path, rather than MaybeRef. - for depth in [100_000, 20, 128, 129] { - let value = nested(depth, "array"); - let mut reader = Reader::new(value.as_bytes()); - assert_eq!(reader.skip::>(true).is_some(), depth <= 128, "content: depth {depth}"); - let content = format!("{value} discard {paint}"); - let page = nesting_render(pdf("null", &content)); - if depth <= 128 { - assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "content: depth {depth}"); - } - } - } - - /// A dictionary with an object where a key belongs is repaired by reading that object, not - /// skipping it, and that read recursed without a bound when the object was another such - /// dictionary: `<<` 100,000 times aborted the process. Here as a content-stream operand. - #[test] - fn dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack() { - for closed in [true, false] { - let content = format!("1 0 0 rg 0 0 4 4 re f {} discard", keyless(100_000, closed)); - let page = nesting_render(nesting_pdf("", &content)); - assert_eq!(px40(&page, 1, 38), &[255, 0, 0, 255], "closed: {closed}"); - } - } - - /// The same, where a key of the trailer belongs: the xref table can't be used, and repair - /// finds the catalog. - #[test] - fn dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack() { - for closed in [true, false] { - let page = nesting_render(nesting_pdf(&keyless(100_000, closed), "1 0 0 rg 0 0 4 4 re f")); - assert_eq!(px40(&page, 1, 38), &[255, 0, 0, 255], "closed: {closed}"); - } - } - - /// The same as the properties of marked content (`/Tag <<<<<< … BDC`). - #[test] - fn dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack() { - for closed in [true, false] { - let properties = if closed { "<<".repeat(100_000) + &">>".repeat(100_000) } else { "<<".repeat(100_000) }; - let content = format!("1 0 0 rg 0 0 4 4 re f /Tag {properties} BDC 0 0 1 rg 36 36 4 4 re f EMC"); - let page = nesting_render(nesting_pdf("", &content)); - assert_eq!(px40(&page, 1, 38), &[255, 0, 0, 255], "closed: {closed}"); - } - } - - /// Through the same three paths, dictionaries without keys are read up to the nesting cap - /// (128 dictionaries in all) and refused one past it. - #[test] - fn dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap() { - use hayro_syntax::object::{FromBytes, Object}; - use hayro_syntax::reader::{Reader, ReaderExt}; - let (red, blue, white) = ([255, 0, 0, 255], [0, 0, 255, 255], [255, 255, 255, 255]); - for depth in [128, 129] { - let within = depth <= 128; - let value = keyless(depth, true); - assert_eq!(Object::from_bytes(value.as_bytes()).is_some(), within, "object: {depth}"); - assert_eq!(Reader::new(value.as_bytes()).read_without_context::>().is_some(), within, "operand: {depth}"); - // Content reading stops at an operand it can't read, so the blue square is drawn - // only when the dictionaries were read. - let properties = "<<".repeat(depth) + &">>".repeat(depth); - for (path, operand) in [("operand", format!("{value} discard")), ("BDC", format!("/Tag {properties} BDC"))] { - let content = format!("1 0 0 rg 0 0 4 4 re f {operand} 0 0 1 rg 36 36 4 4 re f"); - let page = nesting_render(nesting_pdf("", &content)); - assert_eq!(px40(&page, 1, 38), &red, "{path}: {depth}"); - assert_eq!(px40(&page, 38, 1), if within { &blue } else { &white }, "{path}: {depth}"); - } - // The trailer is the outermost dictionary. Within the cap it is read (with its - // /Info); past it, repair recovers the catalog but not the trailer. - let bytes = nesting_pdf(&keyless(depth - 1, true), "1 0 0 rg 0 0 4 4 re f"); - let parsed = Pdf::new(bytes.clone()).expect("the trailer or repair finds the catalog"); - assert_eq!(parsed.metadata().title, within.then(|| b"Nested".to_vec()), "trailer: {depth}"); - assert_eq!(px40(&nesting_render(bytes), 1, 38), &red, "trailer: {depth}"); - } - } - - /// The cap must also fit small stacks: skip, read, iterate down with the array iterators and - /// format (`{:?}`) the deepest nesting it admits, of arrays, dictionaries, both and - /// dictionaries without keys, on a 512 KiB thread in a debug build. Measured when this was - /// written (macOS arm64, dev profile): at most about 100 KiB, for `{:?}` of 128 dictionaries. - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn nesting_at_the_cap_fits_a_512_kib_stack() { - use hayro_syntax::object::{FromBytes, Object}; - use hayro_syntax::reader::{Reader, ReaderExt}; - let worker = std::thread::Builder::new().stack_size(512 * 1024).spawn(|| { - // (kind, arrays and dictionaries `{:?}` shows, levels walked down to the end) - for (kind, arrays, dicts, levels) in [("array", 128, 0, 129), ("dict", 0, 128, 129), ("mixed", 64, 64, 129), ("keyless", 0, 1, 1)] { - let value = if kind == "keyless" { keyless(128, true) } else { nested(128, kind) }; - assert!(Reader::new(value.as_bytes()).skip::>(false).is_some(), "{kind}: skip"); - assert!(Reader::new(value.as_bytes()).skip::>(true).is_some(), "{kind}: skip in content"); - assert!(Reader::new(value.as_bytes()).read_without_context::>().is_some(), "{kind}: operand"); - let object = Object::from_bytes(value.as_bytes()).expect("the cap admits 128 levels"); - let shown = format!("{object:?}"); - assert_eq!((shown.matches("Array(").count(), shown.matches("Dict(").count()), (arrays, dicts), "{kind}: {{:?}}"); - let (mut walked, mut next) = (0, Some(object)); - while let Some(object) = next.take() { - walked += 1; - next = match object { - Object::Array(array) => { - assert_eq!(array.raw_iter().count(), 1, "{kind}: raw_iter"); - assert!(array.flex_iter().next::>().is_some(), "{kind}: flex_iter"); - array.iter::>().next() - } - Object::Dict(dict) => dict.get::>("N"), - _ => None, - }; - } - assert_eq!(walked, levels, "{kind}: iterate"); - } - }); - // A stack overflow aborts the process; a failed check is a panic, shown as it is. - if let Err(panic) = worker.expect("spawn a 512 KiB thread").join() { - std::panic::resume_unwind(panic); - } - } - - /// 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 = 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, ®ion); - 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, ..Default::default() })); - }); - 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 = (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, ..Default::default() })); - }); - 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"); - } - } - - /// Item 1 of storytold/pdfcraft#623: a page's content used to be cut at `MAX_DECODED_STREAM`, - /// so everything after the cut was lost (the text and the title block of the A1 sample). Content - /// that expands by a few times over is read to its end, however large it is. - #[test] - fn page_content_past_the_old_cap_renders() { - let limit = hayro::hayro_syntax::MAX_DECODED_STREAM; - // Blocks of a comment and a run of blanks. Each compresses about thirty times over, well - // within the budget that `ContentReader` allows. - let mut content = Vec::with_capacity(limit + (8 << 20)); - let mut state = 0x2545_f491_4f6c_dd1d_u64; - while content.len() < limit + (8 << 20) { - content.push(b'%'); - for _ in 0..3 { - state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1); - content.push(b'a' + ((state >> 33) % 26) as u8); - } - content.push(b'\n'); - content.resize(content.len() + 128, b' '); - } - content.extend_from_slice(b"1 0 0 rg 0 0 4 4 re f"); - let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); - std::io::Write::write_all(&mut z, &content).unwrap(); - let stream = z.finish().unwrap(); - assert!(content.len() < 64 * stream.len(), "the content expands by less than the budget allows"); - - 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 >> endobj\n".to_vec(); - pdf.extend_from_slice(format!("4 0 obj << /Filter /FlateDecode /Length {} >> stream\n", stream.len()).as_bytes()); - pdf.extend_from_slice(&stream); - 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, preview: false })); - }); - let page = rx.recv_timeout(std::time::Duration::from_secs(120)).expect("a large page must render"); - assert!(page.error.is_none(), "{:?}", page.error); - assert!(!page.warnings.contains(&RenderWarning::ContentTruncated), "nothing was cut"); - assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the square after the old cap draws"); - } - - /// The content stream is interpreted a window at a time. An inline image with more data than - /// a window is read whole, and what follows it is still drawn. - #[test] - fn inline_images_longer_than_a_window_are_read_whole() { - let mut content = b"q 300 0 0 300 0 0 cm BI /W 300 /H 300 /BPC 8 /CS /G ID ".to_vec(); - content.extend(std::iter::repeat_n(0x80_u8, 300 * 300)); - content.extend_from_slice(b" EI Q 1 0 0 rg 0 0 4 4 re f"); - let pdf = one_page_pdf(&content, ""); - 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, preview: false })); - }); - let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("an inline image must not stall the renderer"); - assert!(page.error.is_none(), "{:?}", page.error); - let pixel = |x: u32, y: u32| { - let offset = ((y * page.width + x) * 4) as usize; - [page.rgba[offset], page.rgba[offset + 1], page.rgba[offset + 2], page.rgba[offset + 3]] - }; - assert_eq!(pixel(1, 38), [255, 0, 0, 255], "the square after the image draws"); - let grey = pixel(30, 5); - assert!(grey[0] == grey[1] && grey[1] == grey[2] && grey[0] < 255, "the image covers the page: {grey:?}"); - } - - /// A five-point open polyline is not a rectangle. `path_as_rect` took any five-element path - /// whose points covered the four corners of its bounding box for that box, and a `MoveTo` with - /// four `LineTo` is five elements, so an open zigzag stroked with `S` was painted as its - /// bounding rectangle: the peak between its ends vanished and the open side came out closed. - #[test] - fn a_five_point_polyline_is_not_painted_as_its_bounding_box() { - // The ends and the points beside them sit on the four corners of the bounding box; the - // peak at (20, 20) lies between them and is on no corner. - let content = b"0 0 0 RG 2 w 30 30 m 30 10 l 20 20 l 10 10 l 10 30 l S".to_vec(); - let pdf = one_page_pdf(&content, ""); - 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, preview: false })); - }); - let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("the polyline must render"); - assert!(page.error.is_none(), "{:?}", page.error); - // Device y counts down the 40 pt page, so the two ends at y = 30 are on row 10. - let dark = |x: u32, y: u32| { - let offset = ((y * page.width + x) * 4) as usize; - page.rgba[offset] < 128 - }; - assert!((17..=23).any(|x| (17..=23).any(|y| dark(x, y))), "the peak between the ends draws"); - assert!(!(14..=26).any(|x| dark(x, 10)), "the side the path leaves open stays open"); - } - - /// Five points all on the corners still need not trace the rectangle: three sides and a step - /// back along the third is such a path, and it was painted as the box -- drawing a fourth - /// side the path does not have (FredApps on #3327). - #[test] - fn a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle() { - // Bottom, right, top, then back along the top. The left side is never drawn. - let content = b"0 0 0 RG 2 w 10 10 m 30 10 l 30 30 l 10 30 l 30 30 l S".to_vec(); - let pdf = one_page_pdf(&content, ""); - 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, preview: false })); - }); - let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("the path must render"); - assert!(page.error.is_none(), "{:?}", page.error); - let dark = |x: u32, y: u32| { - let offset = ((y * page.width + x) * 4) as usize; - page.rgba[offset] < 128 - }; - // Device y counts down the 40 pt page: the bottom side at y = 10 is row 30. - assert!((14..=26).any(|x| dark(x, 30)), "the sides the path does draw are drawn"); - assert!(!(14..=26).any(|y| dark(10, y)), "the left side it never draws is not drawn"); - } - - /// A rectangle still takes the rectangle route, written either way: `m l l l` and closed, or - /// `m l l l l` back to its first corner. - #[test] - fn a_rectangle_is_still_painted_as_a_rectangle() { - for content in [&b"0 0 0 rg 10 10 m 30 10 l 30 30 l 10 30 l f"[..], &b"0 0 0 rg 10 10 m 30 10 l 30 30 l 10 30 l 10 10 l f"[..]] { - let pdf = one_page_pdf(content, ""); - 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, preview: false })); - }); - let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("the rectangle must render"); - assert!(page.error.is_none(), "{:?}", page.error); - let pixel = |x: u32, y: u32| { - let offset = ((y * page.width + x) * 4) as usize; - page.rgba[offset] - }; - assert!(pixel(20, 20) < 128, "the rectangle is filled"); - assert_eq!(pixel(5, 5), 255, "and nothing outside it is"); - } - } - - /// A one-page PDF whose content is the stream `stream`, with the entries `dict` in its - /// dictionary. - fn one_page_pdf(stream: &[u8], dict: &str) -> Vec { - 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 >> endobj\n".to_vec(); - pdf.extend_from_slice(format!("4 0 obj << {dict} /Length {} >> stream\n", stream.len()).as_bytes()); - pdf.extend_from_slice(stream); - pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); - pdf - } - - /// A document whose `pages` page dictionaries all live in one (uncompressed) object stream, - /// found through a cross-reference stream: every page shows the content `0 0 1 rg 10 10 80 80 - /// re f` on a 100 pt square, except the last, which is 50 × 100 pt. - fn pages_in_one_object_stream(pages: usize) -> Vec { - // 1 catalog, 2 page tree, 3 content, 4 the object stream, 5.. the pages, then the xref. - let page = |n: usize| { - let size = if n + 1 == pages { "50 100" } else { "100 100" }; - format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {size}] /Contents 3 0 R >>\n") - }; - let (mut index, mut body) = (String::new(), String::new()); - for n in 0..pages { - index.push_str(&format!("{} {} ", n + 5, body.len())); - body.push_str(&page(n)); - } - index.push('\n'); - let kids: Vec = (0..pages).map(|n| format!("{} 0 R", n + 5)).collect(); - let content = "0 0 1 rg 10 10 80 80 re f"; - let mut pdf = b"%PDF-1.5\n".to_vec(); - let mut offsets = Vec::new(); - for (num, obj) in [ - (1, "<< /Type /Catalog /Pages 2 0 R >>".to_string()), - (2, format!("<< /Type /Pages /Kids [{}] /Count {pages} >>", kids.join(" "))), - (3, format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())), - ( - 4, - format!( - "<< /Type /ObjStm /N {pages} /First {} /Length {} >>\nstream\n{index}{body}\nendstream", - index.len(), - index.len() + body.len() - ), - ), - ] { - offsets.push(pdf.len()); - pdf.extend_from_slice(format!("{num} 0 obj\n{obj}\nendobj\n").as_bytes()); - } - // Cross-reference stream rows of /W [1 4 4]: free 0, objects 1–4 in the file, the pages - // at their index in object 4, and the cross-reference stream itself. - let xref_num = pages + 5; - let xref_at = pdf.len(); - let mut rows = vec![0u8, 0, 0, 0, 0, 0, 0, 0xff, 0xff]; - let row = |kind: u8, a: usize, b: usize| [[kind].as_slice(), &(a as u32).to_be_bytes(), &(b as u32).to_be_bytes()].concat(); - for at in &offsets { - rows.extend(row(1, *at, 0)); - } - for n in 0..pages { - rows.extend(row(2, 4, n)); - } - rows.extend(row(1, xref_at, 0)); - pdf.extend_from_slice( - format!("{xref_num} 0 obj\n<< /Type /XRef /Size {} /W [1 4 4] /Root 1 0 R /Length {} >>\nstream\n", xref_num + 1, rows.len()).as_bytes(), - ); - pdf.extend_from_slice(&rows); - pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref_at}\n%%EOF\n").as_bytes()); - pdf - } - - /// An object stream's index of `objnum offset` pairs is read once, not for every object - /// fetched from it (vendored hayro-syntax patch 22). Read again each time, all of a stream of - /// N objects took N² steps: a Firefox page keeping 65,542 objects in one stream took 37 s to - /// draw and 40 s to extract its text. These 40,000 pages in one stream took about a minute - /// to open that way; now a fraction of a second. The last page, at the end of the index, - /// still has its own size. - #[test] - fn objects_in_one_large_object_stream_are_read_in_linear_time() { - const PAGES: usize = 40_000; - let pdf = pages_in_one_object_stream(PAGES); - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); - let first = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); - let last = r.render(RenderRequest { page: PAGES - 1, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); - let _ = tx.send((first, last)); - }); - let (first, last) = - rx.recv_timeout(std::time::Duration::from_secs(20)).expect("reading every object of one object stream must not take quadratic time"); - assert!(first.error.is_none(), "{:?}", first.error); - assert_eq!((first.width, first.height), (100, 100)); - assert_eq!(rgb_at(&first.rgba, first.width as usize, 50, 50), [0, 0, 255]); - assert!(last.error.is_none(), "{:?}", last.error); - assert_eq!((last.width, last.height), (50, 100), "the last page is read from its own offset"); - } - - /// #2946: ScanSoft PDF Create! writes the `post` table version of every font it embeds with - /// its 16-bit halves swapped (`00 00 00 03` for 3.0). Such a font must still be used, drawing - /// exactly as with a correct version; a version that is neither (9.0) is still refused and - /// drawn with a substitute, as before. - #[test] - fn a_font_whose_post_version_has_swapped_halves_is_still_used() { - let font = include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"); - // The `post` table's offset, from the table directory. - let tables = usize::from(u16::from_be_bytes([font[4], font[5]])); - let post = (0..tables) - .map(|i| &font[12 + 16 * i..28 + 16 * i]) - .find(|e| &e[..4] == b"post") - .map(|e| u32::from_be_bytes([e[8], e[9], e[10], e[11]]) as usize) - .expect("the test font has a post table"); - let render_with = |version: [u8; 4], encoding: &str| -> Vec { - let mut program = font.to_vec(); - program[post..post + 4].copy_from_slice(&version); - let content = b"BT /F1 28 Tf 10 40 Td (Hamburg 0123) Tj ET"; - let widths = vec!["600"; 95].join(" "); - let objects: Vec> = vec![ - b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), - b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), - b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 80] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), - [format!("<< /Length {} >>\nstream\n", content.len()).into_bytes(), content.to_vec(), b"\nendstream".to_vec()].concat(), - format!("<< /Type /Font /Subtype /TrueType /BaseFont /JetBrainsMono-Regular /FirstChar 32 /LastChar 126 /Widths [{widths}] /FontDescriptor 6 0 R /Encoding {encoding} >>").into_bytes(), - b"<< /Type /FontDescriptor /FontName /JetBrainsMono-Regular /Flags 32 /FontBBox [-1000 -300 1000 1000] /ItalicAngle 0 /Ascent 1020 /Descent -300 /CapHeight 730 /StemV 80 /FontFile2 7 0 R >>".to_vec(), - [format!("<< /Length {} >>\nstream\n", program.len()).into_bytes(), program, b"\nendstream".to_vec()].concat(), - ]; - let mut pdf = b"%PDF-1.7\n".to_vec(); - let mut offsets = Vec::new(); - for (i, o) in objects.iter().enumerate() { - offsets.push(pdf.len()); - pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); - pdf.extend_from_slice(o); - pdf.extend_from_slice(b"\nendobj\n"); - } - let xref = pdf.len(); - pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); - for o in offsets { - pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); - } - pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); - let page = PageRenderer::new(Arc::new(pdf), RenderConfig::default()).render(RenderRequest { - page: 0, - kind: RequestKind::Pixels, - tile: None, - scale: 2.0, - tag: 0, - preview: false, - }); - assert!(page.error.is_none(), "{:?}", page.error); - page.rgba.to_vec() - }; - let render = |version: [u8; 4]| render_with(version, "/WinAnsiEncoding"); - let correct = render([0, 3, 0, 0]); - assert_eq!(render([0, 0, 0, 3]), correct, "3.0 with swapped halves draws with the embedded font"); - assert_eq!(render([0, 0, 0, 2]), render([0, 2, 0, 0]), "2.0 with swapped halves too"); - assert_ne!(render([0, 9, 0, 0]), correct, "an unknown version is still refused"); - // A /Differences name only the font's own post table knows (W's alternate, `W.cv07`) - // is found through a swapped 2.0 table too (review on #2949). - let alternate = |version: [u8; 4]| render_with(version, "<< /BaseEncoding /WinAnsiEncoding /Differences [72 /W.cv07] >>"); - assert_ne!(alternate([0, 2, 0, 0]), render([0, 2, 0, 0]), "the alternate W draws instead of H"); - assert_eq!(alternate([0, 0, 0, 2]), alternate([0, 2, 0, 0]), "and also with swapped halves"); - } - - /// A page whose content expands far beyond its compressed stream stops at the expansion budget, - /// which is 64 times the compressed size and at least `MAX_DECODED_STREAM`, and the page says so. - #[test] - fn content_bombs_stop_at_the_budget_and_say_so() { - let bomb = zlib_zeros(hayro::hayro_syntax::MAX_DECODED_STREAM + (32 << 20)); - let pdf = one_page_pdf(&bomb, "/Filter /FlateDecode"); - 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, preview: false })); - }); - let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("a content bomb must not stall the renderer"); - assert!(page.error.is_none(), "{:?}", page.error); - assert!(page.warnings.contains(&RenderWarning::ContentTruncated), "the cut content is observable"); - } - - /// A stream read in pieces of any size gives the same bytes: stored and dynamic blocks, and in - /// a `/Contents` array a stream that is not Flate-coded, between two that are. - #[test] - fn content_reader_returns_the_same_bytes_in_any_pieces() { - use hayro::hayro_syntax::Pdf; - use std::io::Write; - let mut plain = Vec::new(); - for i in 0..40_000_u32 { - plain.extend_from_slice(format!("{} {} l\n", i % 977, i % 613).as_bytes()); - } - let zlib = |data: &[u8], level| { - let mut z = flate2::write::ZlibEncoder::new(Vec::new(), level); - z.write_all(data).unwrap(); - z.finish().unwrap() - }; - let read = |pdf: Vec, piece: usize| { - let doc = Pdf::new(pdf).expect("parses"); - let page = doc.pages().first().expect("one page"); - let mut content = page.content_reader(); - let mut out = Vec::new(); - while content.read_into(&mut out, piece) > 0 {} - out - }; - let pieces = [1, 3, 64, 4096, 65_536, 1_000_000]; - - for level in [flate2::Compression::none(), flate2::Compression::best()] { - let pdf = one_page_pdf(&zlib(&plain, level), "/Filter /FlateDecode"); - for piece in pieces { - assert!(read(pdf.clone(), piece) == plain, "piece {piece}"); - } - } - - // A `/Contents` array: Flate, then ASCIIHex (decoded whole), then Flate. As before, each - // stream is followed by a space. - let hex: String = plain[..100].iter().map(|b| format!("{b:02x}")).collect::() + ">"; - let first = zlib(&plain, flate2::Compression::default()); - let last = zlib(&plain[..50], flate2::Compression::default()); - let mut expected = plain.clone(); - expected.push(b' '); - expected.extend_from_slice(&plain[..100]); - expected.push(b' '); - expected.extend_from_slice(&plain[..50]); - expected.push(b' '); - 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 5 0 R 6 0 R] >> endobj\n".to_vec(); - pdf.extend_from_slice(format!("4 0 obj << /Filter /FlateDecode /Length {} >> stream\n", first.len()).as_bytes()); - pdf.extend_from_slice(&first); - pdf.extend_from_slice(format!("\nendstream endobj\n5 0 obj << /Filter /ASCIIHexDecode /Length {} >> stream\n", hex.len()).as_bytes()); - pdf.extend_from_slice(hex.as_bytes()); - pdf.extend_from_slice(format!("\nendstream endobj\n6 0 obj << /Filter /FlateDecode /Length {} >> stream\n", last.len()).as_bytes()); - pdf.extend_from_slice(&last); - pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); - for piece in pieces { - assert!(read(pdf.clone(), piece) == expected, "the array: piece {piece}"); - } - } - - /// Zlib data that inflates to `len` zero bytes. - fn zlib_zeros(len: usize) -> Vec { - 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 { - 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 { - 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, RunLength and ASCII85 - /// (#1796: each `z` is four bytes, here after Flate) 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 mut a85 = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best()); - std::io::Write::write_all(&mut a85, &b"z".repeat(big / 4 + 1)).unwrap(); - let streams: [(&str, Vec); 5] = [ - ("/FlateDecode", zlib_zeros(big)), - ("/FlateDecode", deflate_for_the_fallback(big)), - ("/LZWDecode", lzw_zeros(big / 7_000_000 + 1)), - ("/RunLengthDecode", run_length), - ("[/FlateDecode /ASCII85Decode]", a85.finish().unwrap()), - ]; - 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> = doc.objects().into_iter().filter_map(|o| if let Object::Stream(s) = o { Some(s) } else { None }).collect(); - assert_eq!(found.len(), 5, "all five 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(¶ms).expect("decodes as an image"); - assert!(image.data.len() <= 128, "{what}: an 8 × 8 image decodes at most 128 bytes, not {}", image.data.len()); - } - } - - #[test] - fn form_cycles_are_bounded_and_finite_nesting_paints() { - let cases = [ - ("self-cycle", "/A Do 0 0 1 rg 0 0 8 8 re f", "0 0 1 rg 0 0 8 8 re f"), - ("two-form-cycle", "/B Do 0 0 1 rg 0 0 8 8 re f", "/A Do 0 0 1 rg 0 0 8 8 re f"), - ("finite-nesting", "/B Do", "0 0 1 rg 0 0 8 8 re f"), - ]; - let page_content = "/A Do 1 0 0 rg 32 32 8 8 re f"; - for (case, form_a, form_b) in cases { - let 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 /MediaBox [0 0 40 40] >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /A 5 0 R /B 6 0 R >> >> >> endobj\n\ - 4 0 obj << /Length {} >> stream\n{page_content}\nendstream endobj\n\ - 5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length {} >> stream\n{form_a}\nendstream endobj\n\ - 6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length {} >> stream\n{form_b}\nendstream endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF", - page_content.len(), - form_a.len(), - form_b.len() - ) - .into_bytes(); - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); - let _ = tx.send(renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() })); - }); - let page = rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap_or_else(|_| panic!("{case}: render did not finish")); - assert!(page.error.is_none(), "{case}: {:?}", page.error); - assert_eq!((page.width, page.height), (40, 40), "{case}"); - let pixel = |x: u32, y: u32| { - let offset = ((y * page.width + x) * 4) as usize; - [page.rgba[offset], page.rgba[offset + 1], page.rgba[offset + 2], page.rgba[offset + 3]] - }; - assert_eq!(pixel(3, 35), [0, 0, 255, 255], "{case}: nested form content paints"); - assert_eq!(pixel(35, 4), [255, 0, 0, 255], "{case}: page content after the form still paints"); - } - } - - /// 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, ..Default::default() })); - }); - // Only a guard against a hang: the shared budget is checked by the warnings below, which - // come out the same on every machine. The render decodes and reads 512 MiB of content, - // a third of a second on a desktop, and a busy FreeBSD VM in CI ran past the minute this - // used to allow. - let page = rx.recv_timeout(std::time::Duration::from_secs(600)).expect("a self-painting form must not stall the renderer"); - assert!(page.error.is_none(), "{:?}", page.error); - assert!(page.warnings.contains(&RenderWarning::ContentTruncated), "the skipped nested content is observable"); - assert_eq!( - page.warnings.iter().filter(|w| **w == RenderWarning::ContentTruncated).count(), - 1, - "reported once per page, not once per nested paint" - ); - 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, ..Default::default() }); - 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, ..Default::default() })); - }); - 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"); - } - - /// A sampled function kept its samples in a hash map keyed by each point of its grid, built - /// again every time a shading using it was painted (vendored hayro-interpret patch 31). - /// InDesign pages paint 4096-sample gradient ramps dozens of times, and `bug1721218` in the - /// pdf.js corpus spent most of its 1.5 s hashing them. The samples are now one flat array - /// indexed by strides: this page paints a 2,097,152-sample ramp (the most a table may hold) - /// 30 times, which took 38 seconds through the hash map. - /// - /// The build each paint does is timed against reading the function's samples, the part of it - /// no table can skip, not against a deadline: through the hash map it took about a hundred - /// times as long as the read, through the flat array about as long. A deadline can't tell - /// them apart on every machine: the hash map's 30 paints took 14 s on one desktop, the flat - /// array's over 10 s on a busy FreeBSD VM in CI. No counter does either (both read every - /// sample on every build), so the test compares two times taken on this thread, the fastest - /// of several rounds of each: a stall from other work (other tests, swapping) would have to - /// slow every build and spare a read to fail it. - #[test] - fn shadings_with_large_sampled_functions_paint_quickly() { - use hayro::hayro_interpret::Function; - use hayro_syntax::bit_reader::BitReader; - use hayro_syntax::object::{FromBytes, Object}; - use std::time::{Duration, Instant}; - - const SAMPLES: usize = 1 << 21; - let samples = zlib_zeros(SAMPLES); - let function = format!( - "<< /FunctionType 0 /Domain [0 1] /Range [0 1] /Size [{SAMPLES}] /BitsPerSample 8 /Filter /FlateDecode /Length {} >>", - samples.len() - ); - // Black ramp on the left half, 30 times over; a red square after it. - let content = "q 0 0 20 40 re W n /Sh0 sh Q\n".repeat(30) + "1 0 0 rg 36 36 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}\nendstream endobj\n", content.len()).as_bytes()); - pdf.extend_from_slice(b"5 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 {function} stream\n").as_bytes()); - pdf.extend_from_slice(&samples); - pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); - let page = render_first_page(pdf); - assert_eq!(rgb_at(&page.rgba, 40, 5, 20), [0, 0, 0], "the ramp is drawn"); - assert_eq!(rgb_at(&page.rgba, 40, 30, 20), [255, 255, 255], "outside the clip"); - assert_eq!(rgb_at(&page.rgba, 40, 38, 2), [255, 0, 0], "the rest of the page draws"); - - // What each paint does: build the function from its stream. Timed against reading the - // stream's samples, the part of the build no table can skip. - let mut bytes = format!("{function} stream\n").into_bytes(); - bytes.extend_from_slice(&samples); - bytes.extend_from_slice(b"\nendstream"); - let object = Object::from_bytes(&bytes).expect("a stream object"); - let Object::Stream(stream) = &object else { panic!("the function is a stream") }; - let (mut build, mut read) = (Duration::MAX, Duration::MAX); - for _ in 0..8 { - let started = Instant::now(); - let function = Function::new(&object); - assert!(function.is_some(), "a sampled function"); - drop(function); - build = build.min(started.elapsed()); - - let started = Instant::now(); - let decoded = stream.decoded().expect("the samples decode"); - let mut reader = BitReader::new(&decoded); - let mut values = Vec::new(); - while values.len() < SAMPLES - && let Some(value) = reader.read(8) - { - values.push(value); - } - assert_eq!(std::hint::black_box(&values).len(), SAMPLES); - drop(values); - read = read.min(started.elapsed()); - } - assert!( - build < read * 10, - "building a {SAMPLES}-sample function took {build:?}, over ten times the {read:?} reading its samples takes (a hash map took a hundred times)" - ); - } - - /// Patch 31's flat table gives every sampled function the values the hash map gave: points - /// on the grid, multilinear interpolation between them (first input varying fastest), and no - /// value where a sample the interpolation needs is missing from a short stream. - #[test] - fn sampled_functions_interpolate_their_grid_as_before() { - use hayro::hayro_interpret::Function; - use hayro_syntax::object::{FromBytes, Object}; - - // Size [3 2], two outputs: out0 = 40 i + 60 j (linear), out1 = 100 i j (bilinear only). - let mut samples = Vec::new(); - for j in 0..2u8 { - for i in 0..3u8 { - samples.extend_from_slice(&[40 * i + 60 * j, 100 * i * j]); - } - } - let function = |data: &[u8]| { - let mut bytes = - format!("<< /FunctionType 0 /Domain [0 1 0 1] /Range [0 1 0 1] /Size [3 2] /BitsPerSample 8 /Length {} >> stream\n", data.len()) - .into_bytes(); - bytes.extend_from_slice(data); - bytes.extend_from_slice(b"\nendstream"); - bytes - }; - let whole = function(&samples); - let f = Function::new(&Object::from_bytes(&whole).expect("a stream object")).expect("a sampled function"); - for (x, y) in [(0.0, 0.0), (1.0, 1.0), (0.5, 0.0), (0.25, 0.5), (0.75, 0.25), (0.1, 0.9), (1.0, 0.3)] { - let (gi, gj) = (2.0 * x, y); - let got = f.eval([x, y].into_iter().collect()).expect("every sample is present"); - let want = [(40.0 * gi + 60.0 * gj) / 255.0, 100.0 * gi.min(1.0) * gj / 255.0 + 100.0 * (gi - 1.0).max(0.0) * gj / 255.0]; - assert!(got.len() == 2 && got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-5), "({x}, {y}): got {got:?}, want {want:?}"); - } - // Only the j = 0 row: points on it evaluate, points needing j = 1 have no value. - let short = function(&samples[..6]); - let f = Function::new(&Object::from_bytes(&short).expect("a stream object")).expect("a sampled function"); - assert_eq!(f.eval([0.5, 0.0].into_iter().collect()).map(|v| v.to_vec()), Some(vec![40.0 / 255.0, 0.0])); - assert_eq!(f.eval([0.5, 0.5].into_iter().collect()), None); - } - - /// 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| { - 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, ..Default::default() })); - }); - 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, ..Default::default() }); - 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:?}"); - } - - /// Coons and tensor-product patches put each corner's colour at its corner and blend them - /// in between. Patch 33 interpolates each point of a patch's 20 × 20 triangle grid once - /// instead of once per cell touching it, and moves vertices instead of copying them; these - /// colours (taken before that change) pin that every vertex still gets its own point's colour. - #[test] - fn patch_meshes_keep_their_corner_colours() { - // Points on a 4 × 4 grid over the 40 pt page, in stream order: the 12 boundary points - // (both patch types), then a tensor patch's four interior ones. - let grid = |x: u8, y: u8| [x * 85, y * 85]; - let boundary = [(0, 0), (0, 1), (0, 2), (0, 3), (1, 3), (2, 3), (3, 3), (3, 2), (3, 1), (3, 0), (2, 0), (1, 0)]; - let interior = [(1, 1), (1, 2), (2, 2), (2, 1)]; - let colours = [255u8, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 0]; - let patch = |points: &[(u8, u8)]| { - let mut data = vec![0u8]; - data.extend(points.iter().flat_map(|(x, y)| grid(*x, *y))); - data.extend_from_slice(&colours); - data - }; - let render = |shading_type: u8, data: Vec| { - let content = b"/Sh0 sh"; - 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(format!("\nendstream endobj\n5 0 obj << /ShadingType {shading_type} /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 40 0 40 0 1 0 1 0 1] /Length {} >> stream\n", data.len()).as_bytes()); - pdf.extend_from_slice(&data); - pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); - render_first_page(pdf) - }; - let coons = render(6, patch(&boundary)); - let tensor = render(7, patch(&[&boundary[..], &interior[..]].concat())); - // Near each corner (top-left, bottom-left, bottom-right, top-right in the raster), the - // centre, and a point between them. - let want = [ - ((5, 5), [32, 199, 28]), - ((5, 34), [217, 61, 5]), - ((34, 34), [217, 190, 32]), - ((34, 5), [32, 61, 190]), - ((20, 20), [127, 128, 64]), - ((12, 27), [172, 110, 25]), - ]; - for (what, page) in [("Coons", &coons), ("tensor", &tensor)] { - for ((x, y), colour) in want { - let got = rgb_at(&page.rgba, 40, x, y); - assert!(got.iter().zip(colour).all(|(g, w)| g.abs_diff(w) <= 1), "{what} patch at ({x}, {y}): got {got:?}, want {colour:?}"); - } - } - } - - /// 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, ..Default::default() })); - }); - 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:?}"); - } - - /// Two triangles of a mesh that share an edge cover it together. The sampler tested each - /// pixel's corner, and only strictly inside a triangle, so the diagonal two triangles share - /// was left white wherever it ran through the samples, and so was a pixel row or column - /// along the mesh's own sides. Vendored hayro-interpret patch 36. - #[test] - fn mesh_triangles_sharing_an_edge_leave_no_seam() { - // A red square from 10 to 40 on a 50 × 50 page, cut along its diagonal; in device space - // the diagonal runs through pixel centres and corners alike. - let vertex = |flag: u8, x: u8, y: u8| [flag, x, y, 255, 0, 0]; - let (lo, hi) = (10, 40); - let mut triangles = Vec::new(); - for [a, b, c] in [[(lo, lo), (hi, lo), (hi, hi)], [(lo, lo), (hi, hi), (lo, hi)]] { - for (x, y) in [a, b, c] { - triangles.extend(vertex(0, x, y)); - } - } - // Each triangle as its own three vertices, and the second one as a strip (flag 1). - let mut strip = Vec::new(); - for (flag, (x, y)) in [(0, (lo, lo)), (0, (hi, lo)), (0, (hi, hi)), (2, (lo, hi))] { - strip.extend(vertex(flag, x, y)); - } - for (label, data) in [("separate triangles", triangles), ("a strip", strip)] { - let content = b"/Sh0 sh"; - 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 50 50] /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(format!("\nendstream endobj\n5 0 obj << /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Length {} >> stream\n", data.len()).as_bytes()); - pdf.extend_from_slice(&data); - 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, ..Default::default() }); - assert!(page.error.is_none(), "{label}: {:?}", page.error); - assert_eq!((page.width, page.height), (50, 50)); - let (mut white_inside, mut red_outside) = (Vec::new(), Vec::new()); - for y in 0..50_usize { - for x in 0..50_usize { - let p = &page.rgba[(y * 50 + x) * 4..][..4]; - let inside = (10..40).contains(&x) && (10..40).contains(&y); - if inside && p != [255, 0, 0, 255] { - white_inside.push((x, y)); - } else if !inside && p != [255, 255, 255, 255] { - red_outside.push((x, y)); - } - } - } - assert!(white_inside.is_empty(), "{label}: unshaded pixels inside the square: {white_inside:?}"); - assert!(red_outside.is_empty(), "{label}: shaded pixels outside the square: {red_outside:?}"); - } - - // One triangle alone, the square's upper left half: a pixel whose corner lies on its - // diagonal is taken by its centre, so one wholly across the diagonal (x + y ≥ 50 in - // device space) is not shaded, and one wholly inside is. - let mut data = Vec::new(); - for (x, y) in [(lo, lo), (hi, hi), (lo, hi)] { - data.extend(vertex(0, x, y)); - } - let content = b"/Sh0 sh"; - 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 50 50] /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(format!("\nendstream endobj\n5 0 obj << /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Length {} >> stream\n", data.len()).as_bytes()); - pdf.extend_from_slice(&data); - 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, ..Default::default() }); - assert!(page.error.is_none(), "{:?}", page.error); - let (mut spilled, mut missing) = (Vec::new(), Vec::new()); - for y in 10..40_usize { - for x in 10..40_usize { - let p = &page.rgba[(y * 50 + x) * 4..][..4]; - if x + y >= 50 && p == [255, 0, 0, 255] { - spilled.push((x, y)); - } else if x + y + 2 <= 50 && p != [255, 0, 0, 255] { - missing.push((x, y)); - } - } - } - assert!(spilled.is_empty(), "shaded pixels wholly across the diagonal: {spilled:?}"); - assert!(missing.is_empty(), "unshaded pixels wholly inside the triangle: {missing:?}"); - } - - /// A pixel whose corner is inside one triangle and whose centre is inside its neighbour - /// takes the colour at its corner, whichever triangle comes first (patch 36): the - /// centre-coverage pass only fills pixels no triangle has given a colour yet. Here the - /// shared diagonal lies at x + y = 50.25 in device space, so pixel (20, 30) has its corner - /// in the red triangle and its centre in the blue one. - #[test] - fn a_shared_edge_pixel_keeps_the_colour_at_its_corner() { - let vertex = |x: u8, y: u8, rgb: [u8; 3]| [0, x, y, rgb[0], rgb[1], rgb[2]]; - let (lo, hi) = (10, 40); - let (red, blue) = ([255, 0, 0], [0, 0, 255]); - // In PDF space the red triangle is the square's upper left half, the blue one the lower - // right; `cm` moves both down a quarter point. - let red_triangle = [(lo, lo, red), (hi, hi, red), (lo, hi, red)]; - let blue_triangle = [(lo, lo, blue), (hi, lo, blue), (hi, hi, blue)]; - for (label, order) in [("red first", [red_triangle, blue_triangle]), ("blue first", [blue_triangle, red_triangle])] { - let mut data = Vec::new(); - for triangle in order { - for (x, y, rgb) in triangle { - data.extend(vertex(x, y, rgb)); - } - } - let content = b"1 0 0 1 0 -0.25 cm /Sh0 sh"; - 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 50 50] /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(format!("\nendstream endobj\n5 0 obj << /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Length {} >> stream\n", data.len()).as_bytes()); - pdf.extend_from_slice(&data); - 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, ..Default::default() }); - assert!(page.error.is_none(), "{label}: {:?}", page.error); - let pixel = |x: usize, y: usize| page.rgba[(y * 50 + x) * 4..][..4].to_vec(); - assert_eq!(pixel(20, 30), [255, 0, 0, 255], "{label}: the corner of pixel (20, 30) is in the red triangle"); - // Controls: wholly inside one triangle or the other. - assert_eq!(pixel(15, 20), [255, 0, 0, 255], "{label}"); - assert_eq!(pixel(35, 30), [0, 0, 255, 255], "{label}"); - } - } - - /// 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| { - 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, ..Default::default() })); - }); - 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, ..Default::default() })); - }); - 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, ..Default::default() })); - }); - 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]); - } - - /// Hex-encodes `bytes` for an `/ASCIIHexDecode` stream. - fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{b:02x}")).collect() - } - - /// An 8×4 source of 2×2 blocks: block column `x / 2` and row `y / 2` takes `levels[(y / 2) * 4 + x / 2]`. - fn block_source(levels: [T; 8]) -> Vec { - (0..32).map(|i| levels[(i / 16) * 4 + (i % 8) / 2]).collect() - } - - /// Draws one `size` image of colour space `space` (`data` as raw samples, with an optional gray - /// `/SMask` of the same size) at 1:1 on a `device`-sized page, writing `/Interpolate` when given. - fn draw_minified_image( - space: &str, - size: (u32, u32), - interpolate: Option, - data: &[u8], - alpha: Option<&[u8]>, - device: (u32, u32), - ) -> RenderedPage { - let (width, height) = size; - let interp = interpolate.map_or_else(String::new, |value| format!("/Interpolate {value} ")); - let (smask, mask) = match alpha { - Some(alpha) => ( - "/SMask 6 0 R ", - format!( - "6 0 obj << /Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length {} >> stream\n{}>\nendstream endobj\n", - hex(alpha).len() + 1, - hex(alpha) - ), - ), - None => ("", String::new()), - }; - let image = hex(data); - let content = format!("q {} 0 0 {} 0 0 cm /Im0 Do Q", device.0, device.1); - 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 {} {}] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\ - 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ - 5 0 obj << /Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /{space} /BitsPerComponent 8 {interp}{smask}/Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\ - {mask}trailer << /Root 1 0 R >>\n%%EOF\n", - device.0, - device.1, - content.len(), - image.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), device); - page - } - - /// Issue #624: an image drawn below its source resolution was pre-resampled with Catmull-Rom - /// even when `/Interpolate` was false (or absent, whose PDF default is false). That blends - /// neighboring samples before the renderer's later non-interpolating image sampler sees them, - /// making scans, screenshots and rasterized text look soft. - #[test] - fn non_interpolated_images_are_not_smoothed_when_minified() { - // Top band K W R G, bottom band B Y M C. Nearest 8×4 → 4×2 sampling picks each block's - // pixel, so every device pixel is one palette colour; Catmull-Rom blends across blocks. - let palette: [[u8; 3]; 8] = [[0, 0, 0], [255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [255, 0, 255], [0, 255, 255]]; - let rgb: Vec = block_source(palette).concat(); - let exact: Vec<[u8; 4]> = palette.iter().map(|c| [c[0], c[1], c[2], 255]).collect(); - let pixels = |p: &RenderedPage| p.rgba.as_chunks::<4>().0.to_vec(); - - // `/Interpolate` defaults to false, and explicit false is equivalent. - let omitted = draw_minified_image("DeviceRGB", (8, 4), None, &rgb, None, (4, 2)); - assert_eq!(pixels(&omitted), exact, "default interpolation"); - let disabled = draw_minified_image("DeviceRGB", (8, 4), Some(false), &rgb, None, (4, 2)); - assert_eq!(&omitted.rgba[..], &disabled.rgba[..], "/Interpolate false must match the default"); - - // The fix must not disable interpolation: the same source asking for it blends blocks. - let enabled = draw_minified_image("DeviceRGB", (8, 4), Some(true), &rgb, None, (4, 2)); - assert_ne!(pixels(&enabled), exact, "/Interpolate true should still blend"); - assert!(pixels(&enabled).iter().any(|px| px[..3].iter().any(|&c| (32..=223).contains(&c))), "/Interpolate true made no mid-tone"); - } - - /// A shrunk, interpolated image whose edges fall between device pixels is snapped to whole - /// pixels (shrunk once to that size, drawn 1:1). Before, the shrunk raster was sampled a second - /// time at the fractional offset, so every edge of a scan was a blended, soft pixel. - #[test] - fn interpolated_minified_image_edges_snap_to_whole_pixels() { - let black = vec![0u8; 40 * 40]; - let content = "q 10 0 0 10 0.4 0.4 cm /Im0 Do Q"; - let image = hex(&black); - 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 11 11] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\ - 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ - 5 0 obj << /Type /XObject /Subtype /Image /Width 40 /Height 40 /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate true /Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF\n", - content.len(), - image.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), (11, 11)); - let gray = |x: usize, y: usize| page.rgba[(y * 11 + x) * 4]; - // Device box 0.4..10.4 × 0.6..10.6 snaps to columns 0..10 and rows 1..11. - for y in 1..11 { - for x in 0..10 { - assert_eq!(gray(x, y), 0, "pixel ({x}, {y}) inside the snapped image"); - } - assert_eq!(gray(10, y), 255, "column 10 is outside the snapped image (row {y})"); - } - for x in 0..11 { - assert_eq!(gray(x, 0), 255, "row 0 is outside the snapped image (column {x})"); - } - } - - #[test] - fn non_interpolated_gray_images_are_not_smoothed_when_minified() { - let levels = [0u8, 255, 85, 170, 40, 200, 120, 230]; - let page = draw_minified_image("DeviceGray", (8, 4), None, &block_source(levels), None, (4, 2)); - let expected: Vec<[u8; 4]> = levels.iter().map(|&v| [v, v, v, 255]).collect(); - assert_eq!(page.rgba.as_chunks::<4>().0.to_vec(), expected); - } - - #[test] - fn non_integer_minification_samples_the_same_columns() { - // Ten columns drawn six pixels wide: centre sampling reads columns 0, 2, 4, 5, 7 and 9. - let ramp = [10u8, 20, 30, 40, 50, 60, 70, 80, 90, 100]; - let page = draw_minified_image("DeviceGray", (10, 1), None, &ramp, None, (6, 1)); - let grays: Vec = page.rgba.as_chunks::<4>().0.iter().map(|px| px[0]).collect(); - assert_eq!(grays, [10, 30, 50, 60, 80, 100]); - } - - #[test] - fn non_integer_minification_samples_colour_and_mask_from_the_same_columns() { - // Ten gray columns with a soft mask, drawn six pixels wide: columns 0, 2, 4, 5, 7 and 9 are - // sampled, so the mask must pick the same columns as the colours. - let ramp = [10u8, 20, 30, 40, 50, 60, 70, 80, 90, 100]; - let mask = [255u8, 0, 0, 0, 255, 255, 0, 0, 0, 255]; - let page = draw_minified_image("DeviceGray", (10, 1), None, &ramp, Some(&mask), (6, 1)); - // Opaque columns keep their gray; transparent columns show the white page. - assert_eq!( - page.rgba.as_chunks::<4>().0.to_vec(), - vec![[10, 10, 10, 255], [255, 255, 255, 255], [50, 50, 50, 255], [60, 60, 60, 255], [255, 255, 255, 255], [100, 100, 100, 255]] - ); - } - - #[test] - fn non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified() { - let palette: [[u8; 3]; 8] = [[0, 0, 0], [255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [255, 0, 255], [0, 255, 255]]; - let rgb: Vec = block_source(palette).concat(); - let alpha = block_source([255u8, 0, 128, 255, 0, 255, 64, 128]); - let page = draw_minified_image("DeviceRGB", (8, 4), None, &rgb, Some(&alpha), (4, 2)); - // Premultiplied colours composited over the white page: alpha 0 shows the page. - let expected = vec![ - [0, 0, 0, 255], - [255, 255, 255, 255], - [255, 127, 127, 255], - [0, 255, 0, 255], - [255, 255, 255, 255], - [255, 255, 0, 255], - [255, 191, 255, 255], - [127, 255, 255, 255], - ]; - assert_eq!(page.rgba.as_chunks::<4>().0.to_vec(), expected); - } - - /// Issue #1649: Word can save text as a tiny black image whose `/SMask` is a high-resolution - /// bitmap of the glyphs, so the mask alone carries the text. Shrinking that mask by - /// nearest-neighbour keeps one sample in N, and a stroke thinner than N mask pixels vanished: - /// the text came out broken and aliased. Here a one-column stroke of an 8-column mask is drawn - /// 4 pixels wide; nearest reads columns 1, 3, 5 and 7 and misses the stroke in column 2. - #[test] - fn shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes() { - let mask: [u8; 8] = [0, 0, 255, 0, 0, 0, 0, 0]; - let content = "q 4 0 0 1 0 0 cm /Im0 Do Q"; - 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 4 1] /Contents 4 0 R /Resources << /XObject << /Im0 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 /ColorSpace /DeviceGray /BitsPerComponent 8 /SMask 6 0 R /Filter /ASCIIHexDecode /Length 3 >> stream\n00>\nendstream endobj\n\ - 6 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length {} >> stream\n{}>\nendstream endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF\n", - content.len(), - hex(&mask).len() + 1, - hex(&mask) - ); - 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), (4, 1)); - let grays: Vec = page.rgba.as_chunks::<4>().0.iter().map(|px| px[0]).collect(); - // The stroke covers half of device pixel 1, so it is a mid-tone; the far side stays white. - assert!((64..=191).contains(&grays[1]), "the stroke is lost or not a mid-tone: {grays:?}"); - assert_eq!(grays[3], 255, "{grays:?}"); - } - - /// Type 3 glyph images used to be sampled bicubically (`ImageQuality::High`) whatever their - /// `/Interpolate`, which blends the nearest pre-resize back in. The glyph is 4.6 × 2.3 pixels - /// and its image pre-resizes to 5 × 3, so the final draw samples fractionally and bicubic - /// filtering would blend palette colours. Interior pixels must stay palette colours unless the - /// image asks for interpolation. - #[test] - fn non_interpolated_type3_glyph_images_are_not_smoothed_when_minified() { - let palette: [[u8; 3]; 8] = [[0, 0, 0], [255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [255, 0, 255], [0, 255, 255]]; - let data = hex(&block_source(palette).concat()); - let render = |interp: &str| { - let content = "BT /F1 1 Tf 0 0.7 Td (A) Tj ET"; - let glyph = "4.6 0 d0 q 4.6 0 0 2.3 0 0 cm /Im0 Do Q"; - 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 5 3] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\n\ - 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ - 5 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 10 10] /FontMatrix [1 0 0 1 0 0] /FirstChar 65 /LastChar 65 /Widths [5] /Encoding << /Differences [65 /a] >> /CharProcs << /a 6 0 R >> /Resources << /XObject << /Im0 7 0 R >> >> >> endobj\n\ - 6 0 obj << /Length {} >> stream\n{glyph}\nendstream endobj\n\ - 7 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 4 /ColorSpace /DeviceRGB /BitsPerComponent 8 {interp}/Filter /ASCIIHexDecode /Length {} >> stream\n{data}>\nendstream endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF\n", - content.len(), - glyph.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), (5, 3)); - page - }; - // The glyph fully covers columns 0–3 and rows 0–1. - let interior = |p: &RenderedPage| -> Vec<[u8; 3]> { - (0..2).flat_map(|y| (0..4).map(move |x| (y * 5 + x) * 4)).map(|i| [p.rgba[i], p.rgba[i + 1], p.rgba[i + 2]]).collect() - }; - - let omitted = render(""); - for px in interior(&omitted) { - assert!(palette.contains(&px), "non-interpolated glyph image was smoothed to {px:?}"); - } - let enabled = render("/Interpolate true "); - assert!(interior(&enabled).iter().any(|px| !palette.contains(px)), "/Interpolate true glyph image should still be sampled bicubically"); - } - - /// 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, ..Default::default() }); - let text = r.render(RenderRequest { page: 0, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, ..Default::default() }); - 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 { - 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, ..Default::default() }); - 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, ..Default::default() }); - let text = r.render(RenderRequest { page: 0, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, ..Default::default() }); - 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"); - } - - /// Accented letters in non-embedded fonts (vendored hayro-interpret patch to - /// `StandardKind`): the stand-ins for the standard fonts hold only the glyphs of the - /// Standard, WinAnsi and Mac Roman encodings, so a letter a font names beyond those in its - /// /Differences drew nothing. iText 2 writes Turkish this way (`/Gbreve`, `/Idotaccent`, - /// `/Scedilla`, `/gbreve`, `/scedilla` over Ð, Ý, Þ, ð, þ), and a Turkish letter had blanks - /// for Ğ, İ, Ş, ğ and ş. Each is now its base letter with the accent above or below it. - #[test] - fn accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts() { - let letters = ["G", "Gbreve", "S", "Scedilla", "I", "Idotaccent", "g", "gbreve", "s", "scedilla"]; - let mut content = String::new(); - for (i, _) in letters.iter().enumerate() { - content.push_str(&format!("BT /F1 40 Tf {} 20 Td <{:02X}> Tj ET\n", 10 + 40 * i, 65 + i)); - } - let differences = letters.iter().map(|n| format!("/{n}")).collect::>().join(" "); - let pdf = format!( - "%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 420 80] /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 /TrueType /BaseFont /TimesNewRomanPSMT /FirstChar 65 /LastChar 74 - /Widths [722 722 556 556 333 333 500 500 389 389] /FontDescriptor 6 0 R - /Encoding << /Type /Encoding /Differences [65 {differences}] >> >> endobj -6 0 obj << /Type /FontDescriptor /FontName /TimesNewRomanPSMT /Flags 32 /FontBBox [-568 -306 2045 1039] - /ItalicAngle 0 /Ascent 693 /Descent -215 /CapHeight 662 /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, ..Default::default() }); - assert!(p.error.is_none(), "{:?}", p.error); - // The dark pixels of the i-th letter: how many, and its top and bottom rows (y down). - let ink = |i: u32| { - let (mut count, mut top, mut bottom) = (0, u32::MAX, 0); - for y in 0..p.height { - for x in (8 + 40 * i)..(46 + 40 * i).min(p.width) { - if p.rgba[((y * p.width + x) * 4) as usize] < 128 { - count += 1; - top = top.min(y); - bottom = bottom.max(y); - } - } - } - (count, top, bottom) - }; - for (pair, above) in [(0, true), (1, false), (2, true), (3, true), (4, false)] { - let (base, accented) = (ink(2 * pair), ink(2 * pair + 1)); - let name = letters[2 * pair as usize + 1]; - assert!(base.0 > 0, "{name}: the base letter draws"); - assert!(accented.0 > base.0, "{name} draws its letter and accent: {accented:?} vs {base:?}"); - if above { - assert!(accented.1 + 2 < base.1, "{name}: the accent sits above the letter: {accented:?} vs {base:?}"); - } else { - assert!(accented.2 > base.2 + 2, "{name}: the accent hangs below the letter: {accented:?} vs {base:?}"); - } - } - } - - /// Comma-below letters in non-embedded standard fonts (vendored hayro-interpret patch to - /// `StandardKind`). The stand-ins have neither these letters nor a `commaaccent` to build - /// them from, so Romanian ș Ș (a form laid out from its XFA template names `/scommaaccent`) - /// and Latvian ķ ļ ņ ŗ Ģ drew nothing, and ț Ț drew a cedilla. Each is now its letter with a - /// comma under it, apart from the letter and inside the descender, whichever name the font - /// gives it: the glyph list calls the Latvian letters `/Kcedilla`…, and `uni` names say only - /// the code point. - #[test] - fn comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter() { - let pairs = [ - ("s", "uni0219"), - ("t", "uni021B"), - ("T", "uni021A"), - ("K", "uni0136"), - ("k", "kcedilla"), - ("G", "uni0122"), - ("G", "Gcedilla"), - ("R", "uni0156"), - ("s", "scommaaccent"), - ("S", "Scommaaccent"), - ("t", "tcommaaccent"), - ("T", "Tcommaaccent"), - ("k", "kcommaaccent"), - ("K", "Kcommaaccent"), - ("l", "lcommaaccent"), - ("L", "Lcommaaccent"), - ("n", "ncommaaccent"), - ("N", "Ncommaaccent"), - ("r", "rcommaaccent"), - ("R", "Rcommaaccent"), - ("G", "Gcommaaccent"), - ]; - let letters: Vec<&str> = pairs.iter().flat_map(|(base, accented)| [*base, *accented]).collect(); - let (size, baseline) = (40u32, 60u32); - for font in ["Helvetica", "Times-Roman", "Courier", "Helvetica-BoldOblique"] { - let mut content = String::new(); - for i in 0..letters.len() { - content.push_str(&format!("BT /F1 {size} Tf {} {} Td <{:02X}> Tj ET\n", 10 + 40 * i, 80 - baseline, 1 + i)); - } - let differences = letters.iter().map(|n| format!("/{n}")).collect::>().join(" "); - let pdf = format!( - "%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 {} 80] /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 /Type1 /BaseFont /{font} - /Encoding << /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [1 {differences}] >> >> endobj -trailer << /Root 1 0 R >> -%%EOF", - 20 + 40 * letters.len(), - 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, ..Default::default() }); - assert!(p.error.is_none(), "{font}: {:?}", p.error); - // The rows (y down) of the i-th letter that have dark pixels. - let ink_rows = |i: u32| -> Vec { - (0..p.height).filter(|y| ((8 + 40 * i)..(48 + 40 * i).min(p.width)).any(|x| p.rgba[((y * p.width + x) * 4) as usize] < 128)).collect() - }; - for (pair, (_, name)) in pairs.iter().enumerate() { - let (base, accented) = (ink_rows(2 * pair as u32), ink_rows(2 * pair as u32 + 1)); - let (Some(&base_bottom), Some(&bottom)) = (base.last(), accented.last()) else { - panic!("{font} {name}: draws nothing ({} rows of its base letter)", base.len()); - }; - assert!(bottom > base_bottom + 2, "{font} {name}: a mark hangs below the letter ({bottom} vs {base_bottom})"); - assert!( - (base_bottom + 1..bottom).any(|y| !accented.contains(&y)), - "{font} {name}: the mark is apart from the letter, a comma and not a cedilla" - ); - assert!(bottom < baseline + size * 3 / 10, "{font} {name}: the comma stays within the descender ({bottom})"); - } - } - } - - // Contributor-original classic-xref vector fixtures; no embedded or external assets. - fn raw_crop_unit_pdf(unit: f64, rotation: i32, crop: [f64; 4], marker: Option<[f64; 4]>) -> Vec { - let bounds = crop.map(|n| n.to_string()).join(" "); - let body = marker.map(|rect| format!("0 0 0 rg {} re f\n", rect.map(|n| n.to_string()).join(" "))).unwrap_or_default(); - let objects = [ - "<< /Type /Catalog /Pages 2 0 R >>".to_owned(), - "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(), - format!( - "<< /Type /Page /Parent 2 0 R /MediaBox [{bounds}] /CropBox [{bounds}] /UserUnit {unit} /Rotate {rotation} /Resources << >> /Contents 4 0 R >>" - ), - format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), - ]; - let mut bytes = b"%PDF-1.7\n".to_vec(); - let mut offsets = Vec::new(); - for (i, object) in objects.iter().enumerate() { - offsets.push(bytes.len()); - bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); - } - let xref = bytes.len(); - bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n"); - for offset in offsets { - bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); - } - bytes.extend_from_slice(format!("trailer << /Size 5 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); - bytes - } - - #[test] - fn raw_crop_user_unit_inspection_keeps_observed_dimensions() { - let bytes = raw_crop_unit_pdf(4096.0, 0, [0.0, 0.0, 0.1, 0.1], None); - let info = crate::inspect(Arc::new(bytes), None).expect("original valid raw-0.1 PDF"); - assert_eq!(info.pages.len(), 1); - assert!(info.warnings.is_empty(), "{:?}", info.warnings); - let page = &info.pages[0]; - assert!( - (page.width - 409.6).abs() < 0.0001 && (page.height - 409.6).abs() < 0.0001, - "raw 0.1 at UserUnit4096 is 409.6 points, got {} by {}", - page.width, - page.height - ); - } - - #[test] - fn raw_crop_unit_floor_corners_match_nonzero_origin_and_rotations() { - use hayro::hayro_interpret::TransformExt; - - for (unit, width, height, dx, dy) in - [(1.0, 1.0, 1.0, 0.125, 0.25), (4096.0, 512.0, 1024.0, 512.0, 1024.0), (75000.0, 9375.0, 18750.0, 9375.0, 18750.0)] - { - for rotation in [0, 90, 180, 270] { - let bytes = raw_crop_unit_pdf(unit, rotation, [10.0, 20.0, 10.125, 20.25], None); - let info = crate::inspect(Arc::new(bytes.clone()), None).expect("original valid offset crop"); - assert!(info.warnings.is_empty(), "{:?}", info.warnings); - let dimensions = if rotation % 180 == 0 { (width as f32, height as f32) } else { (height as f32, width as f32) }; - assert_eq!((info.pages[0].width, info.pages[0].height), dimensions, "unit={unit}, rotation={rotation}"); - let pdf = Pdf::new(Arc::new(bytes)).expect("same PDF parser"); - let page = pdf.pages().first().expect("one page"); - assert_eq!(page.base_dimensions(), ((width / unit) as f32, (height / unit) as f32)); - let transform = page.initial_transform(true).to_kurbo(); - let (origin, opposite) = match rotation { - 0 => ([0.0, height], [dx, height - dy]), - 90 => ([0.0, 0.0], [dy, dx]), - 180 => ([width, 0.0], [width - dx, dy]), - _ => ([height, width], [height - dy, width - dx]), - }; - for (point, expected) in [(kurbo::Point::new(10.0, 20.0), origin), (kurbo::Point::new(10.125, 20.25), opposite)] { - let actual = transform * point; - assert!( - (actual.x - expected[0]).abs() < 1e-9 && (actual.y - expected[1]).abs() < 1e-9, - "unit={unit}, rotation={rotation}, point={point:?}: {actual:?} != {expected:?}" - ); - } - } - } - } - - #[test] - fn raw_crop_unit_floor_reciprocal_does_not_trigger_letter_fallback() { - for unit in [1.0, 41.0, 47.0] { - let bytes = raw_crop_unit_pdf(unit, 0, [0.0, 0.0, 0.01, 0.01], None); - let pdf = Pdf::new(Arc::new(bytes.clone())).expect("ordinary small crop"); - let page = pdf.pages().first().expect("one page"); - let (width, height) = page.base_dimensions(); - // In f32, the nearest reciprocal of 41 or 47 multiplies back to 0.99999994. - assert!( - width * page.user_unit() >= 1.0 && height * page.user_unit() >= 1.0, - "unit={unit}: floor must not round below one physical point" - ); - let info = crate::inspect(Arc::new(bytes), None).expect("inspect the same small crop"); - let page = &info.pages[0]; - assert!( - (1.0..=1.000001).contains(&page.width) && (1.0..=1.000001).contains(&page.height), - "unit={unit}: one-point floor must not become a Letter placeholder: {} by {}", - page.width, - page.height - ); - assert!(info.warnings.is_empty(), "{:?}", info.warnings); - } - } - - #[test] - fn raw_crop_unit_floor_preserves_nearly_zero_fallback() { - for unit in [1.0, 4096.0, 75000.0] { - for side in [0.0, 0.0001] { - let bytes = raw_crop_unit_pdf(unit, 0, [0.0, 0.0, side, side], None); - let pdf = Pdf::new(Arc::new(bytes)).expect("existing degenerate-page fallback"); - let page = pdf.pages().first().expect("one page"); - // Existing A4 raw fallback remains raw A4, then scales by the page unit. - let expected = (595.2755905511812_f64 as f32, 841.8897637795277_f64 as f32); - assert_eq!(page.base_dimensions(), expected, "unit={unit}, raw side={side}"); - assert_eq!(page.render_dimensions(), (expected.0 * unit as f32, expected.1 * unit as f32)); - } - } - } - - fn raw_crop_ink(page: &RenderedPage) -> ([u32; 4], usize) { - let (mut left, mut top, mut right, mut bottom, mut count) = (page.width, page.height, 0, 0, 0); - for (i, pixel) in page.rgba.as_chunks::<4>().0.iter().enumerate() { - if pixel[0] < 128 && pixel[1] < 128 && pixel[2] < 128 { - let (x, y) = (i as u32 % page.width, i as u32 / page.width); - left = left.min(x); - top = top.min(y); - right = right.max(x + 1); - bottom = bottom.max(y + 1); - count += 1; - } - } - assert!(count > 0, "original vector marker must draw"); - ([left, top, right, bottom], count) - } - - #[test] - fn raw_crop_user_unit_vector_rasters_match_physical_controls() { - for (unit, scale, width, height, physical_width, physical_height, rects) in [ - ( - 4096.0, - 0.5, - 256, - 512, - 512.0, - 1024.0, - [[32.0, 320.0, 96.0, 448.0], [64.0, 32.0, 192.0, 96.0], [160.0, 64.0, 224.0, 192.0], [320.0, 160.0, 448.0, 224.0]], - ), - ( - 75000.0, - 0.04, - 375, - 750, - 9375.0, - 18750.0, - [ - [46.875, 468.75, 140.625, 656.25], - [93.75, 46.875, 281.25, 140.625], - [234.375, 93.75, 328.125, 281.25], - [468.75, 234.375, 656.25, 328.125], - ], - ), - ] { - for (index, rotation) in [0, 90, 180, 270].into_iter().enumerate() { - let raw_crop = [10.0, 20.0, 10.125, 20.25]; - let raw_marker = [10.015625, 20.03125, 0.03125, 0.0625]; - let control_crop = raw_crop.map(|n| n * unit); - let control_marker = raw_marker.map(|n| n * unit); - let physical = if rotation % 180 == 0 { (physical_width, physical_height) } else { (physical_height, physical_width) }; - let pixels = if rotation % 180 == 0 { (width, height) } else { (height, width) }; - let render = |bytes: Vec| { - let info = crate::inspect(Arc::new(bytes.clone()), None).expect("bounded original vector PDF"); - assert_eq!((info.pages[0].width, info.pages[0].height), physical, "check dimensions before any raster allocation"); - let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); - let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale, ..Default::default() }); - assert!(page.error.is_none(), "{:?}", page.error); - assert_eq!((page.width, page.height), pixels); - page - }; - let high = render(raw_crop_unit_pdf(unit, rotation, raw_crop, Some(raw_marker))); - let control = render(raw_crop_unit_pdf(1.0, rotation, control_crop, Some(control_marker))); - let (bbox, count) = raw_crop_ink(&high); - for (actual, expected) in bbox.into_iter().zip(rects[index]) { - assert!((f64::from(actual) - expected).abs() <= 1.0, "unit={unit}, rotation={rotation}: {bbox:?} != {:?}", rects[index]); - } - let (control_bbox, control_count) = raw_crop_ink(&control); - assert_eq!(bbox, control_bbox, "unit={unit}, rotation={rotation}: marker placement"); - assert_eq!(count, control_count, "unit={unit}, rotation={rotation}: visible marker ink"); - assert!(high.rgba == control.rgba, "unit={unit}, rotation={rotation}: physically equivalent crops render identically"); - } - } - } - - /// A page's `/UserUnit` (the size of its unit in points) scales the page and everything on - /// it, after the crop box and before `/Rotate`, as Acrobat shows such pages: with - /// `/UserUnit 2`, scale 1 renders pixel for pixel what scale 2 renders without it. As in - /// Acrobat, values below 1, values that aren't numbers and a value on a parent `/Pages` - /// node count as 1, and values over 75,000 count as 75,000. - #[test] - fn user_unit_scales_the_page_and_everything_on_it() { - let content = "1 0 0 rg 20 10 30 20 re f BT /F1 18 Tf 15 50 Td (Hg) Tj ET q 16 0 0 8 60 30 cm BI /W 2 /H 2 /CS /RGB /BPC 8 /F /AHx ID 0000FFFF000000FF00FFFF00> EI Q"; - let pdf = |page: &str, parent: &str| { - format!( - "%PDF-1.4 -1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj -2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 {parent} >> endobj -3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 80] /CropBox [10 6 90 76] {page} /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 /Type1 /BaseFont /Helvetica >> endobj -6 0 obj 2 endobj -trailer << /Root 1 0 R >> -%%EOF", - content.len() - ) - .into_bytes() - }; - let render = |bytes: Vec, scale: f32| { - let mut r = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); - let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale, tag: 0, ..Default::default() }); - assert!(p.error.is_none(), "{:?}", p.error); - p - }; - let glyphs = |bytes: Vec| { - let parsed = Pdf::new(Arc::new(bytes)).expect("parses"); - crate::text::extract_page(&parsed, 0, &InterpreterSettings::default()).expect("text").glyphs - }; - let size = |bytes: Vec| { - let p = &crate::inspect(Arc::new(bytes), None).expect("opens").pages[0]; - (p.width, p.height) - }; - for rotate in [0, 90, 180, 270] { - let turned = format!("/Rotate {rotate}"); - let (w, h) = if rotate % 180 == 0 { (80.0, 70.0) } else { (70.0, 80.0) }; - let plain = render(pdf(&turned, ""), 2.0); - let one = render(pdf(&turned, ""), 1.0); - let plain_glyphs = glyphs(pdf(&turned, "")); - for unit in ["/UserUnit 2", "/UserUnit 6 0 R"] { - let page = format!("{turned} {unit}"); - assert_eq!(size(pdf(&page, "")), (w * 2.0, h * 2.0), "{page}"); - let doubled = render(pdf(&page, ""), 1.0); - assert_eq!((doubled.width, doubled.height), (plain.width, plain.height), "{page}"); - assert!(doubled.rgba == plain.rgba, "{page}: the page renders as at twice the scale"); - let doubled_glyphs = glyphs(pdf(&page, "")); - assert_eq!(doubled_glyphs.len(), plain_glyphs.len(), "{page}"); - for (d, p) in doubled_glyphs.iter().zip(&plain_glyphs) { - assert_eq!(d.rect, p.rect.map(|v| v * 2.0), "{page}: text layer"); - } - } - for unit in ["/UserUnit 1", "/UserUnit 0", "/UserUnit -1", "/UserUnit 0.5", "/UserUnit /Two", "/UserUnit (2)"] { - let page = format!("{turned} {unit}"); - assert_eq!(size(pdf(&page, "")), (w, h), "{page}"); - assert!(render(pdf(&page, ""), 1.0).rgba == one.rgba, "{page} counts as 1"); - } - assert_eq!(size(pdf(&turned, "/UserUnit 2")), (w, h), "not inherited from /Pages"); - assert!(render(pdf(&turned, "/UserUnit 2"), 1.0).rgba == one.rgba, "not inherited from /Pages"); - for unit in ["/UserUnit 75000", "/UserUnit 100000"] { - assert_eq!(size(pdf(&format!("{turned} {unit}"), "")), (w * 75_000.0, h * 75_000.0), "{unit}"); - } - } - } - - #[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, ..Default::default() }); - 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, ..Default::default() }); - 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}"); - } - } - } - - /// White-on-black: the page clears black, the black filled rectangle maps to the text colour, - /// and the image's pixels are byte-identical to the plain render — photos never pass through - /// the mapping, not even its white quadrant, which would map to black if they did. - #[test] - fn reading_colors_map_solid_paints_and_leave_images() { - let content = "0 0 0 rg 10 10 30 20 re f\nq 20 0 0 10 60 20 cm /Im Do Q"; - let pdf = format!( - "%PDF-1.4\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 100 50] /Contents 4 0 R /Resources << /XObject << /Im 6 0 R >> >> >> endobj\n\ - 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ - 6 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 25 >> stream\nFF000000FF00000000FFFFFFFF>\nendstream endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF", - content.len() - ) - .into_bytes(); - let req = RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0, preview: false }; - let render = |reading_colors: ReadingColors| { - let mut r = PageRenderer::new(Arc::new(pdf.clone()), RenderConfig { reading_colors, ..Default::default() }); - let page = r.render(req); - assert!(page.error.is_none(), "{:?}", page.error); - page - }; - let off = render(ReadingColors::default()); - let on = render(ReadingColors::WHITE_ON_BLACK); - assert_eq!((on.width, on.height), (200, 100)); - fn pixel(page: &RenderedPage, x: u32, y: u32) -> &[u8] { - &page.rgba[((y * page.width + x) * 4) as usize..][..4] - } - - // The page clears to the setting's background instead of white. - assert_eq!(pixel(&off, 5, 5), &[255, 255, 255, 255]); - assert_eq!(pixel(&on, 5, 5), &[0, 0, 0, 255]); - // The black rectangle maps to the text colour. - assert_eq!(pixel(&off, 50, 60), &[0, 0, 0, 255]); - assert_eq!(pixel(&on, 50, 60), &[255, 255, 255, 255]); - // The whole image area (the interior of device pixels 120..160 × 40..60) is untouched. - for y in 44..56u32 { - for x in 124..156u32 { - assert_eq!(pixel(&on, x, y), pixel(&off, x, y), "image pixel {x},{y}"); - } - } - assert_eq!(pixel(&on, 150, 55), &[255, 255, 255, 255], "the image's white quadrant stays white"); - } - - /// #2975: a stencil mask (`/ImageMask true`) is painted in the fill colour at the fill - /// opacity, once: at `ca 0.5` it is as opaque as a rectangle filled at `ca 0.5` beside it - /// (it was a quarter as opaque, the opacity applied by both the image's group and its paint). - /// The same holds when the fill is a pattern, whose paint keeps the opacity in force when it - /// was set with `scn`: an uncoloured tiling pattern and a shading pattern. - #[test] - fn stencil_masks_are_drawn_at_the_fill_opacity_once() { - let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..3].to_vec(); - let fills = [("colour", "0 0 1 rg"), ("uncoloured tiling pattern", "/CsP cs 0 0 1 /Tp scn"), ("shading pattern", "/Pattern cs /Sp scn")]; - for (fill_name, fill) in fills { - for alpha in ["1", "0.5", "0.25"] { - let content = format!("/A gs {fill} q 100 0 0 100 20 50 cm /Msk Do Q 160 50 100 100 re f"); - let tile = "0 0 10 10 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 300 200] /Contents 4 0 R /Resources << /ExtGState << /A << /ca {alpha} >> >> /XObject << /Msk 5 0 R >> /ColorSpace << /CsP [/Pattern /DeviceRGB] >> /Pattern << /Tp 6 0 R /Sp 7 0 R >> >> >> endobj -4 0 obj << /Length {} >> stream -{content} -endstream endobj -5 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ImageMask true /Length 2 >> stream -\0\0 -endstream endobj -6 0 obj << /Type /Pattern /PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >> /Length {} >> stream -{tile} -endstream endobj -7 0 obj << /Type /Pattern /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 300 0] /Extend [true true] /Function << /FunctionType 2 /Domain [0 1] /C0 [0 0 1] /C1 [0 0 1] /N 1 >> >> >> endobj -trailer << /Root 1 0 R >> -%%EOF", - content.len(), - tile.len() - ); - let p = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()).render(RenderRequest { - page: 0, - kind: RequestKind::Pixels, - tile: None, - scale: 1.0, - tag: 0, - ..Default::default() - }); - assert!(p.error.is_none(), "{fill_name}, ca {alpha}: {:?}", p.error); - // Pixel rows run top down: PDF (70, 100) is in the mask, (210, 100) in the rectangle. - let (mask, rect) = (px(&p, 70, 100), px(&p, 210, 100)); - assert!(rect != [255, 255, 255], "{fill_name}, ca {alpha}: the rectangle is painted"); - assert!(mask.iter().zip(&rect).all(|(m, r)| m.abs_diff(*r) <= 1), "{fill_name}, ca {alpha}: mask {mask:?}, rectangle {rect:?}"); - } - } - } - - /// #2971: in a knockout transparency group (`/K true`) each element composites with the - /// group's initial backdrop, not with the elements drawn before it in the group, over the - /// element's shape. A blue square at 50 % opacity over a red one at 50 % shows only the blue - /// where they overlap; without `/K` the two blend. The same holds for an image (its shape - /// is its rectangle; a soft-masked image's shape is its mask, so its transparent part leaves - /// what is under it) and for a nested group (its shape is the union of its elements' - /// shapes, while its own elements still blend with each other). - #[test] - fn knockout_groups_replace_what_their_elements_cover() { - let render_as = |group: &str, form: &str, preview: bool| { - 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 300 200] /Contents 4 0 R /Resources << /XObject << /Fm1 5 0 R >> /ExtGState << /G << /ca 0.5 >> >> >> >> endobj -4 0 obj << /Length 39 >> stream -/Fm1 Do /G gs 0 1 0 rg 10 10 30 30 re f -endstream endobj -5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency {group} >> /Resources << /ExtGState << /A << /ca 0.5 >> >> /XObject << /In 6 0 R /Msk 7 0 R /Neu 8 0 R /Img 9 0 R /Sm 10 0 R /Nimg 12 0 R /Deep 13 0 R /Shg 16 0 R /Big 17 0 R >> >> /Length {} >> stream -{form} -endstream endobj -6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /A << /ca 0.5 >> >> >> /Length 34 >> stream -/A gs 0 0 1 rg 100 50 150 120 re f -endstream endobj -7 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ImageMask true /Filter /ASCIIHexDecode /Length 5 >> stream -0000> -endstream endobj -8 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /H << /ca 0.5 >> >> >> /Length 62 >> stream -/H gs 0 1 0 rg 80 40 100 100 re f 0 0 1 rg 130 80 100 100 re f -endstream endobj -9 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 7 >> stream -0000FF> -endstream endobj -10 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask 11 0 R /Filter /ASCIIHexDecode /Length 13 >> stream -0000FF0000FF> -endstream endobj -11 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 5 >> stream -00FF> -endstream endobj -12 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /H << /ca 0.5 >> >> /XObject << /Img 9 0 R >> >> /Length 38 >> stream -/H gs q 160 0 0 100 60 50 cm /Img Do Q -endstream endobj -13 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /H << /ca 0.5 >> >> /XObject << /In3 14 0 R >> >> /Length 13 >> stream -/H gs /In3 Do -endstream endobj -14 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Length 28 >> stream -0 0 1 rg 100 50 150 120 re f -endstream endobj -15 0 obj << /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 1 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [0 0 1] /C1 [0 0 1] /N 1 >> /Extend [true true] /BBox [100 50 140 170] >> >> endobj -16 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /Pattern << /P 15 0 R >> >> /Length 38 >> stream -/Pattern cs /P scn 100 50 150 120 re f -endstream endobj -17 0 obj << /Type /XObject /Subtype /Image /Width 20000 /Height 20000 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 7 >> stream -0000FF> -endstream endobj -trailer << /Root 1 0 R >> -%%EOF", - form.len() - ); - let p = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()).render(RenderRequest { - page: 0, - kind: RequestKind::Pixels, - tile: None, - scale: 1.0, - tag: 0, - preview, - }); - assert!(p.error.is_none(), "{group} {form}: {:?}", p.error); - p - }; - let render = |group: &str, form: &str| render_as(group, form, false); - // Pixel rows run top down: PDF (x, y) is pixel (x, 200 - y). - let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[((200 - y) * p.width + x) as usize * 4..][..3].to_vec(); - let check = |p: &RenderedPage, x: u32, y: u32, want: [u8; 3], what: &str| { - let got = px(p, x, y); - assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 2), "{what}: {got:?}, expected {want:?}"); - }; - let red = "/A gs 1 0 0 rg 20 20 150 150 re f "; - let ko = "/I true /K true"; - - let squares = render(ko, &format!("{red}0 0 1 rg 100 50 150 120 re f")); - check(&squares, 130, 100, [128, 128, 255], "knocked out"); - check(&squares, 50, 40, [255, 128, 128], "red alone"); - check(&squares, 210, 100, [128, 128, 255], "blue alone"); - check(&squares, 280, 190, [255, 255, 255], "paper"); - // Page content after the group composites normally again: green at 50 % over the red. - check(&squares, 30, 30, [128, 191, 64], "after the group"); - let plain = render("/I true", &format!("{red}0 0 1 rg 100 50 150 120 re f")); - check(&plain, 130, 100, [128, 64, 191], "blended without /K"); - - // An image at 50 % replaces the red under its rectangle. - let image = render(ko, &format!("{red}q 160 0 0 100 60 50 cm /Img Do Q")); - check(&image, 100, 100, [128, 128, 255], "image knocks out"); - check(&image, 40, 40, [255, 128, 128], "red beside the image"); - // A soft-masked image: its transparent left half leaves the red, its opaque half replaces it. - let masked = render(ko, "1 0 0 rg 20 20 150 150 re f q 160 0 0 100 60 50 cm /Sm Do Q"); - check(&masked, 100, 100, [255, 0, 0], "under the transparent half"); - check(&masked, 155, 100, [0, 0, 255], "under the opaque half"); - - // A nested group entered at 50 % (blue at 25 % in all) replaces the red under its shape. - let nested = render(ko, &format!("{red}/In Do")); - check(&nested, 130, 100, [191, 191, 255], "nested group knocks out"); - check(&nested, 50, 40, [255, 128, 128], "red beside the nested group"); - // A nested group at full opacity: it knocks out the red as a whole, and its own two - // squares at 50 % still blend with each other where they overlap. - let neutral = render(ko, "1 0 0 rg 20 20 150 150 re f /Neu Do"); - check(&neutral, 100, 60, [128, 255, 128], "green over the knocked-out red"); - check(&neutral, 150, 110, [64, 128, 191], "the nested squares blend"); - check(&neutral, 40, 40, [255, 0, 0], "red beside the nested group"); - - // An image inside a nested group is drawn once (blue at 50 %), over the knocked-out red. - let nested_image = render(ko, "1 0 0 rg 20 20 150 150 re f /Nimg Do"); - check(&nested_image, 100, 100, [128, 128, 255], "image in a nested group"); - // A group inside a nested group knocks out the red as well. - let deep = render(ko, "1 0 0 rg 20 20 150 150 re f /Deep Do"); - check(&deep, 130, 100, [128, 128, 255], "group in a nested group"); - check(&deep, 40, 40, [255, 0, 0], "red beside it"); - - // A stencil mask must not erase the red beside it. - let stencil = render(ko, &format!("{red}0 0 1 rg q 150 0 0 120 100 50 cm /Msk Do Q")); - check(&stencil, 50, 40, [255, 128, 128], "red beside a stencil"); - // Where the stencil paints (every sample 0), its blue is drawn (review on #2974: the check - // above alone passed even if the stencil drew nothing). A stencil composites as it did - // before rather than knocking the red out, and its exact colour also depends on #2975 (a - // stencil's fill opacity applied twice), so only "blue was drawn here" is pinned. - let under = px(&stencil, 130, 100); - assert!(under != [255, 128, 128] && under[2] >= 150 && under[0] < 230, "stencil drew nothing: {under:?}"); - - // A shading's /BBox clips it (ISO 32000-2 Table 77), so a shading fill's shape is its path - // inside the box: the erase pass must clear only that, or the red outside the box but - // inside the path stays erased (review on #2974). - let shaded = render(ko, &format!("{red}/Shg Do")); - check(&shaded, 120, 100, [128, 128, 255], "the shading inside its box"); - check(&shaded, 155, 100, [255, 128, 128], "red outside the shading's box"); - - // An image that isn't decoded (here over the renderer's pixel limit) draws nothing, so it - // must not clear what is under its rectangle either (review on #2974). - let undecoded = render(ko, &format!("{red}q 160 0 0 100 60 50 cm /Big Do Q")); - check(&undecoded, 100, 100, [255, 128, 128], "red under an image that isn't drawn"); - // Nor one a preview skips for covering only a few device pixels (review on #2974): the - // red under a 3 x 3 image stays in a preview, and a final render knocks it out. - let tiny = format!("{red}q 3 0 0 3 100 100 cm /Img Do Q"); - check(&render_as(ko, &tiny, true), 101, 101, [255, 128, 128], "red under a preview's skipped image"); - check(&render_as(ko, &tiny, false), 101, 101, [128, 128, 255], "the tiny image in a final render"); - } - - /// #2970: a page's objects composite with a transparent backdrop and the page group is then - /// put on the paper, so a separable blend mode only blends where something was drawn. A - /// green fill with `/BM /Screen` (or Lighten, Difference…) half over a red square is yellow - /// (or whatever the mode gives) over the red, and plain green over the empty page: blending - /// with the white paper made it white or magenta. - #[test] - fn blend_modes_over_an_empty_part_of_the_page_keep_their_colour() { - let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec(); - for (mode, over_red) in [ - ("Normal", Some([0, 255, 0])), - ("Multiply", Some([0, 0, 0])), - ("Screen", Some([255, 255, 0])), - ("Lighten", Some([255, 255, 0])), - ("Difference", Some([255, 255, 0])), - ("Exclusion", Some([255, 255, 0])), - ("ColorDodge", Some([255, 0, 0])), - ("Overlay", Some([255, 0, 0])), - // The non-separable modes mix hue, saturation and luminosity; over an empty part of - // the page they too must keep the source colour. - ("Hue", None), - ("Saturation", None), - ("Color", None), - ("Luminosity", None), - ] { - let content = "1 0 0 rg 20 20 150 150 re f /G1 gs 0 1 0 rg 100 50 150 120 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 300 200] /Contents 4 0 R /Resources << /ExtGState << /G1 5 0 R >> >> >> endobj -4 0 obj << /Length {} >> stream -{content} -endstream endobj -5 0 obj << /Type /ExtGState /BM /{mode} >> endobj -trailer << /Root 1 0 R >> -%%EOF", - content.len() - ); - let p = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()).render(RenderRequest { - page: 0, - kind: RequestKind::Pixels, - tile: None, - scale: 1.0, - tag: 0, - ..Default::default() - }); - assert!(p.error.is_none(), "{mode}: {:?}", p.error); - // Pixel rows run top down: PDF (210, 100) and (130, 100) are rows 100 from the top. - assert_eq!(px(&p, 210, 100), vec![0, 255, 0, 255], "{mode} over the empty page"); - if let Some(over_red) = over_red { - assert_eq!(px(&p, 130, 100)[..3], over_red, "{mode} over the red square"); - } - assert_eq!(px(&p, 280, 10), vec![255, 255, 255, 255], "{mode}: the paper stays white"); - } - } - - /// A transparent page leaves what nothing paints see-through, so it can be laid over another - /// raster: with reading colours on, and in a tile, too. Painted pixels stay premultiplied. - #[test] - fn transparent_pages_paint_only_their_content() { - let content = "0 0 1 rg 10 10 30 20 re f /GS gs 0.5 g 60 10 30 20 re f"; - let pdf = format!( - "%PDF-1.4\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 100 50] /Contents 4 0 R /Resources << /ExtGState << /GS << /ca 0.5 >> >> >> >> endobj\n\ - 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF", - content.len() - ) - .into_bytes(); - let pixel = |page: &RenderedPage, x: u32, y: u32| page.rgba[((y * page.width + x) * 4) as usize..][..4].to_vec(); - for reading_colors in [ReadingColors::default(), ReadingColors::WHITE_ON_BLACK] { - let config = RenderConfig { transparent: true, reading_colors, ..Default::default() }; - let mut r = PageRenderer::new(Arc::new(pdf.clone()), config.clone()); - let page = r.render(RenderRequest { page: 0, scale: 2.0, ..Default::default() }); - assert!(page.error.is_none(), "{:?}", page.error); - assert_eq!(pixel(&page, 5, 5), [0, 0, 0, 0], "{reading_colors:?}: nothing painted, nothing shown"); - assert_eq!(pixel(&page, 50, 60)[3], 255, "{reading_colors:?}: the opaque rectangle covers"); - assert_eq!(pixel(&page, 150, 60), [64, 64, 64, 128], "{reading_colors:?}: half-transparent grey, premultiplied"); - let tile = r.render(RenderRequest { page: 0, scale: 2.0, tile: Some(Tile { x: 0, y: 40, w: 100, h: 60 }), ..Default::default() }); - assert_eq!((pixel(&tile, 5, 5), pixel(&tile, 50, 20)), (pixel(&page, 5, 45), pixel(&page, 50, 60)), "{reading_colors:?}"); - let pool = RenderPool::new(Arc::new(pdf.clone()), 1, config); - pool.set_queue(vec![RenderRequest { page: 0, scale: 2.0, ..Default::default() }]); - let pooled = loop { - if let Some(p) = pool.try_recv() { - break p; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - }; - assert_eq!(pooled.rgba, page.rgba, "{reading_colors:?}: the pool renders the same"); - } - let opaque = PageRenderer::new(Arc::new(pdf), RenderConfig::default()).render(RenderRequest { page: 0, scale: 2.0, ..Default::default() }); - assert_eq!(pixel(&opaque, 5, 5), [255, 255, 255, 255], "pages are still white by default"); - } - - /// Reading colours replace *document* colours, never annotation colours (issue #1548): a - /// highlight keeps its `/C`, so on a dark background the content under it shows through the - /// Multiply blend in the highlight's colour instead of being darkened into invisibility. - /// Both annotation paths are covered: the `/AP` appearance stream and the no-`/AP` - /// `/QuadPoints` fallback. - #[test] - fn reading_colors_keep_highlight_annotations_readable() { - 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] /QuadPoints [15 65 120 65 15 35 120 35] /C [1 1 0] {{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 render = |annot: &str| { - let mut r = PageRenderer::new( - Arc::new(pdf.replace("{annot}", annot).into_bytes()), - RenderConfig { reading_colors: ReadingColors::WHITE_ON_BLACK, ..Default::default() }, - ); - let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); - 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(); - - // No /AP: the page's black rectangle is remapped to white (document line art), the - // yellow fill is not, so Multiply leaves the content under the quad yellow — readable. - let p = render(""); - assert_eq!(px(&p, 5, 5), vec![0, 0, 0, 255], "the page clears to the background"); - assert_eq!(px(&p, 105, 50), vec![255, 255, 0, 255], "content under the quad shows through in the highlight's colour"); - assert_eq!(px(&p, 30, 50), vec![0, 0, 0, 255], "the band itself multiplies into the dark background, as in Acrobat"); - assert_eq!(px(&p, 150, 50), vec![0, 0, 0, 255], "outside the quad"); - - // With /AP the appearance's blue is an annotation colour too and stays blue instead of - // being remapped towards white by its luminance. - let p = render("/AP << /N 6 0 R >>"); - assert_eq!(px(&p, 30, 50), vec![0, 0, 255, 255], "the appearance keeps its own colour"); - assert_eq!(px(&p, 90, 50), vec![0, 0, 0, 255], "outside the appearance's own drawing"); - } - - /// A print-preview request (`PRINT_TAG`) renders the document's true colours even though - /// the document's config carries reading colours: a preview's job is fidelity to the - /// printout, which is imposed from the uncoloured document. Covers both render paths — - /// the inline `PageRenderer` and a pool worker. - #[cfg(not(target_arch = "wasm32"))] - #[test] - fn print_preview_requests_render_with_reading_colors_off() { - let content = "0 0 0 rg 10 10 30 20 re f"; - let pdf = Arc::new( - format!( - "%PDF-1.4\n\ - 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 50] >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R >> endobj\n\ - 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF", - content.len() - ) - .into_bytes(), - ); - fn px(page: &RenderedPage, x: u32, y: u32) -> &[u8] { - &page.rgba[((y * page.width + x) * 4) as usize..][..4] - } - - // The page and its black rectangle in their true colours. - let mut plain = PageRenderer::new(pdf.clone(), RenderConfig::default()); - let truth = plain.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0, preview: false }); - assert!(truth.error.is_none(), "{:?}", truth.error); - assert_eq!(px(&truth, 5, 5), &[255, 255, 255, 255]); - assert_eq!(px(&truth, 50, 60), &[0, 0, 0, 255]); - - let mut recoloured = PageRenderer::new(pdf.clone(), RenderConfig { reading_colors: ReadingColors::WHITE_ON_BLACK, ..Default::default() }); - // A plain request honours the setting: the page clears black, the rect maps white. - let on = recoloured.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0, preview: false }); - assert_eq!(px(&on, 5, 5), &[0, 0, 0, 255]); - assert_eq!(px(&on, 50, 60), &[255, 255, 255, 255]); - // A print-preview request ignores the setting and matches the true colours exactly. - let preview = RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: PRINT_TAG, preview: false }; - let off = recoloured.render(preview); - assert_eq!(px(&off, 5, 5), &[255, 255, 255, 255]); - assert_eq!(px(&off, 50, 60), &[0, 0, 0, 255]); - assert_eq!(off.rgba, truth.rgba); - - // The same override on the worker path the print dialog's canvas renders through. - let pool = RenderPool::new(pdf, 1, RenderConfig { reading_colors: ReadingColors::WHITE_ON_BLACK, ..Default::default() }); - pool.set_queue(vec![preview]); - let worker = loop { - if let Some(p) = pool.try_recv() { - break p; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - }; - assert!(worker.error.is_none(), "{:?}", worker.error); - assert_eq!(worker.rgba, truth.rgba); - } - - /// A recoloured tile must equal the crop of the recoloured full render, or zooming would - /// flash wrong colours at tile seams. - #[test] - fn reading_colors_tiles_match_full_render() { - let mut r = - PageRenderer::new(Arc::new(ONE_PAGE.to_vec()), RenderConfig { reading_colors: ReadingColors::WHITE_ON_BLACK, ..Default::default() }); - let full = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0, preview: false }); - 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, preview: false }); - assert!(full.error.is_none() && part.error.is_none(), "{:?} {:?}", full.error, part.error); - 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}"); - } - } - // The colours are really on: the page cleared black and the blue rectangle mapped towards - // white (its luminance is 7.2%, so 255·(1−0.0722) ≈ 237). - assert_eq!(&full.rgba[..4], &[0, 0, 0, 255]); - assert_eq!(&full.rgba[((60 * full.width + 50) * 4) as usize..][..4], &[237, 237, 237, 255]); - } - - // A tile seam must not change the raster when large-page work is stitched back together. - #[test] - fn stitched_render_partitions_match_a_whole_render() { - let content = - "0 0 1 rg 0 0 200 200 re f\n1 0 0 RG 2 w 0 100 m 200 100 l S\nBT /F1 18 Tf 20 150 Td (tile text) Tj ET\nq 40 0 0 40 100 100 cm /Im Do Q"; - let pdf = format!( - "%PDF-1.4\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 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im 6 0 R >> >> >> endobj\n\ - 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ - 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n\ - 6 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 25 >> stream\nFF000000FF00000000FFFFFFFF>\nendstream endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF", - content.len() - ); - let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); - let request = RenderRequest { scale: 6.0, ..Default::default() }; - let whole = renderer.render(request); - assert!(whole.error.is_none(), "{:?}", whole.error); - assert_eq!((whole.width, whole.height), (1200, 1200)); - - let mut assert_partition = |tile_width: u32, tile_height: u32| { - let mut stitched = vec![0; whole.rgba.len()]; - for y in (0..whole.height).step_by(tile_height as usize) { - for x in (0..whole.width).step_by(tile_width as usize) { - let tile = Tile { x, y, w: tile_width.min(whole.width - x), h: tile_height.min(whole.height - y) }; - let part = renderer.render(RenderRequest { tile: Some(tile), ..request }); - assert!(part.error.is_none(), "tile {tile:?}: {:?}", part.error); - assert_eq!((part.width, part.height), (tile.w, tile.h)); - for row in 0..tile.h { - let src = (row * tile.w * 4) as usize; - let dst = ((y + row) * whole.width * 4 + x * 4) as usize; - let len = (tile.w * 4) as usize; - stitched[dst..dst + len].copy_from_slice(&part.rgba[src..src + len]); - } - } - } - assert_eq!(stitched.as_slice(), whole.rgba.as_ref(), "partition {tile_width}×{tile_height}"); - }; - - for bands in [2, 4, 8] { - assert_partition(whole.width, whole.height.div_ceil(bands)); - } - assert_partition(1024, 1024); - assert_partition(127, 131); - } - - /// hayro skips drawing a path that can't paint the canvas (vendored patch (9)), so a tile no - /// longer strokes every path of its page. Every tile, and the whole page, is byte for byte - /// what it was without skipping, where paint reaches past a path into the next tile: round - /// and square caps, a sharp miter, a rotated and a stretched matrix, a hairline, dashes, a - /// curve, text, and a page shown rotated. - /// - /// Images are skipped the same way (vendored patch (10)): an image on a tile, one crossing - /// tile lines, and one fully off the page. - #[test] - fn skipping_paths_off_the_canvas_changes_no_pixel() { - // Tiles of 80 device pixels at scale 2 meet every 40 pt; each mark crosses such a line - // only with its stroke width, caps, miter or antialiasing. The last one is off the page. - let content = "1 0 0 RG 20 w 1 J 10 30 m 35 30 l S -2 J 10 60 m 35 70 l S -0 J 0 j 10 M 8 w 100 50 m 118 60 l 100 70 S -q 0.7071 0.7071 -0.7071 0.7071 160 20 cm 15 w 1 J 0 0 m 20 0 l S Q -0 w 0 0 0 RG 79.9 100 m 79.9 140 l S -1 0 0 rg 80.3 150 20 20 re f -0 0 1 RG 6 w [8 4] 0 d 1 J 130 100 m 205 160 l S [] 0 d -0 1 0 RG 3 w 10 200 m 10 290 70 290 70 200 c S -BT /F1 30 Tf 70 205 Td (Hg) Tj ET -q 4 0 0 0.25 0 0 cm 12 w 1 J 2 500 m 8 500 l S Q -300 300 m 320 320 l S -q 10 0 0 10 5 5 cm /Im0 Do Q -q 30 0 0 30 100 100 cm /Im0 Do Q -q 5 0 0 5 500 500 cm /Im0 Do Q"; - // A 4x4 RGB image of plain `A` bytes (no `endstream` inside, so the raw stream parses). - let pixels = "A".repeat(4 * 4 * 3); - for rotate in [0, 90] { - let pdf = format!( - "%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 240 240] /Rotate {rotate} /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >> >> endobj -4 0 obj << /Length {} >> stream -{content} -endstream endobj -5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj -6 0 obj << /Type /XObject /Subtype /Image /Width 4 /Height 4 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length {} >> stream -{pixels} -endstream endobj -trailer << /Root 1 0 R >> -%%EOF", - content.len(), - pixels.len() - ); - let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); - let mut both = |tile| { - let req = RenderRequest { scale: 2.0, tile, ..Default::default() }; - let skipped = r.render(req); - hayro::set_skip_offscreen_paths(false); - let drawn = r.render(req); - hayro::set_skip_offscreen_paths(true); - assert!(skipped.error.is_none() && drawn.error.is_none(), "{:?}", skipped.error); - assert!(skipped.rgba == drawn.rgba, "/Rotate {rotate}: {tile:?} changed"); - }; - both(None); - for y in (0..480).step_by(80) { - for x in (0..480).step_by(80) { - both(Some(Tile { x, y, w: 80, h: 80 })); - } - } - } - } - - /// Previews skip images that would cover only a few device pixels (vendored patch (11)): - /// a tiny draw is missing from the preview but present in the final, while a large draw - /// is in both. Finals never set the flag, so they draw everything. - #[test] - fn preview_renders_skip_tiny_images_but_finals_draw_everything() { - let content = "q 50 0 0 50 100 100 cm /Im0 Do Q -q 0.5 0 0 0.5 10 10 cm /Im0 Do Q"; - let pixels = "A".repeat(4 * 4 * 3); - let pdf = format!( - "%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 240 240] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj -4 0 obj << /Length {} >> stream -{content} -endstream endobj -5 0 obj << /Type /XObject /Subtype /Image /Width 4 /Height 4 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length {} >> stream -{pixels} -endstream endobj -trailer << /Root 1 0 R >> -%%EOF", - content.len(), - pixels.len() - ); - let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); - let mut render = |preview| r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview }); - let first = render(false); - let second = render(false); - assert!(first.error.is_none(), "{:?}", first.error); - assert_eq!(first.rgba, second.rgba, "finals are deterministic"); - let gray = first.rgba.as_chunks::<4>().0.iter().filter(|px| px[0] == 0x41 && px[1] == 0x41 && px[2] == 0x41).count(); - assert!(gray > 1000, "the large image draws {gray} pixels"); - let preview = render(true); - assert!(preview.error.is_none(), "{:?}", preview.error); - assert_ne!(preview.rgba, first.rgba, "the tiny image is skipped only in the preview"); - } - - /// A page whose stream objects 5, 6, … are `objects` (dictionary, data), drawn by `content` - /// with `resources`, on a 130 × 70 pt page. - fn image_cache_pdf(content: &str, resources: &str, objects: &[(&str, &[u8])]) -> Vec { - let mut pdf = format!( - "%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 130 70] /Contents 4 0 R /Resources {resources} >> endobj -4 0 obj << /Length {} >> stream -{content} -endstream endobj -", - content.len() - ) - .into_bytes(); - for (i, (dict, data)) in objects.iter().enumerate() { - pdf.extend(format!("{} 0 obj {dict} stream\n", i + 5).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"); - pdf - } - - /// The RGB of the pixel at (`x`, `y`) (from the top left) of a `width`-pixel-wide RGBA raster. - fn rgb_at(rgba: &[u8], width: usize, x: usize, y: usize) -> [u8; 3] { - let i = (y * width + x) * 4; - [rgba[i], rgba[i + 1], rgba[i + 2]] - } - - const ONE_PIXEL_RGB: &str = "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 3 >>"; - - /// Two images whose dictionaries are byte for byte the same (uncompressed, one size) but - /// whose pixels differ each draw their own: the decoded-image cache tells them apart by the - /// stream, not by the dictionary's bytes alone, in one render and across renders sharing a - /// cache, and previews never hand their entries to a final render. - #[test] - fn decoded_images_with_identical_dictionaries_keep_their_own_pixels() { - let pdf = image_cache_pdf( - // B over A at the same place and size (so one decode target): B must show. A alone - // on the right, at a size of its own. - "q 50 0 0 50 10 10 cm /A Do Q q 50 0 0 50 10 10 cm /B Do Q q 40 0 0 40 80 15 cm /A Do Q", - "<< /XObject << /A 5 0 R /B 6 0 R >> >>", - &[(ONE_PIXEL_RGB, b"\xff\x00\x00"), (ONE_PIXEL_RGB, b"\x00\x00\xff")], - ); - let req = |preview| RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview }; - let mut r = PageRenderer::new(Arc::new(pdf.clone()), RenderConfig::default()); - let out = r.render(req(false)); - assert!(out.error.is_none(), "{:?}", out.error); - let w = out.width as usize; - assert_eq!(rgb_at(&out.rgba, w, 35, 35), [0, 0, 255], "the second image drew the first one's pixels"); - assert_eq!(rgb_at(&out.rgba, w, 100, 35), [255, 0, 0]); - // One cache across a preview and then a final render, as a render worker keeps it. - let pdf = parse(&Arc::new(pdf), None).expect("parses"); - let cache = RenderCache::new(); - let settings = InterpreterSettings::default(); - let preview = render_page(&pdf, &cache, &settings, &RenderConfig::default(), req(true)).expect("preview renders"); - let fin = render_page(&pdf, &cache, &settings, &RenderConfig::default(), req(false)).expect("final renders"); - let fresh = render_page(&pdf, &RenderCache::new(), &settings, &RenderConfig::default(), req(false)).expect("final renders"); - assert_eq!(rgb_at(&preview.2, w, 35, 35), [0, 0, 255]); - assert_eq!(&fin.2[..], &fresh.2[..], "a final render after a preview matches one with a fresh cache"); - } - - /// An image drawn under a transfer function (`/TR`) is decoded with it, even when the same - /// image was drawn without one just before (and the other way round). - #[test] - fn decoded_images_under_a_transfer_function_are_not_shared() { - let pdf = image_cache_pdf( - // The image, then the same image at the same place under the inverting function - // (one decode target), then the image alone on the right. - "q 50 0 0 50 10 10 cm /A Do Q q /Inv gs 50 0 0 50 10 10 cm /A Do Q q 40 0 0 40 80 15 cm /A Do Q", - "<< /XObject << /A 5 0 R >> /ExtGState << /Inv << /TR 6 0 R >> >> >>", - &[(ONE_PIXEL_RGB, b"\xff\x00\x00"), ("<< /FunctionType 4 /Domain [0 1] /Range [0 1] /Length 14 >>", b"{ 1 exch sub }")], - ); - let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); - let out = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); - assert!(out.error.is_none(), "{:?}", out.error); - let w = out.width as usize; - assert_eq!(rgb_at(&out.rgba, w, 35, 35), [0, 255, 255], "the transfer function was not applied to a cached image"); - assert_eq!(rgb_at(&out.rgba, w, 100, 35), [255, 0, 0]); - } - - /// The decoded-image cache keyed every decode by the size the image is drawn at, but only - /// a JPX stream decodes differently for a size: a scan was decoded again at every zoom step - /// and kept once per zoom level (vendored hayro-interpret patch 32). An image drawn at five - /// sizes is now decoded once, unless JPX decodes it or its soft mask. - #[test] - fn decoded_images_serve_every_size_they_are_drawn_at() { - 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}; - use std::io::Write; - - /// Asks for every raster image at the size it is drawn at, as the renderer does, and - /// counts the decoded images it was handed. - #[derive(Default)] - struct Draws(usize); - impl<'a> Device<'a> for Draws { - fn set_soft_mask(&mut self, _: Option>) {} - fn set_blend_mode(&mut self, _: BlendMode) {} - fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {} - fn push_clip_path(&mut self, _: &ClipPath) {} - fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} - fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {} - fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) { - let [a, b, c, d, ..] = transform.as_coeffs(); - let target = ((a.hypot(b) * f64::from(image.width())).ceil() as u32, (c.hypot(d) * f64::from(image.height())).ceil() as u32); - if let Image::Raster(r) = image { - r.with_rgba(|_, _| self.0 += 1, Some(target)); - } - } - fn pop_clip_path(&mut self) {} - fn pop_transparency_group(&mut self) {} - } - - let flate = |data: &[u8]| { - let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); - z.write_all(data).unwrap(); - z.finish().unwrap() - }; - let pixels = flate(&(0..64 * 64 * 3).map(|i| (i % 251) as u8).collect::>()); - let image = |extra: &str| { - format!( - "<< /Type /XObject /Subtype /Image /Width 64 /Height 64 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode {extra}/Length {} >>", - pixels.len() - ) - }; - let (plain, jpx_masked) = (image(""), image("/SMask 7 0 R ")); - let jpx_mask = - "<< /Type /XObject /Subtype /Image /Width 64 /Height 64 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /JPXDecode /Length 4 >>"; - // `name` drawn at five sizes, from 10 to 40 pt square. - let decodes = |name: &str| { - let content = [(10, 0, 0), (20, 12, 0), (30, 34, 0), (40, 66, 0), (25, 100, 40)] - .map(|(s, x, y)| format!("q {s} 0 0 {s} {x} {y} cm /{name} Do Q")) - .join(" "); - let bytes = image_cache_pdf( - &content, - "<< /XObject << /A 5 0 R /B 6 0 R >> >>", - &[(&plain, &pixels), (&jpx_masked, &pixels), (jpx_mask, b"\0\0\0\0")], - ); - let parsed = Pdf::new(Arc::new(bytes)).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, 130.0, 70.0), &cache, page.xref(), InterpreterSettings::default()); - let mut device = Draws::default(); - interpret_page(page, &mut ctx, &mut device); - assert_eq!(device.0, 5, "every draw of /{name} was handed its pixels"); - cache.decoded_image_decodes() - }; - assert_eq!(decodes("A"), 1, "a Flate image decodes the same at every size"); - assert_eq!(decodes("B"), 5, "a JPX soft mask decodes for the size it is drawn at"); - } - - // ICC profiles whose stream dictionaries are the same (vendored hayro-interpret patch 48). - mod icc_profile_cache; - - // A symbolic TrueType font draws through its (3, 0) cmap subtable first (#4156). - mod symbolic_truetype_cmap; - - /// Render `pdf`'s first page at 72 dpi with no time limit beyond the test's own. - fn render_first_page(pdf: Vec) -> RenderedPage { - let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); - let out = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); - assert!(out.error.is_none(), "{:?}", out.error); - out - } - - /// A spot-colour image runs its tint transform once per possible sample, not once per pixel - /// (vendored hayro-interpret patch 28). Fillable character sheets draw their background art - /// as an 8-bit DeviceN image whose tint transform is a PostScript calculator program: run for - /// each of a 3304 × 4105 background's pixels, every page took four seconds and 430 MB to - /// draw, again after each field was filled. This image (2048 × 2048, a 9,000-operator - /// program) took about 40 seconds that way; from a 256-entry table it takes milliseconds. - /// The runs are counted (patch 34) rather than timed: a deadline also failed when a slow, - /// busy CI machine took longer than it allowed, and the count is the same on every machine. - #[test] - fn spot_colour_images_run_their_tint_transform_once_per_sample_value() { - const SIDE: usize = 2048; - // 8,990 operators that change nothing (the calculator allows 10,000 a run), then - // tint t → RGB (1 − t, t, 0). - let program = format!("{{ {}dup 1 exch sub exch 0 }}", "dup pop ".repeat(4495)); - // Each row: tint 0 on the left half, tint 1 on the right. - let row: Vec = [vec![0u8; SIDE / 2], vec![255u8; SIDE / 2]].concat(); - let data = row.repeat(SIDE); - let image = format!( - "<< /Type /XObject /Subtype /Image /Width {SIDE} /Height {SIDE} /ColorSpace [/Separation /Spot /DeviceRGB 6 0 R] /BitsPerComponent 8 /Length {} >>", - data.len() - ); - let function = format!("<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Length {} >>", program.len()); - let pdf = - image_cache_pdf("q 130 0 0 70 0 0 cm /A Do Q", "<< /XObject << /A 5 0 R >> >>", &[(&image, &data), (&function, program.as_bytes())]); - // The count is per thread, and this render runs on this one. - let before = hayro::hayro_interpret::color::tint_transform_runs(); - let out = render_first_page(pdf); - let runs = hayro::hayro_interpret::color::tint_transform_runs() - before; - assert_eq!(runs, 256, "the tint transform runs once per possible sample value, not once for each of the {} pixels", SIDE * SIDE); - let w = out.width as usize; - assert_eq!(rgb_at(&out.rgba, w, 10, 35), [255, 0, 0], "tint 0"); - assert_eq!(rgb_at(&out.rgba, w, 120, 35), [0, 255, 0], "tint 1"); - } - - /// Images converted through patch 28's table keep the colour of every sample: the samples of - /// a pixel in order, each through its own `/Decode` range, rows that end mid-byte, and an image - /// whose data stops part-way through a row (that row is padded with decoded zeros, as before). - #[test] - fn images_converted_through_a_table_keep_every_samples_colour() { - let close = |got: [u8; 3], want: [u8; 3], at: (usize, usize)| { - assert!(got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 1), "pixel {at:?}: got {got:?}, want {want:?}"); - }; - // DeviceN of two inks at 4 bits (256 possible pixels) shown as RGB (a, b, 0), the first - // ink's range reversed. 32 × 16 pixels, each drawn 4 pt square. - let mut data = Vec::new(); - for y in 0..16 { - for x in 0..32 { - let (a, b) = (x % 16, (x / 16 + 2 * y) % 16); - data.push(((a << 4) | b) as u8); - } - } - let image = format!( - "<< /Type /XObject /Subtype /Image /Width 32 /Height 16 /ColorSpace [/DeviceN [/A /B] /DeviceRGB 6 0 R] /BitsPerComponent 4 /Decode [1 0 0 1] /Length {} >>", - data.len() - ); - let pdf = image_cache_pdf( - "q 128 0 0 64 0 0 cm /A Do Q", - "<< /XObject << /A 5 0 R >> >>", - &[(&image, &data), ("<< /FunctionType 4 /Domain [0 1 0 1] /Range [0 1 0 1 0 1] /Length 5 >>", b"{ 0 }")], - ); - let out = render_first_page(pdf); - let w = out.width as usize; - for y in 0..16 { - for x in 0..32 { - let (a, b) = (x % 16, (x / 16 + 2 * y) % 16); - // The 64 pt image sits at the bottom of the 70 pt page. - close(rgb_at(&out.rgba, w, 4 * x + 2, 6 + 4 * y + 2), [(255 - 17 * a) as u8, (17 * b) as u8, 0], (x, y)); - } - } - - // DeviceGray at 2 bits, reversed: 31 pixels (62 bits, so each row ends mid-byte) by 6. - let sample = |x: usize, y: usize| (x + y) % 4; - let mut data = Vec::new(); - for y in 0..6 { - let mut row = [0u8; 8]; - for x in 0..31 { - row[x / 4] |= (sample(x, y) as u8) << (6 - 2 * (x % 4)); - } - data.extend_from_slice(&row); - } - let image = format!( - "<< /Type /XObject /Subtype /Image /Width 31 /Height 6 /ColorSpace /DeviceGray /BitsPerComponent 2 /Decode [1 0] /Length {} >>", - data.len() - ); - let pdf = image_cache_pdf("q 124 0 0 60 0 0 cm /A Do Q", "<< /XObject << /A 5 0 R >> >>", &[(&image, &data)]); - let out = render_first_page(pdf); - let w = out.width as usize; - for y in 0..6 { - for x in 0..31 { - // The 60 pt image at the bottom of the page: rows 10 pt high. - close(rgb_at(&out.rgba, w, 4 * x + 2, 10 + 10 * y + 5), [(255 - 85 * sample(x, y)) as u8; 3], (x, y)); - } - } - - // DeviceGray at 8 bits through the range [0.2 0.8] (so not drawn from the raw bytes): - // 31 × 12 declared, data for 8 rows and the first 12 pixels of the 9th. - let sample = |x: usize, y: usize| (x * 7 + y * 3) % 256; - let mut data = Vec::new(); - for y in 0..9 { - let row: Vec = (0..31).map(|x| sample(x, y) as u8).collect(); - data.extend_from_slice(if y < 8 { &row[..] } else { &row[..12] }); - } - let image = format!( - "<< /Type /XObject /Subtype /Image /Width 31 /Height 12 /ColorSpace /DeviceGray /BitsPerComponent 8 /Decode [0.2 0.8] /Length {} >>", - data.len() - ); - let pdf = image_cache_pdf("q 124 0 0 60 0 0 cm /A Do Q", "<< /XObject << /A 5 0 R >> >>", &[(&image, &data)]); - let out = render_first_page(pdf); - let w = out.width as usize; - for y in 0..9 { - for x in 0..31 { - // The rest of the 9th row is decoded zero (black), not sample 0 (0.2 under this range). - let gray = if y < 8 || x < 12 { (51.0 + 0.6 * sample(x, y) as f64).round() as u8 } else { 0 }; - // The rows present keep the declared rows' height: 60 pt / 12. - close(rgb_at(&out.rgba, w, 4 * x + 2, 10 + 5 * y + 2), [gray; 3], (x, y)); - } - } - } - - /// Patch 28's table converts each entry on its own: a tint transform that gives the wrong - /// number of components for a sample value the image never uses leaves the colours of the - /// values it does use alone. Converted as one batch, the one component tint 0 gives here - /// shifted every later entry, and tint 1's red came out black (PR #1691 review). - #[test] - fn a_malformed_tint_for_an_unused_sample_keeps_the_used_colours() { - // Tint 0 gives one component instead of the three `/Range` declares; any other tint red. - let program = b"{ dup 0 eq { pop 0 } { pop 1 0 0 } ifelse }"; - // 32 × 32 = 1,024 pixels, more than the table's 256 entries, all sample 255 (tint 1). - let data = [255u8; 32 * 32]; - let centre = |space: &str| { - let image = format!( - "<< /Type /XObject /Subtype /Image /Width 32 /Height 32 /ColorSpace [{space} /DeviceRGB 6 0 R] /BitsPerComponent 8 /Length {} >>", - data.len() - ); - let function = format!("<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Length {} >>", program.len()); - let pdf = image_cache_pdf("q 130 0 0 70 0 0 cm /A Do Q", "<< /XObject << /A 5 0 R >> >>", &[(&image, &data), (&function, program)]); - let out = render_first_page(pdf); - rgb_at(&out.rgba, out.width as usize, 65, 35) - }; - assert_eq!([centre("/Separation /Spot"), centre("/DeviceN [/Spot]")], [[255, 0, 0]; 2], "Separation, then one-ink DeviceN"); - } - - /// 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, ..Default::default() }); - 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)"); - } - } - - // #3601: UniJIS-UCS2-HW-H maps code 0x0020 to CID 231 / U+2002. The PDF's - // explicit 500-unit width must win over the substitute's ordinary-space width. - fn japanese_spacing_pdf(content: &str, widths: &str) -> Vec { - 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 180 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 /MS-Mincho /Encoding /UniJIS-UCS2-HW-H /DescendantFonts [6 0 R] >> endobj -6 0 obj << /Type /Font /Subtype /CIDFontType2 /BaseFont /MS-Mincho /CIDSystemInfo << /Registry (Adobe) /Ordering (Japan1) /Supplement 4 >> - /FontDescriptor 7 0 R /DW 1000 {widths} >> endobj -7 0 obj << /Type /FontDescriptor /FontName /MS-Mincho /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() - ) - .into_bytes() - } - - #[test] - fn japanese_substitute_missing_spaces_draw_no_ink() { - if pdfcraft_fonts::document_japanese_font().is_none() { - eprintln!("built without craft-fonts (CRAFT_FONTS_DIR unset): no Japanese face to check"); - return; - } - let out = render_first_page(japanese_spacing_pdf("BT /F1 20 Tf 10 30 Td <002000200020> Tj ET", "/W [231 [500]]")); - assert!(out.rgba.as_chunks::<4>().0.iter().all(|pixel| *pixel == [255; 4]), "spacing glyphs must leave the page white"); - } - - #[test] - fn japanese_substitute_spaces_keep_pdf_widths_and_unicode() { - if pdfcraft_fonts::document_japanese_font().is_none() { - eprintln!("built without craft-fonts (CRAFT_FONTS_DIR unset): no Japanese face to check"); - return; - } - for (widths, second_x) in [("/W [231 [500]]", 50), ("", 70)] { - let pdf = japanese_spacing_pdf("BT /F1 20 Tf 10 30 Td <65E500200020672C> Tj ET", widths); - let out = render_first_page(pdf.clone()); - // 日 advances 20 pt; each space advances 10 pt (/W) or 20 pt (/DW). - // A second independent text matrix pins 本 at the expected original PDF position. - let reference = format!("BT /F1 20 Tf 1 0 0 1 10 30 Tm <65E5> Tj 1 0 0 1 {second_x} 30 Tm <672C> Tj ET"); - let expected = render_first_page(japanese_spacing_pdf(&reference, widths)); - assert!(expected.rgba.as_chunks::<4>().0.iter().any(|pixel| pixel[0] < 128), "the Japanese control must draw ink"); - assert!(out.rgba.as_ref() == expected.rgba.as_ref(), "spaces must be blank without changing {widths:?} advances"); - let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); - let text = renderer.render(RenderRequest { page: 0, kind: RequestKind::Text, ..Default::default() }); - assert!(text.error.is_none(), "{:?}", text.error); - let glyphs: String = text.text.unwrap().glyphs.iter().map(|glyph| glyph.text.as_str()).collect(); - assert_eq!(glyphs, "日\u{2002}\u{2002}本", "glyph substitution must not rewrite Unicode"); - } - } - - /// 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 = ('\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, ..Default::default() }); - 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(¬def)); - 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> 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, ..Default::default() }); - 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 噂"); - } - } - - /// InDesign fades are luminosity soft masks whose group is one extended axial `sh`: a - /// gradient over the whole page. hayro 0.7 sampled each one pixel by pixel through the - /// shading function, so a report page with 40 of them took 15 s at 144 dpi. Vendored hayro - /// patch (backport of hayro 0.8): axial and radial shadings that a native gradient can - /// represent exactly are drawn as one, with stops placed at stitching-function bounds. - #[test] - fn axial_and_radial_shadings_draw_as_native_gradients() { - use hayro::hayro_interpret::font::Glyph; - use hayro::hayro_interpret::hayro_syntax::Pdf; - use hayro::hayro_interpret::pattern::Pattern; - use hayro::hayro_interpret::{ - BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, Paint, PathDrawMode, SoftMask, TransformExt, interpret_page, - }; - use kurbo::{Affine, BezPath, Rect, Shape}; - - /// For every shading painted, the offsets of its native gradient stops, or `None` when - /// it has to be sampled pixel by pixel. - #[derive(Default)] - struct Gradients(Vec>>); - impl<'a> Device<'a> for Gradients { - fn set_soft_mask(&mut self, _: Option>) {} - fn set_blend_mode(&mut self, _: BlendMode) {} - fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &Paint<'a>, _: &PathDrawMode) { - if let Paint::Pattern(pattern) = paint - && let Pattern::Shading(s) = pattern.as_ref() - { - let bbox = (transform * path.clone()).bounding_box(); - let native = s.encode().as_svg_gradient(bbox, 0.01); - self.0.push(native.map(|g| g.stops.iter().map(|stop| stop.offset).collect())); - } - } - fn push_clip_path(&mut self, _: &ClipPath) {} - fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} - fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {} - fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {} - fn pop_clip_path(&mut self) {} - fn pop_transparency_group(&mut self) {} - } - - // A 40 × 20 pt page painting `/Sh0 sh`, with optional extra objects from 6 0 obj on. - let pdf = |shading: &str, extra: &str| { - let content = "/Sh0 sh"; - 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 20] /Contents 4 0 R /Resources << /Shading << /Sh0 5 0 R >> >> >> endobj -4 0 obj << /Length {} >> stream -{content} -endstream endobj -5 0 obj {shading} endobj -{extra}trailer << /Root 1 0 R >> -%%EOF", - content.len() - ) - .into_bytes() - }; - let gradients = |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, 40.0, 20.0), &cache, page.xref(), InterpreterSettings::default()); - let mut device = Gradients::default(); - interpret_page(page, &mut ctx, &mut device); - device.0 - }; - let render = |bytes: Vec| { - let mut r = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); - let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); - assert!(p.error.is_none(), "{:?}", p.error); - p - }; - let grey = |p: &RenderedPage, x: u32| i32::from(p.rgba[((10 * p.width + x) * 4) as usize]); - let ramp = "/FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1"; - // A function-based shading (always sampled) that draws the same picture is the reference; - // its /Range clamps to 0..1. - let sampled = |code: &str| { - let extra = - format!("6 0 obj << /FunctionType 4 /Domain [0 40 0 20] /Range [0 1] /Length {} >> stream\n{code}\nendstream endobj\n", code.len()); - pdf("<< /ShadingType 1 /ColorSpace /DeviceGray /Domain [0 40 0 20] /Function 6 0 R >>", &extra) - }; - - // A steep extended axial ramp, x 10 to 14: one stop at each end, drawn at pixel centres - // (a half-pixel shift would move each pixel 32 levels) and padded on both sides. - let axial = pdf(&format!("<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [10 0 14 0] /Extend [true true] /Function << {ramp} >> >>"), ""); - assert_eq!(gradients(&axial), vec![Some(vec![0.0, 1.0])], "an extended axial shading is a native gradient"); - let native = render(axial); - let reference = render(sampled("{ pop 10 sub 4 div }")); - for (x, expected) in [(2, 0), (9, 0), (10, 32), (11, 96), (12, 159), (13, 223), (14, 255), (30, 255)] { - assert!((grey(&native, x) - expected).abs() <= 4, "axial, x {x}: {} instead of {expected}", grey(&native, x)); - assert!( - (grey(&native, x) - grey(&reference, x)).abs() <= 4, - "axial, x {x}: native {} vs sampled {}", - grey(&native, x), - grey(&reference, x) - ); - } - - // An extended radial shading matches the sampled one everywhere. - let radial = - pdf(&format!("<< /ShadingType 3 /ColorSpace /DeviceGray /Coords [20 10 0 20 10 8] /Extend [true true] /Function << {ramp} >> >>"), ""); - assert!(matches!(gradients(&radial).as_slice(), [Some(_)]), "an extended radial shading is a native gradient"); - let native = render(radial); - let reference = render(sampled("{ 10 sub dup mul exch 20 sub dup mul add sqrt 8 div }")); - let worst = native.rgba.iter().zip(reference.rgba.iter()).map(|(a, b)| a.abs_diff(*b)).max(); - assert!(worst.is_some_and(|d| d <= 4), "radial: native and sampled differ by {worst:?} levels"); - - // A stitched function peaking at a quarter of the way gets a stop exactly at its bound. - let stitched = pdf( - "<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 40 0] /Extend [true true] /Function << /FunctionType 3 /Domain [0 1] /Bounds [0.25] /Encode [0 1 0 1] /Functions [<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>] >> >>", - "", - ); - let stops = gradients(&stitched); - assert!(matches!(stops.as_slice(), [Some(offsets)] if offsets.contains(&0.25)), "a stop at the stitching bound: {stops:?}"); - let native = render(stitched); - for (x, expected) in [(0, 13), (9, 242), (10, 250), (39, 4)] { - assert!((grey(&native, x) - expected).abs() <= 4, "stitched, x {x}: {} instead of {expected}", grey(&native, x)); - } - - // Without /Extend the area beyond the axis keeps the background, which a native gradient - // can't leave out: still sampled. Function-based shadings are always sampled. - let unextended = pdf(&format!("<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [10 0 14 0] /Function << {ramp} >> >>"), ""); - assert_eq!(gradients(&unextended), vec![None], "an axial shading without /Extend that covers more than its axis"); - assert_eq!(gradients(&sampled("{ pop pop 0.5 }")), vec![None], "a function-based shading"); - - // The report's case: a luminosity soft mask whose group is one extended axial `sh` over - // the page. Red through a mask fading in from x 0 to 40 over white: 255, 255 − t, 255 − t. - let content = "/M gs 1 0 0 rg 0 0 40 20 re f"; - let masked = 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 20] /Contents 4 0 R /Resources << /ExtGState << /M 5 0 R >> >> >> endobj -4 0 obj << /Length {} >> stream -{content} -endstream endobj -5 0 obj << /Type /ExtGState /SMask << /Type /Mask /S /Luminosity /G 6 0 R >> >> endobj -6 0 obj << /Type /XObject /Subtype /Form /BBox [-32768 -32768 32767 32767] /Group << /S /Transparency /CS /DeviceGray >> /Resources << /Shading << /Sh0 << /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 40 0] /Extend [true true] /Function << {ramp} >> >> >> >> /Length 7 >> stream -/Sh0 sh -endstream endobj -trailer << /Root 1 0 R >> -%%EOF", - content.len() - ); - let p = render(masked.into_bytes()); - for x in [2u32, 20, 37] { - let px = &p.rgba[((10 * p.width + x) * 4) as usize..][..4]; - let faded = 255 - (255.0 * (f64::from(x) + 0.5) / 40.0).round() as i32; - assert!(px[0] == 255 && (i32::from(px[1]) - faded).abs() <= 4 && px[1] == px[2], "masked, x {x}: {px:?}, green and blue {faded}"); - } - } - - /// 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, ..Default::default() }); - 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"); - } - - /// Issue #2128: PowerPoint fades a gradient fill (an axial shading pattern) through a soft - /// mask. vello_cpu 0.0.8 copies an opaque gradient straight over fully covered tiles, - /// skipping the mask, so the fade came out solid. Images get around that by declaring - /// transparency while a mask or blend mode is set; native gradients can't, so they are - /// sampled as a texture then. - #[test] - fn soft_mask_applies_to_a_shading_pattern_fill() { - let content = "/Pattern cs /P0 scn /M gs 0 0 100 100 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 100 100] /Contents 4 0 R /Resources << /ExtGState << /M 5 0 R >> /Pattern << /P0 7 0 R >> >> >> endobj -4 0 obj << /Length {} >> stream -{content} -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 /CS /DeviceRGB >> /Length 20 >> stream -0 g 0 0 50 100 re f -endstream endobj -7 0 obj << /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Extend [true true] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [1 0 0] /N 1 >> >> >> 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, ..Default::default() }); - 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"); - } - - /// Issue #3176: `CA` / `ca` belong to the graphics state at paint time, so a `gs` after - /// `SCN` / `scn` still fades a shading pattern, and a stroke takes the stroke alpha. The - /// pattern took the fill alpha current at `scn`/`SCN`, so both shapes came out opaque. - #[test] - fn shading_pattern_uses_the_alpha_current_at_paint_time() { - let content = "/Pattern CS /P0 SCN /S1 gs 10 w 0 75 m 100 75 l S /Pattern cs /P0 scn /F1 gs 0 0 100 50 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 100 100] /Contents 4 0 R - /Resources << /ExtGState << /S1 << /CA 0.25 >> /F1 << /ca 0.25 >> >> /Pattern << /P0 5 0 R >> >> >> endobj -4 0 obj << /Length {} >> stream -{content} -endstream endobj -5 0 obj << /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Extend [true true] - /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [1 0 0] /N 1 >> >> >> 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, ..Default::default() }); - 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(); - // Red at 25% over white is about (255, 191, 191); opaque red would be (255, 0, 0). - for (what, x, y) in [("the stroke under /CA 0.25", 50, 25), ("the fill under /ca 0.25", 50, 75)] { - let c = px(x, y); - assert!(c[0] >= 250 && (185..=197).contains(&c[1]) && (185..=197).contains(&c[2]), "{what}: {c:?}"); - } - } - - /// 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, ..Default::default() }); - 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"); - } - } - - /// ISO 32000-2 §9.3.6: text in a Type 3 font is rendered from its glyph descriptions in every - /// text rendering mode but 3 and 7 (vendored hayro-interpret patches 42 and 64 in - /// `show_glyph`). Print-stream PDFs set `2 Tr` for Type 3 bitmap fonts, and the glyph - /// procedure ran twice (fill, then stroke), so its anti-aliased edges doubled up and the text - /// looked bold; under `1 Tr` an uncoloured glyph took the stroking colour. Modes 3 and 7 - /// render nothing (`tests/it/type3_text_clip.rs` covers the clip). Both glyph kinds have - /// edges between pixels. - #[test] - fn rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled() { - let render = |glyph: &str, mode: u8| { - let content = format!("BT 1 0 0 RG 6 w 0 g /F1 20 Tf {mode} Tr 10.3 10.3 Td (A) 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 40 40] /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 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 -{glyph} -endstream endobj -trailer << /Root 1 0 R >> -%%EOF", - content.len(), - glyph.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, ..Default::default() }); - assert!(p.error.is_none(), "{glyph} {mode} Tr: {:?}", p.error); - p.rgba - }; - for glyph in ["1 0 0 0 1 1 d1 0.12 0.12 0.71 0.71 re f", "1 0 d0 0.12 0.12 0.71 0.71 re f"] { - let filled = render(glyph, 0); - assert!(filled.as_chunks::<4>().0.iter().any(|p| p[0] < 128), "{glyph}: the glyph paints"); - for mode in [1, 2, 4, 5, 6] { - assert!(render(glyph, mode) == filled, "{glyph}: {mode} Tr draws as 0 Tr"); - } - for mode in [3, 7] { - assert!(render(glyph, mode).as_chunks::<4>().0.iter().all(|p| p[..3] == [255, 255, 255]), "{glyph}: {mode} Tr hides the glyph"); - } - } - } - - // #1201: /DefaultGray, /DefaultRGB and /DefaultCMYK (vendored hayro-interpret patch 38). - mod default_colour_spaces; - - // #4260: DeviceN colours with many inks, and tint transforms that cannot be evaluated - // (vendored hayro-syntax patch 28, hayro-interpret patch 55). - mod devicen_inks; - - /// #3109 / #3116: clipping-only text (`7 Tr`) installs a clip for subsequent painting - /// but draws no pixels itself. - #[test] - fn test_clip_only_text_raster_preserves_clipping_without_painting() { - let pdf_bytes = |content: &str| { - Arc::new( - 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 200 100] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF", - content.len() - ) - .into_bytes(), - ) - }; - let render = |content: &str| { - let mut r = PageRenderer::new(pdf_bytes(content), RenderConfig::default()); - let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); - assert!(p.error.is_none(), "{content}: {:?}", p.error); - p - }; - let white = [255, 255, 255, 255]; - - // The text alone paints nothing. - let p = render("BT /F1 40 Tf 7 Tr 20 60 Td (Hi) Tj ET"); - assert!(p.rgba.as_chunks::<4>().0.iter().all(|c| *c == white), "7 Tr alone leaves the page blank"); - - // A fill afterwards shows only through the text-shaped clip. - let p = render("q BT /F1 40 Tf 7 Tr 20 60 Td (Hi) Tj ET 0 0 1 rg 0 0 200 100 re f Q"); - let pixels = p.rgba.as_chunks::<4>().0; - assert!(pixels.contains(&[0, 0, 255, 255]), "fill shows through the 7 Tr clip"); - assert!(pixels.contains(&white), "the clip is text-shaped, not the whole page"); - } -} +mod tests; diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs new file mode 100644 index 000000000..9635a0591 --- /dev/null +++ b/crates/render/src/raster/tests.rs @@ -0,0 +1,7077 @@ +#[cfg(not(target_arch = "wasm32"))] +fn render_gate(page: usize, shared_generation: bool) -> super::RenderGate { + let barrier = || std::sync::Arc::new(std::sync::Barrier::new(2)); + super::RenderGate { + page, + shared_generation, + claimed: Arc::new(std::sync::atomic::AtomicBool::new(false)), + entered: barrier(), + release: barrier(), + finished: barrier(), + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn parse_gate() -> super::ParseGate { + let barrier = || std::sync::Arc::new(std::sync::Barrier::new(2)); + super::ParseGate { entered: barrier(), release: barrier() } +} + +#[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()); +} + +/// The waits here only guard against a hang; they don't time anything. The initializer is +/// held until the end of the test, so a waiter that can't cancel waits forever and fails the +/// guard however long it is, and a correct one returns as soon as its thread runs. Two +/// seconds was too short for a busy FreeBSD VM in CI to start the threads (#1970). +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn waiting_worker_can_cancel_before_initializer_returns() { + use std::{ + sync::{atomic::AtomicUsize, mpsc}, + time::Duration, + }; + const HANG_GUARD: Duration = Duration::from_secs(600); + 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(); + // A failed test drops the sender: that ends the hold, it is not a + // second failure. + let _ = release_rx.recv(); + Pdf::new(ONE_PAGE.to_vec()).ok() + }, + || false, + ) + }) + }; + started_rx.recv_timeout(HANG_GUARD).expect("the initializer starts"); + 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() + HANG_GUARD; + while checks.load(Ordering::Relaxed) == 0 { + assert!(std::time::Instant::now() < deadline, "the waiter never checked for cancellation"); + std::thread::sleep(Duration::from_millis(1)); + } + cancelled.store(true, Ordering::Release); + assert!(done_rx.recv_timeout(HANG_GUARD).expect("the waiter cancels while the initializer is held")); + waiter.join().unwrap(); + release_tx.send(()).unwrap(); + assert!(initializer.join().unwrap().unwrap().is_some()); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_retired_pool_counts_its_threads_until_they_exit() { + // Idle: its thread leaves as soon as the pool is gone. + let mut idle = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default()).retire(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while idle.running() > 0 && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(idle.running(), 0); + // Busy in a step that can't be interrupted: still counted after the pool is dropped. + let pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default()); + *lock(&pool.shared.slow_page) = Some((0, std::time::Duration::from_secs(2))); + pool.set_queue(vec![RenderRequest { page: 0, scale: 1.0, ..Default::default() }]); + while pool.shared.render_started.load(Ordering::Relaxed) == 0 && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(2)); + } + let mut busy = pool.retire(); + assert_eq!(busy.running(), 1, "the render is still under way"); + while busy.running() > 0 && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(busy.running(), 0, "and the thread exits when it returns"); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_pool_without_replacements_never_runs_a_second_thread() { + // The watchdog gives up on a render stuck in one long step; with no replacement, the + // request is answered with an error and the stuck thread is the only one. + let mut pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default()).without_replacements(); + pool.set_stuck_after(std::time::Duration::from_millis(50)); + *lock(&pool.shared.slow_page) = Some((0, std::time::Duration::from_secs(2))); + pool.set_queue(vec![RenderRequest { page: 0, scale: 1.0, ..Default::default() }]); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + let answer = loop { + assert!(pool.threads() <= 1, "{} render threads", pool.threads()); + if let Some(page) = pool.try_recv() { + break page; + } + assert!(std::time::Instant::now() < deadline, "the stuck render is answered"); + std::thread::sleep(std::time::Duration::from_millis(5)); + }; + assert!(answer.error.is_some(), "answered with an error, not a second attempt"); + assert!(pool.threads() <= 1); + let mut retired = pool.retire(); + while retired.running() > 0 { + assert!(std::time::Instant::now() < deadline, "the stuck thread exits when its step ends"); + std::thread::sleep(std::time::Duration::from_millis(5)); + } +} + +#[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); + // Hold page 1 at the shared render boundary until page 0 has crashed, so it can't finish + // in the shared generation first (then only one parse would happen). + let gate = render_gate(1, true); + *lock(&pool.shared.render_gates) = vec![gate.clone()]; + pool.set_queue(vec![ + RenderRequest { page: 0, scale: 1.0, ..Default::default() }, + RenderRequest { page: 1, scale: 1.0, ..Default::default() }, + ]); + let crashed = receive_before_deadline(&pool); + if gate.claimed.load(Ordering::Acquire) { + gate.entered.wait(); + gate.release.wait(); + } + let mut pages = [crashed, 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); + // A new generation (tag) and scale of the failed page is still refused without parsing again. + pool.set_queue(vec![RenderRequest { page: 0, tag: 1, scale: 2.0, ..Default::default() }]); + assert!(receive_before_deadline(&pool).error.as_deref().is_some_and(|message| message.contains("skipped earlier"))); + 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; + // 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); + } + let gate = parse_gate(); + *lock(&pool.shared.parse_gate) = Some(gate.clone()); + pool.set_stuck_after(Duration::ZERO); + let requests = if private { + vec![RenderRequest { page: 0, scale: 1.0, tag: 60, ..Default::default() }] + } else { + vec![ + RenderRequest { page: 0, scale: 1.0, tag: 60, ..Default::default() }, + RenderRequest { page: 1, scale: 1.0, tag: 61, ..Default::default() }, + ] + }; + pool.set_queue(requests.clone()); + gate.entered.wait(); + assert!(pool.try_recv().is_none(), "the watchdog must leave parsing alone"); + assert!(lock(&pool.shared.stuck).is_empty()); + assert_eq!(*lock(&pool.retired_workers), 0); + assert!(!matches!(*lock(&pool.shared.parser.state), ParserState::Failed)); + // Rendering gets its own deadline after initialization completes. + pool.set_stuck_after(Duration::from_secs(60)); + gate.release.wait(); + let mut pages: Vec<_> = (0..requests.len()).map(|_| receive_before_deadline(&pool)).collect(); + pages.sort_by_key(|page| page.request.page); + for (page, request) in pages.iter().zip(&requests) { + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!((page.width, page.height, page.request.tag), (100, 50, request.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()); + pool.set_stuck_after(Duration::ZERO); + let shared_bad = render_gate(0, true); + let shared_healthy = render_gate(1, true); + let private_bad = render_gate(0, false); + *lock(&pool.shared.render_gates) = vec![shared_bad.clone(), shared_healthy.clone(), private_bad.clone()]; + pool.set_queue(vec![ + RenderRequest { page: 0, scale: 1.0, tag: 40, ..Default::default() }, + RenderRequest { page: 1, scale: 1.0, tag: 41, ..Default::default() }, + ]); + shared_bad.entered.wait(); + shared_healthy.entered.wait(); + // This watchdog pass retries both pages privately. Page 1's retry has no gate, so on a + // loaded machine it can finish before this same call reads the results: that early + // answer is the healthy page, never a timeout error. + let early = pool.try_recv(); + assert!( + early.as_ref().is_none_or(|page| page.request.page == 1 && page.error.is_none()), + "shared timeouts retry instead of answering with an error: {:?}", + early.as_ref().map(|page| (page.request.page, &page.error)) + ); + shared_bad.release.wait(); + shared_healthy.release.wait(); + shared_bad.finished.wait(); + shared_healthy.finished.wait(); + private_bad.entered.wait(); + let healthy = match early { + Some(page) => page, + None => pool.results.recv_timeout(Duration::from_secs(30)).expect("healthy private retry"), + }; + assert!(healthy.error.is_none(), "{:?}", healthy.error); + assert_eq!((healthy.request.page, healthy.width, healthy.height, healthy.request.tag), (1, 100, 50, 41)); + pool.watchdog(); + let bad = pool.results.recv_timeout(Duration::from_secs(30)).expect("the held private retry times out"); + assert!(bad.error.as_deref().is_some_and(|message| message.contains("took longer"))); + assert_eq!(bad.request.page, 0); + private_bad.release.wait(); + private_bad.finished.wait(); + assert!(lock(&pool.shared.stuck).contains(&RequestFailureKey::from(RenderRequest { page: 0, scale: 1.0, tag: 40, ..Default::default() }))); + assert!(!lock(&pool.shared.stuck).contains(&RequestFailureKey::from(RenderRequest { page: 1, scale: 1.0, tag: 41, ..Default::default() }))); + 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::ZERO); + let shared = render_gate(0, true); + let private = render_gate(0, false); + *lock(&pool.shared.render_gates) = vec![shared.clone(), private.clone()]; + 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]); + shared.entered.wait(); + assert!(pool.try_recv().is_none(), "shared timeout schedules the private retry"); + shared.release.wait(); + shared.finished.wait(); + private.entered.wait(); + 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(&RequestFailureKey::from(healthy)), "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)); + private.release.wait(); + private.finished.wait(); + for worker in workers { + worker.join().unwrap(); + } + assert!(pool.try_recv().is_none()); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_failed_page_stays_refused_at_every_scale_and_generation_but_other_pages_render() { + let pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default()); + let bad = RenderRequest { page: 0, scale: 1.0, tag: 70, ..Default::default() }; + lock(&pool.shared.stuck).insert(bad.into()); + for req in [bad, RenderRequest { scale: 0.5, tag: 71, ..bad }, RenderRequest { tile: Some(Tile { x: 0, y: 0, w: 8, h: 8 }), tag: 72, ..bad }] + { + pool.set_queue(vec![req]); + let failed = receive_before_deadline(&pool); + assert_eq!(failed.request, req); + assert!(failed.error.as_deref().is_some_and(|message| message.contains("skipped earlier")), "{req:?}: {:?}", failed.error); + } + let other = RenderRequest { page: 1, scale: 0.5, tag: 73, ..Default::default() }; + pool.set_queue(vec![other]); + let rendered = receive_before_deadline(&pool); + assert_eq!(rendered.request, other); + assert!(rendered.error.is_none(), "another page still renders: {:?}", rendered.error); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn replacing_a_request_during_initialization_skips_its_obsolete_render() { + use std::time::Duration; + let mut pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default()); + let gate = parse_gate(); + *lock(&pool.shared.parse_gate) = Some(gate.clone()); + pool.set_stuck_after(Duration::ZERO); + pool.set_queue(vec![RenderRequest { page: 0, scale: 1.0, tag: 10, ..Default::default() }]); + gate.entered.wait(); + pool.set_queue(vec![RenderRequest { page: 1, scale: 1.0, tag: 11, ..Default::default() }]); + assert!(pool.try_recv().is_none(), "parsing is still held"); + pool.set_stuck_after(Duration::from_secs(60)); + gate.release.wait(); + 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_eq!(pool.shared.parses.load(Ordering::Relaxed), 1, "the useful parser is retained"); + assert_eq!(pool.shared.render_started.load(Ordering::Relaxed), 1, "obsolete page was skipped before rendering"); +} + +#[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.into()); + } + 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"); + } + assert_eq!( + pool.stats(), + RenderStats { + parser_builds: 1, + page_interpretations: 2, + render_requests: 3, + tile_requests: 0, + text_interpretations: 1, + rasterizations: 2 + } + ); + 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> { + 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] >>"); + // The 64 functions above nest exactly 128 levels (each dictionary and its /Functions + // array, then the leaf's /Domain array), the parser's nesting cap. 65 nest 130, so the + // parser refuses them before the function-depth guard sees them (a refusal either way). + // `function_limits_depth_guard_refuses_a_chain_of_references` tests the guard itself. + assert!(Object::from_bytes(over.as_bytes()).is_none(), "the parser refuses direct nesting past its cap"); + + // 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); + } +} +/// Stitching functions that each name the next through `N 0 R` nest no direct objects, so +/// the parser's nesting cap doesn't stop them and the function-depth guard does. +#[test] +fn function_limits_depth_guard_refuses_a_chain_of_references() { + use hayro::hayro_interpret::Function; + use hayro_syntax::object::{Object, ObjectIdentifier}; + for functions in [64, 65] { + // Objects 4 onwards: each stitching function names the next one; the last is a leaf. + let objects: String = (4..4 + functions) + .map(|number| { + let body = if number < 3 + functions { + format!("<< /FunctionType 3 /Domain [0 1] /Functions [{} 0 R] /Bounds [] /Encode [0 1] >>", number + 1) + } else { + "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>".to_owned() + }; + format!("{number} 0 obj {body} endobj\n") + }) + .collect(); + let parsed = 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\ + {objects}trailer << /Root 1 0 R >>\n%%EOF" + ) + .into_bytes(), + ) + .unwrap(); + let object = parsed.xref().get::>(ObjectIdentifier::new(4, 0)).unwrap(); + let function = Function::new(&object); + if functions == 64 { + assert_eq!(function.unwrap().eval([0.5].into_iter().collect()).unwrap().as_slice(), &[0.5]); + } else { + assert!(function.is_none(), "65 functions are one past the function-depth guard"); + } + } +} +#[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::>(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::>(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::>(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, ..Default::default() }); + 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])); +} +/// #1203: a /Lab colour space must honour its own /WhitePoint. The old path +/// fed every Lab value to the ICC transform as if the white point were D50, +/// shifting non-D50 pages several levels per channel (for example a D65 +/// blue came out 45 levels too red). Conversion and expected values follow +/// ISO 32000-2 §8.6.5.4 with the space's own /WhitePoint [0.9505 1 1.089]. +#[test] +fn lab_honours_the_space_white_point() { + use super::*; + let render = |l: f32, a: f32, b: f32| -> [u8; 4] { + let content = format!("/L cs {l} {a} {b} sc 0 0 200 200 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 200 200] /Resources << /ColorSpace << /L [/Lab << /WhitePoint [0.9505 1 1.089] /Range [-100 100 -100 100] >>] >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ) + .into_bytes(); + let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + assert!(page.error.is_none(), "Lab ({l} {a} {b}): {:?}", page.error); + assert_eq!((page.width, page.height), (200, 200)); + page.rgba[(100 * page.width as usize + 100) * 4..][..4].try_into().unwrap() + }; + + for (l, a, b, expected) in [ + (40.0, 20.0, -60.0, [19, 90, 194, 255]), + (70.0, -50.0, 30.0, [77, 193, 114, 255]), + (60.0, 0.0, -40.0, [87, 148, 215, 255]), + (50.0, 60.0, 40.0, [217, 61, 55, 255]), + (30.0, 40.0, 20.0, [130, 37, 42, 255]), + ] { + let got = render(l, a, b); + for (channel, (got, want)) in got.iter().zip(expected).enumerate() { + assert!((*got).abs_diff(want) <= 2, "Lab ({l} {a} {b}) channel {channel}: got {got:?}, expected {expected:?}"); + } + } +} +/// #1203: an `/Indexed` palette over Lab looks its entries up as +/// `byte / 255`, which must map onto L* 0..100 and the a*/b* /Range. They +/// were read as raw L*a*b* (L* below 1), so such palettes drew near black. +#[test] +fn indexed_palettes_over_lab_keep_their_colours() { + use super::*; + let lab = "[/Lab << /WhitePoint [0.9505 1 1.089] /Range [-100 100 -100 100] >>]"; + let render = |content: &str| -> [u8; 4] { + 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 200 200] /Resources << /ColorSpace << /L {lab} /I [/Indexed {lab} 1 <000000 80CCB2>] >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ) + .into_bytes(); + let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + assert!(page.error.is_none(), "{content}: {:?}", page.error); + assert_eq!((page.width, page.height), (200, 200)); + let start = (100 * page.width as usize + 100) * 4; + let mut pixel = [0; 4]; + pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel")); + pixel + }; + + // Palette entry 1 is <80 CC B2>: L* = 128/255·100, a* = -100 + 204/255·200, + // b* = -100 + 178/255·200, about Lab (50.2 60 39.6): a warm red. + let indexed = render("/I cs 1 sc 0 0 200 200 re f"); + let direct = render(&format!( + "/L cs {} {} {} sc 0 0 200 200 re f", + 128.0 / 255.0 * 100.0, + -100.0 + 204.0 / 255.0 * 200.0, + -100.0 + 178.0 / 255.0 * 200.0 + )); + for (channel, (got, want)) in indexed.iter().zip(direct).enumerate() { + assert!(got.abs_diff(want) <= 1, "channel {channel}: indexed {indexed:?}, direct {direct:?}"); + } + assert!(indexed[0] > 180, "the palette red must not render near black: {indexed:?}"); +} +#[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::ZERO); + let gate = render_gate(0, false); + *lock(&pool.shared.render_gates) = vec![gate.clone()]; + let req = |page| RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0, ..Default::default() }; + pool.set_queue(vec![req(0)]); + gate.entered.wait(); + let first = pool.try_recv().expect("the watchdog answers while the render is held"); + assert!(first.error.as_deref().is_some_and(|e| e.contains("took longer")), "{:?}", first.error); + // A zero allowance would give up on the healthy page 2 as well. + pool.set_stuck_after(std::time::Duration::from_secs(60)); + // The single worker is stuck, yet page 2 still renders (on the replacement worker). + pool.set_queue(vec![req(1)]); + let second = receive_before_deadline(&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 = receive_before_deadline(&pool); + assert!(again.error.as_deref().is_some_and(|e| e.contains("skipped earlier")), "{:?}", again.error); + // The released worker notices its slot was cleared; its late result is dropped. + gate.release.wait(); + gate.finished.wait(); + 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()); + let gate = render_gate(0, true); + *lock(&pool.shared.render_gates) = vec![gate.clone()]; + let req = |page| RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0, ..Default::default() }; + pool.set_queue(vec![req(0)]); + gate.entered.wait(); + // What the canvas sends next: the page it is still waiting for, then another one. + pool.set_queue(vec![req(0), req(1)]); + let other = receive_before_deadline(&pool); + assert!(other.error.is_none(), "{:?}", other.error); + assert_eq!(other.request.page, 1); + // Page 2 went to the idle worker instead of waiting behind a second copy of page 1. + assert_eq!(pool.shared.render_started.load(Ordering::Relaxed), 2, "each page rendered once"); + gate.release.wait(); + let first = receive_before_deadline(&pool); + assert!(first.error.is_none(), "{:?}", first.error); + assert_eq!(first.request.page, 0); + assert_eq!(pool.shared.render_started.load(Ordering::Relaxed), 2, "each page rendered once"); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn replacing_an_in_flight_request_does_not_delay_the_replacement() { + use super::*; + use std::time::{Duration, Instant}; + + let pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default()); + let a = RenderRequest { page: 0, scale: 0.5, tag: 101, ..Default::default() }; + let b = RenderRequest { page: 1, scale: 0.5, tag: 102, ..Default::default() }; + *lock(&pool.shared.slow_page) = Some((a.page, Duration::from_secs(10))); + pool.set_queue(vec![a]); + + let started = Instant::now(); + while pool.shared.render_started.load(Ordering::Relaxed) == 0 { + assert!(started.elapsed() < Duration::from_secs(2), "request A never started"); + std::thread::sleep(Duration::from_millis(1)); + } + + let replaced = Instant::now(); + pool.set_queue(vec![b]); + assert!(lock(&pool.shared.busy).iter().flatten().any(|busy| busy.cancelled.load(Ordering::Acquire)), "request A was not cancelled"); + let page = receive_before_deadline(&pool); + assert_eq!(page.request, b); + assert!(page.error.is_none(), "replacement request failed: {:?}", page.error); + assert!(replaced.elapsed() < Duration::from_secs(3), "obsolete request A delayed B"); + + let deadline = Instant::now() + Duration::from_millis(100); + while Instant::now() < deadline { + assert!(pool.try_recv().is_none(), "obsolete request produced a result"); + std::thread::sleep(Duration::from_millis(5)); + } +} + +/// A request cancelled while its worker waited for the shared parse must not leave that +/// worker unable to render: the abort is not a parse result. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_request_cancelled_while_waiting_for_the_parse_leaves_its_worker_usable() { + use super::*; + use std::time::{Duration, Instant}; + + let pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 2, RenderConfig::default()); + *lock(&pool.shared.slow_parse) = Some(Duration::from_millis(500)); + let a = RenderRequest { page: 0, scale: 0.5, tag: 301, ..Default::default() }; + let c = RenderRequest { page: 1, scale: 0.5, tag: 302, ..Default::default() }; + pool.set_queue(vec![a, c]); + let started = Instant::now(); + while lock(&pool.shared.busy).iter().flatten().count() < 2 { + assert!(started.elapsed() < Duration::from_secs(2), "both requests never started"); + std::thread::sleep(Duration::from_millis(1)); + } + *lock(&pool.shared.slow_parse) = None; + for round in 0..4u64 { + let x = RenderRequest { page: 0, scale: 0.5, tag: 400 + 2 * round, ..Default::default() }; + let y = RenderRequest { page: 1, scale: 0.5, tag: 401 + 2 * round, ..Default::default() }; + pool.set_queue(vec![x, y]); + for _ in 0..2 { + let page = receive_before_deadline(&pool); + assert!(page.request == x || page.request == y, "round {round}: obsolete {:?}", page.request); + assert!(page.error.is_none(), "round {round}: {:?}", page.error); + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn check_cancelled_parse_request_requeue(renew_before_abort: bool) { + use super::*; + use std::{sync::mpsc, time::Duration}; + + let pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default()); + let a = RenderRequest { page: 0, kind: RequestKind::Text, scale: 1.0, tag: 501, ..Default::default() }; + let b = RenderRequest { page: 1, scale: 1.0, tag: 502, ..Default::default() }; + let parse = parse_gate(); + *lock(&pool.shared.parse_gate) = Some(parse.clone()); + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + *lock(&pool.shared.busy_publish_gate) = Some(BusyPublishGate { request: b, entered: entered_tx, release: Arc::new(Mutex::new(release_rx)) }); + + pool.set_queue(vec![a]); + parse.entered.wait(); + pool.set_queue(vec![b]); + if renew_before_abort { + // B stays first so the worker reaches its gate even when aborting A requeues it. + pool.set_queue(vec![b, a]); + } + parse.release.wait(); + entered_rx.recv_timeout(Duration::from_secs(2)).expect("the worker finished aborting A and reached B"); + // The parse hook's lock can span its barrier waits; clear it only after parsing returned. + *lock(&pool.shared.parse_gate) = None; + if !renew_before_abort { + pool.set_queue(vec![a]); + } + // B holds the sole worker, so this observes queue admission without racing a worker pop. + let pending = pool.shared.queue.pop(); + if let Some(request) = pending { + pool.shared.queue.retry(request); + } + pool.set_queue(vec![a]); + // Release the worker before the regression assertion, including on the unfixed baseline. + release_tx.send(()).unwrap(); + assert_eq!(pending, Some(a), "aborting a parse must release its active request identity"); + + let page = receive_before_deadline(&pool); + assert_eq!(page.request, a); + assert!(page.error.is_none(), "renewed text request failed: {:?}", page.error); + assert!(page.text.is_some(), "the exact cancelled text request completed"); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn cancelled_parse_request_can_be_requested_again() { + check_cancelled_parse_request_requeue(false); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn cancelled_parse_request_renewed_before_abort_is_requeued() { + check_cancelled_parse_request_requeue(true); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn request_removed_before_busy_publication_skips_parser_acquisition() { + use std::{sync::mpsc, time::Duration}; + + let pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default()); + let a = RenderRequest { page: 0, scale: 0.5, tag: 201, ..Default::default() }; + let b = RenderRequest { page: 1, scale: 0.5, tag: 202, ..Default::default() }; + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + *lock(&pool.shared.busy_publish_gate) = Some(BusyPublishGate { request: a, entered: entered_tx, release: Arc::new(Mutex::new(release_rx)) }); + + pool.set_queue(vec![a]); + entered_rx.recv_timeout(Duration::from_secs(2)).expect("request A passed its first obsolescence check"); + assert!(!lock(&pool.shared.busy).iter().flatten().any(|busy| same_request(busy.request, a))); + pool.set_queue(vec![b]); + release_tx.send(()).unwrap(); + + let page = receive_before_deadline(&pool); + assert_eq!(page.request, b); + assert!(page.error.is_none(), "replacement request failed: {:?}", page.error); + assert_eq!(*lock(&pool.shared.parser_stage_entries), vec![b], "obsolete A must not enter parser acquisition"); + assert_eq!(pool.shared.render_started.load(Ordering::Relaxed), 1, "only replacement B is rasterized"); + assert!(pool.try_recv().is_none(), "obsolete request A did not publish a stale result"); +} + +#[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::>().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 = (0..pages) + .map(|page| RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 2000, ..Default::default() }) + .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 = 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, true), None); + assert_eq!(hayro::image_resampling_size(16, 1, 3, 48, 0.5, 32.0, 128, true), None); + assert_eq!(hayro::image_resampling_size(16, 1, 4, 64, 0.5, 32.0, 128, true), 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, true), 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, true), None); + assert_eq!(hayro::image_resampling_size(4, 4, 3, 49, 0.5, 0.5, 128, true), 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, true), 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, true), Some((8, 4))); + assert_eq!(hayro::image_resampling_size(16, 1, 3, 48, 0.5, 8.0, 128, true), Some((8, 8))); + assert_eq!(hayro::image_resampling_size(16, 8, 4, 512, 1.0, 1.0, 128, true), Some((16, 8))); + assert_eq!(hayro::image_resampling_size(0, 8, 1, 0, 1.0, 1.0, 128, true), None); + assert_eq!(hayro::image_resampling_size(16, 9, 1, 144, 1.0, 1.0, 128, true), None); + assert_eq!(hayro::image_resampling_size(65_536, 1, 1, 65_536, 1.0, 1.0, u64::MAX, true), 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, true), Some((16, 1))); + } + assert_eq!(hayro::image_resampling_size(65_535, 1, 1, 65_535, 1.0, 1.0, 65_535, true), 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, true), 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, true), 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, true), None); + assert_eq!(hayro::image_resampling_size(65_532 + 4, 1 + 4, 4, 65_536 * 5 * 4, 1.0, 1.0, 1 << 28, true), None); +} + +/// Nearest-neighbour copies source pixels and needs no filter tables, so a source wider than the +/// Catmull-Rom side cap still shrinks. The pixel budget applies to it all the same. +#[test] +fn image_resampling_nearest_accepts_wide_sources_the_filter_refuses() { + let wide: u32 = (1 << 20) + 1; + assert_eq!(hayro::image_resampling_size(wide, 1, 1, wide as usize, 1.0 / 4096.0, 1.0, 1 << 28, true), None); + assert_eq!(hayro::image_resampling_size(wide, 1, 1, wide as usize, 1.0 / 4096.0, 1.0, 1 << 28, false), Some((257, 1))); + assert_eq!(hayro::image_resampling_size(wide, 1, 1, wide as usize, 1.0 / 4096.0, 1.0, 256, false), None); +} + +fn strip_image_pdf(width: u32, body: &str, space: &str, encoded: &str, alpha: Option<&str>) -> Vec { + 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]); +} + +/// A source wider than the filter's 2^20-pixel side cap is shrunk by nearest-neighbour rather +/// than refused and skipped (the default, `/Interpolate` omitted, is nearest). +#[test] +fn wide_non_interpolated_sources_draw_after_shrinking() { + let data = "7f".repeat(1_100_000); + let pdf = strip_image_pdf(1_100_000, "q 16 0 0 1 2 2 cm /Im0 Do Q\n", "DeviceGray", &data, 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(), "{:?}", 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), &[127, 127, 127, 255], "source image was not dropped"); + assert_eq!(pixel(17), &[127, 127, 127, 255]); + assert_eq!(pixel(19), &[255, 255, 255, 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"; + +fn heavy_vector_pdf(commands: usize, text_at_end: bool) -> Vec { + let mut body = String::with_capacity(commands * 42 + 64); + for i in 0..commands { + let x = (i % 280) as u32; + let y = ((i / 280) % 280) as u32; + body.push_str(&format!("0 0 0 rg {x} {y} 1 1 re f\n")); + } + if text_at_end { + body.push_str("BT /F1 18 Tf 20 280 Td (TRAILING_MARKER) Tj ET\n"); + } + format!( + "%PDF-1.4\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 300 300] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{body}endstream endobj\n\ + 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + body.len() + ) + .into_bytes() +} + +#[test] +fn heavy_vector_fixture_preserves_trailing_text_and_raster_output() { + let mut renderer = PageRenderer::new(Arc::new(heavy_vector_pdf(5_000, true)), RenderConfig::default()); + let pixels = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(pixels.error.is_none(), "heavy vector render failed: {:?}", pixels.error); + assert!(pixels.rgba.as_chunks::<4>().0.iter().any(|pixel| *pixel != [255, 255, 255, 255])); + let text = renderer.render(RenderRequest { page: 0, kind: RequestKind::Text, ..Default::default() }); + assert!(text.error.is_none(), "heavy vector text failed: {:?}", text.error); + assert!(text.text.is_some_and(|page| page.plain_text().contains("TRAILING_MARKER"))); + assert_eq!( + renderer.stats(), + RenderStats { + parser_builds: 1, + page_interpretations: 1, + render_requests: 2, + tile_requests: 0, + text_interpretations: 1, + rasterizations: 1 + } + ); +} + +#[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 { + 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 shared_form_xobject_uses_each_pages_resource_context() { + let form = "/CS1 cs 0.5 0.5 0.5 sc 10 10 30 30 re f"; + let page_content = "/F Do"; + let 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 6 0 R] /Count 2 /MediaBox [0 0 50 50] >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /Resources << /XObject << /F 4 0 R >> /ColorSpace << /CS1 /DeviceRGB >> >> /Contents 5 0 R >> endobj\n\ + 4 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 50 50] /Length {} >> stream\n{form}\nendstream endobj\n\ + 5 0 obj << /Length {} >> stream\n{page_content}\nendstream endobj\n\ + 6 0 obj << /Type /Page /Parent 2 0 R /Resources << /XObject << /F 4 0 R >> /ColorSpace << /CS1 [/CalRGB << /WhitePoint [0.9505 1 1.089] /Gamma [2 2 2] /Matrix [1 0 0 0 1 0 0 0 1] >>] >> >> /Contents 7 0 R >> endobj\n\ + 7 0 obj << /Length {} >> stream\n{page_content}\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + form.len(), + page_content.len(), + page_content.len() + ) + .into_bytes(); + let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let first = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + let second = renderer.render(RenderRequest { page: 1, scale: 1.0, ..Default::default() }); + assert!(first.error.is_none() && second.error.is_none(), "{:?} / {:?}", first.error, second.error); + + let pixel = |page: &RenderedPage| { + let offset = ((20 * page.width + 20) * 4) as usize; + [page.rgba[offset], page.rgba[offset + 1], page.rgba[offset + 2], page.rgba[offset + 3]] + }; + let first_pixel = pixel(&first); + let second_pixel = pixel(&second); + assert_ne!(first_pixel, second_pixel, "the same Form XObject must resolve /CS1 in each page's resources"); + assert_ne!(first_pixel, [255, 255, 255, 255]); + assert_ne!(second_pixel, [255, 255, 255, 255]); +} + +#[test] +fn shared_soft_mask_uses_each_inherited_resource_context() { + let page_body = "/A Do /B Do"; + let form_a = "/GS gs 1 0 0 rg 0 0 40 40 re f"; + let form_b = "/GS gs 0 0 1 rg 60 0 40 40 re f"; + let mask_body = "/FILL Do"; + let white = "1 g 0 0 100 100 re f"; + let black = "0 g 0 0 100 100 re f"; + let 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 /MediaBox [0 0 100 100] >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /Resources << /XObject << /A 4 0 R /B 5 0 R >> >> /Contents 6 0 R >> endobj\n\ + 4 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << /ExtGState << /GS 8 0 R >> /XObject << /FILL 9 0 R >> >> /Length {} >> stream\n{form_a}\nendstream endobj\n\ + 5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << /ExtGState << /GS 8 0 R >> /XObject << /FILL 10 0 R >> >> /Length {} >> stream\n{form_b}\nendstream endobj\n\ + 6 0 obj << /Length {} >> stream\n{page_body}\nendstream endobj\n\ + 8 0 obj << /Type /ExtGState /SMask 11 0 R >> endobj\n\ + 9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Length {} >> stream\n{white}\nendstream endobj\n\ + 10 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Length {} >> stream\n{black}\nendstream endobj\n\ + 11 0 obj << /Type /Mask /S /Luminosity /G 12 0 R >> endobj\n\ + 12 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS /DeviceGray >> /Length {} >> stream\n{mask_body}\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + form_a.len(), + form_b.len(), + page_body.len(), + white.len(), + black.len(), + mask_body.len() + ) + .into_bytes(); + let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + let pixel = |x: u32, y: u32| { + let offset = ((y * page.width + x) * 4) as usize; + [page.rgba[offset], page.rgba[offset + 1], page.rgba[offset + 2], page.rgba[offset + 3]] + }; + assert_eq!(pixel(20, 80), [255, 0, 0, 255], "the white inherited mask makes form A visible"); + assert_eq!(pixel(80, 80), [255, 255, 255, 255], "the black inherited mask must not reuse form A's cached pixels"); +} + +#[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, ..Default::default() }]); + 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]); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn page_pixels_are_invariant_to_render_worker_count() { + let blue = "0 0 1 rg 10 10 30 20 re f"; + let red = "1 0 0 rg 20 5 40 35 re f"; + let pdf = format!( + "%PDF-1.4\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 /MediaBox [0 0 100 50] >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{blue}\nendstream endobj\n\ + 5 0 obj << /Type /Page /Parent 2 0 R /Contents 6 0 R >> endobj\n\ + 6 0 obj << /Length {} >> stream\n{red}\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + blue.len(), + red.len() + ) + .into_bytes(); + let bytes = Arc::new(pdf); + let mut reference = PageRenderer::new(bytes.clone(), RenderConfig::default()); + let expected: Vec<_> = (0..2).map(|page| reference.render(RenderRequest { page, scale: 1.0, ..Default::default() })).collect(); + assert!(expected.iter().all(|page| page.error.is_none())); + + for workers in [1, 2, 4] { + let pool = RenderPool::new(bytes.clone(), workers, RenderConfig::default()); + pool.set_queue((0..2).map(|page| RenderRequest { page, scale: 1.0, ..Default::default() }).collect()); + let mut actual = [receive_before_deadline(&pool), receive_before_deadline(&pool)]; + actual.sort_by_key(|page| page.request.page); + for (page, (actual, expected)) in actual.iter().zip(&expected).enumerate() { + assert!(actual.error.is_none(), "{workers} workers, page {page}: {:?}", actual.error); + assert_eq!( + (actual.width, actual.height, &actual.rgba), + (expected.width, expected.height, &expected.rgba), + "{workers} workers, page {page}" + ); + } + } +} + +#[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, ..Default::default() }); + 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::(), 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, ..Default::default() }).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, ..Default::default() }).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); +} + +#[test] +fn strict_whole_page_render_refuses_cap_reduction_but_tiles_remain_available() { + 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 14400 14400] /Contents 4 0 R >> endobj +4 0 obj << /Length 0 >> stream +endstream endobj +trailer << /Root 1 0 R >> +%%EOF"; + let config = RenderConfig { reject_oversize: true, ..RenderConfig::default() }; + let mut renderer = PageRenderer::new(Arc::new(pdf.to_vec()), config); + let whole = renderer.render(RenderRequest { page: 0, scale: 4.0, ..Default::default() }); + assert!(whole.error.as_deref().is_some_and(|error| error.contains("exceeds renderer limits"))); + let tile = renderer.render(RenderRequest { page: 0, tile: Some(Tile { x: 0, y: 0, w: 32, h: 32 }), scale: 4.0, ..Default::default() }); + assert!(tile.error.is_none(), "tiled rendering must remain available: {:?}", tile.error); + assert_eq!((tile.width, tile.height), (32, 32)); +} + +fn allocation_budget_page(width: f32, height: f32, content: &str) -> Arc> { + Arc::new(format!("%PDF-1.4\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 {width} {height}] /Contents 4 0 R >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF", content.len()).into_bytes()) +} + +#[test] +fn rounded_pixel_budget_rejects_before_actual_allocation() { + use crate::pixels::allocation_probe::{Attempts, observe}; + for reject_oversize in [false, true] { + let mut renderer = PageRenderer::new(allocation_budget_page(8000.5, 7999.5, ""), RenderConfig { reject_oversize, ..Default::default() }); + assert_eq!(renderer.page_count(), 1); + let (out, attempts) = observe(|| renderer.render(RenderRequest { scale: 1.0, ..Default::default() })); + assert_eq!(attempts, Attempts::default(), "rounded over-cap raster reached allocator; strict={reject_oversize}"); + assert_eq!(out.error.as_deref(), Some("page 1 raster size 8001x8000 exceeds renderer pixel limit")); + assert_eq!((out.width, out.height), (0, 0)); + assert!(out.rgba.is_empty()); + assert_eq!(renderer.stats().parser_builds, 1, "normal rejection must not trigger panic recovery"); + } +} + +#[test] +fn rounded_pixel_budget_allows_exact_cap_to_reach_allocator() { + use crate::pixels::allocation_probe::{Attempts, observe}; + for reject_oversize in [false, true] { + let mut renderer = PageRenderer::new(allocation_budget_page(8000.0, 8000.0, ""), RenderConfig { reject_oversize, ..Default::default() }); + let (out, attempts) = observe(|| renderer.render(RenderRequest { scale: 1.0, ..Default::default() })); + assert_eq!(attempts, Attempts { calls: 1, first: Some((8000, 8000)) }); + // The test probe refuses the actual allocation: this is admission evidence, + // not a successful giant-raster rendering or a 256MB test allocation. + assert_eq!(out.error.as_deref(), Some("page 1 is too large to render")); + assert_eq!((out.width, out.height), (0, 0)); + assert!(out.rgba.is_empty()); + assert_eq!(renderer.stats().parser_builds, 1); + } +} + +#[test] +fn rounded_pixel_budget_preserves_small_pixels_and_large_page_tiles() { + let mut previous = None; + for reject_oversize in [false, true] { + let config = RenderConfig { reject_oversize, ..Default::default() }; + let mut small = PageRenderer::new(allocation_budget_page(16.0, 12.0, "0 0 1 rg 4 3 8 6 re f"), config.clone()); + let out = small.render(RenderRequest { scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + assert_eq!((out.width, out.height), (16, 12)); + let expected: Vec = (0..12) + .flat_map(|y| { + (0..16).flat_map(move |x| if (4..12).contains(&x) && (3..9).contains(&y) { [0, 0, 255, 255] } else { [255, 255, 255, 255] }) + }) + .collect(); + assert_eq!(&*out.rgba, expected.as_slice()); + if let Some(previous) = &previous { + assert_eq!(previous, &out.rgba); + } + previous = Some(out.rgba); + let mut large = PageRenderer::new(allocation_budget_page(8000.5, 7999.5, ""), config); + let tile = large.render(RenderRequest { scale: 1.0, tile: Some(Tile { x: 0, y: 0, w: 32, h: 32 }), ..Default::default() }); + assert!(tile.error.is_none(), "{:?}", tile.error); + assert_eq!((tile.width, tile.height), (32, 32)); + assert_eq!(&*tile.rgba, &[255; 32 * 32 * 4]); + assert_eq!(large.stats().parser_builds, 1); + } +} + +#[test] +fn rounded_pixel_budget_invalid_requests_never_reach_allocator() { + use crate::pixels::allocation_probe::{Attempts, observe}; + for reject_oversize in [false, true] { + let mut renderer = PageRenderer::new(allocation_budget_page(16.0, 12.0, ""), RenderConfig { reject_oversize, ..Default::default() }); + let (out, attempts) = observe(|| renderer.render(RenderRequest { page: 1, scale: 1.0, ..Default::default() })); + assert_eq!(attempts, Attempts::default()); + assert!(out.error.is_some()); + assert_eq!((out.width, out.height), (0, 0)); + assert!(out.rgba.is_empty()); + assert_eq!(renderer.stats().parser_builds, 1); + } + let mut renderer = PageRenderer::new(allocation_budget_page(16.0, 12.0, ""), RenderConfig { reject_oversize: true, ..Default::default() }); + for scale in [0.0, -1.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let (out, attempts) = observe(|| renderer.render(RenderRequest { scale, ..Default::default() })); + assert_eq!(attempts, Attempts::default()); + assert!(out.error.as_deref().is_some_and(|error| error.contains("exceeds renderer limits"))); + assert_eq!((out.width, out.height), (0, 0)); + assert!(out.rgba.is_empty()); + assert_eq!(renderer.stats().parser_builds, 1); + } +} + +/// 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, ..Default::default() }; + 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, ..Default::default() }); + 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, ..Default::default() }); + 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"); +} + +/// Microsoft Reporting Services (bank statements, invoices) writes `stream \r\n`, a space +/// before the line break. The content stream was then taken to start at the space and the +/// page rendered blank, while Preview, Chrome and Poppler showed it. Vendored hayro-syntax +/// patch: spaces and tabs before the line break are skipped, with the right `/Length` and +/// with a wrong one. Spaces with no line break after them never start stream data. +#[test] +fn space_before_the_line_break_after_stream_still_renders() { + let pdf = |eol: &str, length: u32| { + format!( + "%PDF-1.3\r\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\r\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\r\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R >> endobj\r\n\ + 4 0 obj << /Length {length} >> stream{eol}0 0 0 rg 20 20 60 60 re f\r\nendstream\r\nendobj\r\n\ + trailer << /Root 1 0 R >>\r\n%%EOF" + ) + .into_bytes() + }; + for (eol, length) in [(" \r\n", 25), (" \n", 25), ("\t \r", 25), (" \r\n", 999), ("\r\n", 25)] { + let mut r = PageRenderer::new(Arc::new(pdf(eol, length)), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(p.error.is_none(), "{eol:?}: {:?}", p.error); + let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec(); + assert_eq!(px(50, 50), vec![0, 0, 0, 255], "stream{eol:?} (/Length {length}) must draw the square"); + assert_eq!(px(5, 5), vec![255, 255, 255, 255], "outside the square stays white"); + } +} + +/// 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>) {} + 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>, _: 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| { + 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, ..Default::default() }) + }; + 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, ..Default::default() }); + 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}"); + } +} + +/// #2583, #1869: a sticky note carries the NoRotate flag (`/F` bit 5), so on a page with +/// `/Rotate` its icon stays upright as displayed, turned about the upper-left corner of its +/// `/Rect`, which stays where it is on the page (ISO 32000-2 §12.5.3). It was drawn turned with +/// the page, in `/Rect` as displayed (vendored hayro-interpret patch 37). A note without the +/// flag still turns with the page. `Annotation::shown_rect` is where the UI hit-tests the +/// note, and must be where it is drawn. +#[test] +fn no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages() { + // A 30 × 20 appearance: red in its top-left quarter, blue elsewhere. + let content = "0 0 1 rg 0 0 30 20 re f 1 0 0 rg 0 10 15 10 re f"; + for rotation in [0u16, 90, 180, 270] { + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 140] /Rotate {rotation} /Annots [4 0 R 5 0 R] >>"), + // NoRotate (and Print), as PdfCraft and Acrobat save notes. + "<< /Type /Annot /Subtype /Text /F 20 /Rect [40 50 70 70] /AP << /N 6 0 R >> >>".to_string(), + // The same appearance without the flag. + "<< /Type /Annot /Subtype /Text /F 4 /Rect [120 50 150 70] /AP << /N 6 0 R >> >>".to_string(), + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 30 20] /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + let pdf = Arc::new(pdf); + let info = crate::inspect(pdf.clone(), None).expect("opens"); + let page = &info.pages[0]; + assert_eq!(page.rotation, rotation); + let mut r = PageRenderer::new(pdf, RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(p.error.is_none(), "{:?}", p.error); + // Displayed-page points are device pixels at scale 1. + let px = |x: f32, y: f32| -> [u8; 4] { + let (x, y) = (x.clamp(0.0, p.width as f32 - 1.0) as u32, y.clamp(0.0, p.height as f32 - 1.0) as u32); + p.rgba[((y * p.width + x) * 4) as usize..][..4].try_into().unwrap() + }; + let red: fn([u8; 4]) -> bool = |c| c[0] > 200 && c[1] < 60 && c[2] < 60; + let blue: fn([u8; 4]) -> bool = |c| c[2] > 200 && c[0] < 60 && c[1] < 60; + let paper: fn([u8; 4]) -> bool = |c| c.iter().all(|v| *v > 240); + + // The NoRotate note: its upper-left corner (40, 70) is the top-left of an upright + // 30 × 20 icon as displayed, red quarter top-left. + let [cx, cy] = page.user_to_view(40.0, 70.0); + for ((dx, dy), want, what) in [ + ((7.5, 5.0), red, "red top-left quarter"), + ((22.5, 5.0), blue, "blue top-right"), + ((7.5, 15.0), blue, "blue bottom-left"), + ((22.5, 15.0), blue, "blue bottom-right"), + ((-3.0, 10.0), paper, "paper left of the icon"), + ((10.0, -3.0), paper, "paper above the icon"), + ((33.0, 10.0), paper, "paper right of its 30 pt width"), + ((10.0, 23.0), paper, "paper below its 20 pt height"), + ] { + let c = px(cx + dx, cy + dy); + assert!(want(c), "rotation {rotation}: NoRotate note at corner + ({dx}, {dy}) is {c:?}, want {what}"); + } + let note = info.annotations.iter().find(|a| a.no_rotate).expect("the NoRotate note is listed"); + let shown = note.shown_rect(rotation); + let (a, b) = (page.user_to_view(shown[0], shown[1]), page.user_to_view(shown[2], shown[3])); + let box_ = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]; + let want = [cx, cy, cx + 30.0, cy + 20.0]; + assert!(box_.iter().zip(want).all(|(v, w)| (v - w).abs() < 0.01), "rotation {rotation}: shown {box_:?}, drawn {want:?}"); + + // The note without the flag turns with the page: its red quarter stays at the + // user-space top-left of /Rect, at the displayed top-left only when unturned. + let plain = info.annotations.iter().find(|a| !a.no_rotate).expect("the plain note is listed"); + assert_eq!(plain.shown_rect(rotation), plain.rect); + let [rx, ry] = page.user_to_view(127.5, 65.0); + let [bx, by] = page.user_to_view(142.5, 55.0); + assert!(red(px(rx, ry)) && blue(px(bx, by)), "rotation {rotation}: the plain note's appearance turns with the page"); + let (a, b) = (page.user_to_view(120.0, 50.0), page.user_to_view(150.0, 70.0)); + let corner = px(a[0].min(b[0]) + 4.0, a[1].min(b[1]) + 4.0); + assert_eq!(red(corner), rotation == 0, "rotation {rotation}: displayed top-left of the plain note is {corner:?}"); + } +} + +/// #1968: the highlight appearance other editors write, a transparency group whose content is +/// filled with `/BM /Multiply`, drew as an opaque block over the text. Every `/Group` form +/// got a layer of its own, so the Multiply blended with that layer's transparent backdrop +/// and the layer then covered the page. Vendored hayro-interpret patch: a group that is not +/// isolated (ISO 32000-2 §11.4.7: its elements composite with what is already on the page) +/// and is entered at full opacity, with no soft mask and Normal blending, has no backdrop of +/// its own to offer, so its content is drawn straight onto the page. An isolated group +/// (`/I true`) keeps its blank backdrop, and a group entered at partial opacity still gets +/// its layer so that opacity applies to the group as a whole. +#[test] +fn a_multiply_highlight_inside_a_transparency_group_keeps_the_text() { + // `annots`: the form drawn as the highlight's appearance; `page`: drawn from the page + // content instead, with the extra graphics state in front of the `Do`. + let render = |group: &str, annots: &str, page: &str| { + let content = format!("0 g 100 40 10 20 re f {page}"); + let form = "/GS0 gs 1 1 0 rg 15 35 105 30 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} /Resources << /XObject << /Fx 6 0 R >> /ExtGState << /Half << /ca 0.5 >> >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Annot /Subtype /Highlight /Rect [15 35 120 65] /QuadPoints [15 65 120 65 15 35 120 35] /C [1 1 0] /AP << /N 6 0 R >> >> endobj +6 0 obj << /Type /XObject /Subtype /Form /BBox [15 35 120 65] {group} /Resources << /ExtGState << /GS0 << /BM /Multiply /CA 1 /ca 1 >> >> >> /Length {} >> stream +{form} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + form.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, ..Default::default() }); + assert!(p.error.is_none(), "{group}: {:?}", 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 annots = "/Annots [5 0 R]"; + let plain = "/Group << /S /Transparency /CS /DeviceRGB >>"; + let explicit = "/Group << /S /Transparency /CS /DeviceRGB /I false /K false >>"; + for group in [plain, explicit, ""] { + for (annots, page) in [(annots, ""), ("", "/Fx Do")] { + let p = render(group, annots, page); + assert_eq!(px(&p, 30, 50), yellow, "{group} {page}: filled with the highlight colour"); + assert_eq!(px(&p, 105, 50), black, "{group} {page}: Multiply keeps the text underneath"); + assert_eq!(px(&p, 150, 50), white, "{group} {page}: outside the appearance"); + } + } + // Isolated: the group's own transparent backdrop is all the Multiply sees (§11.4.7). + let p = render("/Group << /S /Transparency /CS /DeviceRGB /I true >>", annots, ""); + assert_eq!(px(&p, 105, 50), yellow, "an isolated group starts from a blank backdrop"); + // Entered at half opacity, the group is still composited as one: half yellow over white. + let p = render(plain, "", "/Half gs /Fx Do"); + let half = px(&p, 30, 50); + assert!(half[0] == 255 && half[1] == 255 && (120..=136).contains(&half[2]), "{half:?}"); + assert_eq!(px(&p, 150, 50), white); +} + +/// 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, ..Default::default() }); + 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, ..Default::default() }) + }; + 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"); +} + +/// ISO 32000-2 Table 167: Invisible hides an annotation of a type that isn't standard and +/// nothing else; §12.5.6.14: a popup has no appearance of its own, so its `/AP` isn't painted. +#[test] +fn invisible_unknown_annotations_and_popups_paint_nothing() { + let annots = [ + "/Subtype /MyCustomType /F 0", + "/Subtype /MyCustomType /F 1", + "/Subtype /MyCustomType /F 5", + "/F 1", + "/Subtype /Square /F 1", + "/Subtype /Popup /F 4 /Open true", + ]; + let mut pdf = String::from("%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n"); + pdf.push_str("2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n"); + let refs: Vec = (0..annots.len()).map(|i| format!("{} 0 R", 10 + i)).collect(); + pdf.push_str(&format!("3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 50] /Annots [{}] >> endobj\n", refs.join(" "))); + pdf.push_str("4 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 30 30] /Length 24 >> stream\n0 1 0 rg 0 0 30 30 re f\nendstream endobj\n"); + for (i, a) in annots.iter().enumerate() { + let x = 10 + 50 * i; + pdf.push_str(&format!("{} 0 obj << /Type /Annot {a} /Rect [{x} 10 {} 40] /AP << /N 4 0 R >> >> endobj\n", 10 + i, x + 30)); + } + pdf.push_str("trailer << /Root 1 0 R >>\n%%EOF"); + 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, ..Default::default() }); + let px = |x: u32| p.rgba[((25 * p.width + x) * 4) as usize..][..4].to_vec(); + let (green, white) = (vec![0, 255, 0, 255], vec![255, 255, 255, 255]); + assert_eq!(px(25), green, "an unknown type without Invisible is drawn"); + assert_eq!(px(75), white, "Invisible hides an unknown type"); + assert_eq!(px(125), white, "even with the Print flag"); + assert_eq!(px(175), white, "no Subtype is no standard type"); + assert_eq!(px(225), green, "Invisible means nothing for a standard type"); + assert_eq!(px(275), white, "a popup's appearance is not painted on the page"); +} + +#[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, ..Default::default() }, + RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 2, ..Default::default() }, + ]); + 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::ZERO); + let gate = render_gate(0, false); + *lock(&pool.shared.render_gates) = vec![gate.clone()]; + pool.set_queue(vec![RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0, ..Default::default() }]); + gate.entered.wait(); + let first = pool.try_recv().expect("the watchdog answers for the held render"); + 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 = lock(&pool.shared.stop).iter().map(|s| s.load(std::sync::atomic::Ordering::Relaxed)).collect(); + assert_eq!(stop, vec![true, false]); + gate.release.wait(); + gate.finished.wait(); + assert!(pool.try_recv().is_none(), "the abandoned render must not publish a late result"); +} + +#[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, ..Default::default() }); + 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, ..Default::default() }); + 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"); +} + +/// #2986: an optional content membership dictionary with a visibility expression (`/VE`, +/// ISO 32000-2 §8.11.2.2) shows its content only when the expression is true, and the +/// expression takes precedence over `/OCGs` and `/P`. It was ignored, so content under it +/// stayed visible when its layers were hidden. A malformed expression falls back to `/OCGs`. +#[test] +fn visibility_expressions_decide_what_a_membership_dictionary_shows() { + let render = |ocmd: &str, off: &[i32]| { + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /Order [5 0 R 6 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 << /M1 7 0 R >> >> >> endobj +4 0 obj << /Length 44 >> stream +/OC /M1 BDC 1 0 0 rg 0 0 100 100 re f EMC +endstream endobj +5 0 obj << /Type /OCG /Name (A) >> endobj +6 0 obj << /Type /OCG /Name (B) >> endobj +7 0 obj {ocmd} endobj +trailer << /Root 1 0 R >> +%%EOF" + ); + let layers = off.iter().map(|n| (*n, 0, false)).collect(); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig { layers: Arc::new(layers), ..Default::default() }); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0, ..Default::default() }); + p.rgba[..4] == [255, 0, 0, 255] + }; + let and = "<< /Type /OCMD /VE [/And 5 0 R 6 0 R] >>"; + assert!(render(and, &[]), "And: both on"); + assert!(!render(and, &[5]), "And: one off"); + let or = "<< /Type /OCMD /VE [/Or 5 0 R 6 0 R] >>"; + assert!(render(or, &[5]), "Or: one on"); + assert!(!render(or, &[5, 6]), "Or: both off"); + let not = "<< /Type /OCMD /VE [/Not 5 0 R] >>"; + assert!(!render(not, &[]), "Not: the group is on"); + assert!(render(not, &[5]), "Not: the group is off"); + let nested = "<< /Type /OCMD /VE [/And [/Not 5 0 R] 6 0 R] >>"; + assert!(render(nested, &[5]), "nested: A off, B on"); + assert!(!render(nested, &[]), "nested: A on"); + // /VE wins over /OCGs and /P. + let both = "<< /Type /OCMD /OCGs [5 0 R] /P /AllOn /VE [/Not 5 0 R] >>"; + assert!(render(both, &[5]), "the expression, not /P, decides"); + // A malformed expression falls back to /OCGs and /P. + // So is one naming an object that isn't a listed group: a missing one, or the page + // (review on #2987). + for bad in ["[/Xor 5 0 R]", "[/Not 5 0 R 6 0 R]", "[/And]", "[5 0 R]", "[/And (5)]", "[/Or 8 0 R]", "[/Or 3 0 R]"] { + let ocmd = format!("<< /Type /OCMD /OCGs [5 0 R] /VE {bad} >>"); + assert!(render(&ocmd, &[]), "{bad}: /OCGs on"); + assert!(!render(&ocmd, &[5]), "{bad}: /OCGs off"); + } + // Nesting past the limit is malformed too, and does not recurse without end. + let deep = format!("<< /Type /OCMD /OCGs [5 0 R] /VE {}5 0 R{} >>", "[/Not ".repeat(41), "]".repeat(41)); + assert!(!render(&deep, &[5]), "too deep: /OCGs decides"); +} + +/// 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, ..Default::default() }) + }; + 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"); +} + +#[test] +fn tiling_pattern_form_cycles_are_bounded_and_finite_nesting_paints() { + let cases = [ + ("self-cycle", "/Pattern cs /P1 scn 0 0 10 10 re f 0 0 1 rg 0 0 10 10 re f"), + ("pattern-form-cycle", "/F Do 0 0 1 rg 0 0 10 10 re f"), + ("finite-nesting", "/Pattern cs /P2 scn 0 0 10 10 re f"), + ]; + let pattern_b = "0 0 1 rg 0 0 10 10 re f"; + let form = "/Pattern cs /P1 scn 0 0 10 10 re f"; + let page_content = "/Pattern cs /P1 scn 0 0 40 40 re f 1 0 0 rg 32 32 8 8 re f"; + for (case, pattern_a) in cases { + let 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 /MediaBox [0 0 40 40] >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Pattern << /P1 5 0 R /P2 6 0 R >> /XObject << /F 7 0 R >> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{page_content}\nendstream endobj\n\ + 5 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Length {} >> stream\n{pattern_a}\nendstream endobj\n\ + 6 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Length {} >> stream\n{pattern_b}\nendstream endobj\n\ + 7 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Length {} >> stream\n{form}\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + page_content.len(), + pattern_a.len(), + pattern_b.len(), + form.len() + ) + .into_bytes(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let _ = tx.send(renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() })); + }); + let page = rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap_or_else(|_| panic!("{case}: render did not finish")); + assert!(page.error.is_none(), "{case}: {:?}", page.error); + assert_eq!((page.width, page.height), (40, 40), "{case}"); + let pixel = |x: u32, y: u32| { + let offset = ((y * page.width + x) * 4) as usize; + [page.rgba[offset], page.rgba[offset + 1], page.rgba[offset + 2], page.rgba[offset + 3]] + }; + assert_eq!(pixel(1, 38), [0, 0, 255, 255], "{case}: nested pattern content paints"); + assert_eq!(pixel(35, 4), [255, 0, 0, 255], "{case}: page content after the pattern still paints"); + } +} + +/// A shaded Type 3 glyph must use bounds in device coordinates at every position. +#[test] +fn shaded_type3_glyph_draws_at_multiple_positions() { + let content = "BT /F 30 Tf 1 0 0 1 50 70 Tm (A) Tj 1 0 0 1 220 70 Tm (A) Tj ET"; + let glyph = "100 0 d0 0 0 100 100 re W n /S sh"; + 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 300 150] /Resources << /Font << /F 5 0 R >> >> /Contents 4 0 R >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 100 100] /FontMatrix [0.01 0 0 0.01 0 0] /CharProcs << /A 6 0 R >> /Encoding << /Differences [65 /A] >> /FirstChar 65 /LastChar 65 /Widths [100] /Resources << /Shading << /S 7 0 R >> >> >> endobj +6 0 obj << /Length {} >> stream +{glyph} +endstream endobj +7 0 obj << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> /Extend [true true] >> endobj +trailer << /Root 1 0 R >> +%%EOF", content.len(), glyph.len() + ); + let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + for x in [65usize, 235] { + let pixel = &page.rgba[(65 * 300 + x) * 4..][..4]; + assert!(pixel[0] < 240 && pixel[2] < 240 && pixel[1] < 10, "glyph at {x}: {pixel:?}"); + } +} + +/// 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, ..Default::default() }) + }; + 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, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255]); +} + +/// One page `w` × `h` points whose content `content` paints with pattern `/P1`: a 10 × 10 cell +/// of blue with `/XStep step /YStep step` and pattern matrix `matrix`. Its RGBA pixels. +fn tiling_page(w: usize, h: usize, content: &str, step: &str, matrix: &str) -> Vec { + let cell = "0 0 1 rg 0 0 10 10 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 {w} {h}] /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 3 /BBox [0 0 10 10] /XStep {step} /YStep {step} /Matrix [{matrix}] /Length {} >> stream +{cell} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + cell.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, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + page.rgba.to_vec() +} + +/// #3349: PowerPoint (PDFMaker) writes a picture fill as a tiling pattern drawn once, with +/// `/XStep 2147480100 /YStep 2147480100` and a matrix that flips y. Fitting such a step into +/// a cell pixmap shrank the picture below a pixel, so it vanished. The cell's `/BBox` is drawn +/// at full scale, once, filled or stroked: where it is it shows, and around it (left, right, +/// above, below) nothing repeats or smears. +#[test] +fn a_tiling_pattern_drawn_once_shows_its_cell() { + let at = |px: &[u8], w: usize, x: usize, y: usize| px[(y * w + x) * 4..][..3].to_vec(); + let (blue, white) = (vec![0, 0, 255], vec![255, 255, 255]); + // The cell at user x 10..20 and, flipped, y 10..20: device rows 20..30. + let once = tiling_page(40, 40, "/Pattern cs /P1 scn 0 0 40 40 re f", "2147480100", "1 0 0 -1 10 20"); + assert_eq!(at(&once, 40, 15, 25), blue, "the cell"); + for (x, y, side) in [(5, 25, "left"), (25, 25, "right"), (15, 15, "above"), (15, 35, "below")] { + assert_eq!(at(&once, 40, x, y), white, "nothing {side} of it"); + } + // Stroked: a wide line across the cell shows it too. + let stroked = tiling_page(40, 40, "/Pattern CS /P1 SCN 20 w 0 15 m 40 15 l S", "2147480100", "1 0 0 -1 10 20"); + assert_eq!(at(&stroked, 40, 15, 25), blue, "a stroke with the pattern"); + assert_eq!(at(&stroked, 40, 25, 25), white); + // An ordinary step still tiles. + let tiled = tiling_page(40, 40, "/Pattern cs /P1 scn 0 0 40 40 re f", "20", "1 0 0 1 0 0"); + assert_eq!(at(&tiled, 40, 25, 35), blue, "a step of 20 repeats the cell"); +} + +/// #3356 review: a `/BBox` too thin to divide by (not zero, which is refused, but 1e-40 +/// wide) makes the cell scale infinite; it keeps the old path, so no infinite or NaN +/// geometry reaches the cell renderer, the page renders and what follows still draws. +#[test] +fn a_tiling_cell_too_thin_to_scale_keeps_the_old_path() { + assert!(hayro::usable_cell_scale(2.0, 1.0)); + for (xs, ys) in [(f32::INFINITY, 1.0), (1.0, f32::NAN), (0.0, 1.0), (1.0, -2.0)] { + assert!(!hayro::usable_cell_scale(xs, ys), "{xs} {ys}"); + } + let thin = "0.0000000000000000000000000000000000000001"; + let content = "/Pattern cs /P1 scn 0 0 40 40 re f 1 0 0 rg 0 0 4 4 re f"; + let cell = "0 0 1 rg 0 0 10 10 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 3 /BBox [0 0 {thin} 10] /XStep 2147480100 /YStep 2147480100 /Length {} >> stream +{cell} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + cell.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, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "what follows still draws"); + assert_eq!(&page.rgba[((20 * 40 + 20) * 4)..][..3], &[255, 255, 255], "the degenerate cell draws nothing"); +} + +/// #3349: a step too large for a cell pixmap is drawn as one cell only when the painted area +/// can't reach another: a page wide (or tall) enough to show the next cell, or the one before, +/// still tiles them. +#[test] +fn a_large_step_still_tiles_where_the_next_cell_shows() { + let at = |px: &[u8], w: usize, x: usize, y: usize| px[(y * w + x) * 4..][..3].to_vec(); + let blue = vec![0, 0, 255]; + // Steps of 3100 points: over 3000 pixels at this scale, so the cell is shrunk to fit, + // by 3000/3100; a pixel 4 points into a cell is blue either way. + let wide = "/Pattern cs /P1 scn 0 0 3200 20 re f"; + let next = tiling_page(3200, 20, wide, "3100", "1 0 0 1 0 0"); + assert_eq!(at(&next, 3200, 3104, 15), blue, "the cell to the right"); + let before = tiling_page(3200, 20, wide, "3100", "1 0 0 1 3100 0"); + assert_eq!(at(&before, 3200, 4, 15), blue, "the cell to the left"); + let tall = "/Pattern cs /P1 scn 0 0 20 3200 re f"; + let above = tiling_page(20, 3200, tall, "3100", "1 0 0 1 0 0"); + assert_eq!(at(&above, 20, 4, 3200 - 3104), blue, "the cell above"); + let below = tiling_page(20, 3200, tall, "3100", "1 0 0 1 0 3100"); + assert_eq!(at(&below, 20, 4, 3200 - 4), blue, "the cell below"); + // A stroke counts with its width: this line ends at x 3080, short of the next cell at + // 3100, but its square cap reaches 3110. + let stroked = tiling_page(3200, 20, "/Pattern CS /P1 SCN 60 w 2 J 0 10 m 3080 10 l S", "3100", "1 0 0 1 0 0"); + assert_eq!(at(&stroked, 3200, 3104, 15), blue, "the next cell, under the stroke's cap"); + // A miter reaches further than the line width (review on #3356): this join's vertex is + // at x 3085, 15 short of the next cell with a 10-wide line, but its miter tip, 45 long + // under the default limit of 10, reaches 3130. + let mitered = tiling_page(3200, 20, "/Pattern CS /P1 SCN 10 w 0 j 3000 0 m 3085 10 l 3000 20 l S", "3100", "1 0 0 1 0 0"); + // The tip narrows to a point, so this pixel is partly covered: blue, not white. + let tip = at(&mitered, 3200, 3104, 10); + assert!(tip[0] < 160 && tip[1] < 160 && tip[2] > 200, "the next cell, under the miter's tip: {tip:?}"); + // Under a skew, a direction between the axes stretches more than either axis (review on + // #3356). This join's vertex is at device x 2980 and its miter tip, along that direction, + // at 3107: the larger axis stretch (1.118) put the reach at 3092, short of the next cell; + // the most any direction stretches (1.461) puts it at 3126. + let skewed = tiling_page( + 3200, + 20, + "/Pattern CS /P1 SCN 1 0 1 0.5 0 0 cm 20 w 0 j 2783.936 -366.118 m 3033.6 -53.6 l 2721.082 -303.264 l S", + "3100", + "1 0 0 1 0 0", + ); + // The tip narrows to a point, so its pixels are partly covered. + let under_tip = (3100..3108).flat_map(|x| (10..20).map(move |y| (x, y))).map(|(x, y)| at(&skewed, 3200, x, y)); + assert!( + under_tip.clone().any(|px| px[0] < 200 && px[1] < 200 && px[2] > 240), + "the next cell, under the skewed miter's tip: {:?}", + under_tip.collect::>() + ); +} + +/// 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 { + 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 +} + +/// [`jpx_midpoint_pdf`]'s codestream (every sample 128) wrapped in a JP2 file whose header +/// has a colour specification naming enumerated colour space `enumerated`. +fn jp2_midpoint_pdf(components: u16, enumerated: u32) -> Vec { + let raw = jpx_midpoint_pdf(8, components); + let start = raw.windows(4).position(|w| w == [0xff, 0x4f, 0xff, 0x51]).unwrap(); + let end = raw.windows(2).rposition(|w| w == [0xff, 0xd9]).unwrap() + 2; + let codestream = &raw[start..end]; + let jp2_box = |kind: &[u8], body: &[u8]| [&((8 + body.len()) as u32).to_be_bytes()[..], kind, body].concat(); + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&1_u32.to_be_bytes()); // height + ihdr.extend_from_slice(&1_u32.to_be_bytes()); // width + ihdr.extend_from_slice(&components.to_be_bytes()); + ihdr.extend_from_slice(&[7, 7, 0, 0]); // 8 bits, wavelet, colour known, no IPR + let colr = [&[1, 0, 0][..], &enumerated.to_be_bytes()].concat(); + let jp2 = [ + vec![0, 0, 0, 12, b'j', b'P', b' ', b' ', 0x0d, 0x0a, 0x87, 0x0a], + jp2_box(b"ftyp", b"jp2 \0\0\0\0jp2 "), + jp2_box(b"jp2h", &[jp2_box(b"ihdr", &ihdr), jp2_box(b"colr", &colr)].concat()), + jp2_box(b"jp2c", codestream), + ] + .concat(); + let content = "q 20 0 0 20 5 5 cm /Im1 Do Q"; + 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 8 /Filter /JPXDecode /Length {} >> stream\n", + content.len(), + jp2.len() + ) + .into_bytes(); + pdf.extend_from_slice(&jp2); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF"); + pdf +} + +/// Review on #3184: a palette image whose indices are 1-bit samples (`ihdr` BPC 0, `Ssiz` 0) +/// and whose palette holds 8-bit colours. Its colours are decoded to 8 bits; rescaling them +/// to the indices' 1-bit precision kept only 0 or 255 per channel. Contributor-original: +/// one 1×1 tile with an empty packet, so its one sample is the 1-bit midpoint (index 1). +#[test] +fn a_jp2_palette_keeps_its_colours_whatever_the_index_precision() { + let mut codestream = vec![0xff, 0x4f, 0xff, 0x51, 0, 41, 0, 0]; // SOC, SIZ (Lsiz 41), 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(&[0, 1, 0, 1, 1]); // One component: 1 bit, no subsampling. + 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, 0, 0, 0, 15, 0, 1, 0xff, 0x93, 0]); + codestream.extend_from_slice(&[0xff, 0xd9]); + let jp2_box = |kind: &[u8], body: &[u8]| [&((8 + body.len()) as u32).to_be_bytes()[..], kind, body].concat(); + let ihdr = [&1_u32.to_be_bytes()[..], &1_u32.to_be_bytes(), &[0, 1, 0, 7, 0, 0]].concat(); + let colr = [&[1, 0, 0][..], &16_u32.to_be_bytes()].concat(); // sRGB + // pclr: two entries, three 8-bit columns: red, then (64, 128, 192). + let pclr = [0, 2, 3, 7, 7, 7, 255, 0, 0, 64, 128, 192]; + // cmap: channels 0..2 come from component 0 through palette columns 0..2. + let cmap = [0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 1, 2]; + let jp2 = [ + vec![0, 0, 0, 12, b'j', b'P', b' ', b' ', 0x0d, 0x0a, 0x87, 0x0a], + jp2_box(b"ftyp", b"jp2 \0\0\0\0jp2 "), + jp2_box(b"jp2h", &[jp2_box(b"ihdr", &ihdr), jp2_box(b"colr", &colr), jp2_box(b"pclr", &pclr), jp2_box(b"cmap", &cmap)].concat()), + jp2_box(b"jp2c", &codestream), + ] + .concat(); + let content = "q 20 0 0 20 5 5 cm /Im1 Do Q"; + // No /ColorSpace (and no /BitsPerComponent): the JP2 header gives the colours, through its + // palette (ISO 32000-2 §7.4.9). With an /Indexed space the image holds raw indices at + // their own precision, so index 1 picks the space's entry 1, not entry 255. + let lut = format!("FF00004080C0{}00FF00", "000000".repeat(253)); + for colour_space in [String::new(), format!("/ColorSpace [/Indexed /DeviceRGB 255 <{lut}>] ")] { + 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 {colour_space}/Filter /JPXDecode /Length {} >> stream\n", + content.len(), + jp2.len() + ) + .into_bytes(); + pdf.extend_from_slice(&jp2); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%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, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + let px = &page.rgba[((40 - 15) * 40 + 15) * 4..][..3]; + assert!(px.iter().zip([64u8, 128, 192]).all(|(a, b)| a.abs_diff(b) <= 2), "{colour_space:.30}: entry 1: {px:?}"); + } +} + +/// #3306: a JP2 image whose colour specification names an enumerated colour space the +/// decoder refuses (CIEJab in a veraPDF fixture, or YPbPr, or bilevel for one component) is +/// drawn as if it had none, by its component count, as MuPDF and Poppler draw it: the +/// midpoint image shows mid-grey instead of nothing. A space the decoder knows (sRGB) is +/// drawn as before. +#[test] +fn a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn() { + for (components, enumerated) in [(3, 19), (3, 22), (1, 15), (3, 16)] { + let mut r = PageRenderer::new(Arc::new(jp2_midpoint_pdf(components, enumerated)), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{enumerated}: {:?}", page.error); + let px = &page.rgba[((40 - 15) * 40 + 15) * 4..][..3]; + assert!(px.iter().all(|&v| (120..=136).contains(&v)), "enum {enumerated}, {components} component(s): {px:?}"); + } +} + +#[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, ..Default::default() }); + 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, ..Default::default() })); + }); + 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); +} + +/// Scanners and fax services write Group 4 pages with `/Rows 0`, which ISO 32000-2 Table 11 +/// defines as "height not predetermined" (the default), not as zero rows. Such a page used to +/// render blank. The image is an 8 × 4 all-black Group 4 stream, hand-encoded from ITU-T T.6: +/// one horizontal-mode row (white 0, black 8), three V0 rows, then EOFB. +#[test] +fn group4_fax_with_rows_zero_decodes_to_the_image_height() { + const ALL_BLACK_8X4: [u8; 6] = [0x26, 0xa2, 0xfe, 0x00, 0x20, 0x02]; + for rows in ["", "/Rows 4", "/Rows 0"] { + let content = "q 20 0 0 20 5 5 cm /Im1 Do Q"; + let mut 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 << /Type /XObject /Subtype /Image /Width 8 /Height 4 /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter /CCITTFaxDecode /DecodeParms << /K -1 /Columns 8 {rows} >> /Length {} >> stream\n", + content.len(), + ALL_BLACK_8X4.len() + ) + .into_bytes(); + pdf.extend_from_slice(&ALL_BLACK_8X4); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF"); + let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{rows:?}: {:?}", page.error); + assert_eq!(&page.rgba[((24 * 40 + 15) * 4)..][..4], &[0, 0, 0, 255], "{rows:?}: the fax image paints black"); + assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 255, 255, 255], "{rows:?}: outside the image stays white"); + } +} + +/// #3308: a fax image whose `/Rows` says fewer rows than its `/Height` (a plotter driver wrote +/// `/Rows` equal to `/Columns`) is decoded to its height: only the first `/Rows` rows were +/// decoded and the rest drawn black, as MuPDF and Acrobat don't. `/BlackIs1` turns the fixture +/// white, so rows left undecoded show as black. Also through an `/Indexed` colour space, the +/// filter's other output path. +#[test] +fn group4_fax_with_rows_below_its_height_decodes_to_the_image_height() { + const ALL_BLACK_8X4: [u8; 6] = [0x26, 0xa2, 0xfe, 0x00, 0x20, 0x02]; + for (space, rows) in [("/DeviceGray", "/Rows 2"), ("[/Indexed /DeviceRGB 1 <000000FFFFFF>]", "/Rows 2"), ("/DeviceGray", "/Rows 4")] { + let content = "q 20 0 0 20 5 5 cm /Im1 Do Q"; + let mut 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 << /Type /XObject /Subtype /Image /Width 8 /Height 4 /ColorSpace {space} /BitsPerComponent 1 /Filter /CCITTFaxDecode /DecodeParms << /K -1 /Columns 8 {rows} /BlackIs1 true >> /Length {} >> stream +", + content.len(), + ALL_BLACK_8X4.len() + ) + .into_bytes(); + pdf.extend_from_slice(&ALL_BLACK_8X4); + pdf.extend_from_slice( + b" +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + ); + let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{space} {rows}: {:?}", page.error); + for y in [17, 22, 27, 32] { + assert_eq!(&page.rgba[((y * 40 + 15) * 4)..][..4], &[255, 255, 255, 255], "{space} {rows}: image row at y {y} is decoded"); + } + } +} + +#[test] +fn tiff_predictor_packed_rgb_rows_keep_their_byte_boundaries() { + use hayro_syntax::object::{FromBytes, Object}; + use std::io::Write; + + // Red then green, with only the real sample bits compared: row padding may change. + // The three-column case also checks horizontal differencing: red/green/blue and + // green/red/blue, whose one-bit component differences wrap modulo two. + struct RowCase { + bpc: u8, + columns: u32, + encoded: &'static [u8], + expected: &'static [u8], + masks: &'static [u8], + } + let cases = [ + RowCase { bpc: 1, columns: 1, encoded: &[0x80, 0x40], expected: &[0x80, 0x40], masks: &[0xe0] }, + RowCase { bpc: 2, columns: 1, encoded: &[0xc0, 0x30], expected: &[0xc0, 0x30], masks: &[0xfc] }, + RowCase { bpc: 4, columns: 1, encoded: &[0xf0, 0, 0x0f, 0], expected: &[0xf0, 0, 0x0f, 0], masks: &[0xff, 0xf0] }, + RowCase { bpc: 1, columns: 3, encoded: &[0x99, 0x80, 0x5a, 0x80], expected: &[0x88, 0x80, 0x50, 0x80], masks: &[0xff, 0x80] }, + RowCase { bpc: 8, columns: 1, encoded: &[255, 0, 0, 0, 255, 0], expected: &[255, 0, 0, 0, 255, 0], masks: &[0xff, 0xff, 0xff] }, + ]; + for RowCase { bpc, columns, encoded, expected, masks } in cases { + let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(encoded).unwrap(); + let compressed = encoder.finish().unwrap(); + let mut bytes = format!( + "<< /Filter /FlateDecode /DecodeParms << /Predictor 2 /Colors 3 /BitsPerComponent {bpc} /Columns {columns} >> /Length {} >> stream\n", + compressed.len() + ) + .into_bytes(); + bytes.extend_from_slice(&compressed); + bytes.extend_from_slice(b"\nendstream"); + let Object::Stream(stream) = Object::from_bytes(&bytes).unwrap() else { panic!("stream expected") }; + let decoded = stream.decoded().unwrap(); + assert_eq!(decoded.len(), expected.len()); + for ((actual, expected), mask) in decoded.iter().zip(expected).zip(masks.iter().cycle()) { + assert_eq!(actual & mask, *expected, "{bpc}-bit RGB, {columns} columns"); + } + } +} + +#[test] +fn tiff_predictor_packed_rgb_image_renders_every_row() { + use std::io::Write; + + let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(&[0x80, 0x40]).unwrap(); + let compressed = encoder.finish().unwrap(); + let content = "q 40 0 0 40 0 0 cm /Im1 Do Q"; + 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 2 /ColorSpace /DeviceRGB /BitsPerComponent 1 /Filter /FlateDecode /DecodeParms << /Predictor 2 /Colors 3 /BitsPerComponent 1 /Columns 1 >> /Length {} >> stream\n", + content.len(), + compressed.len() + ) + .into_bytes(); + pdf.extend_from_slice(&compressed); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + 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), (40, 40)); + let pixel = |y: usize| &page.rgba[((y * 40 + 20) * 4)..][..4]; + assert_eq!(pixel(10), &[255, 0, 0, 255]); + assert_eq!(pixel(30), &[0, 255, 0, 255]); +} + +/// 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, ..Default::default() })); + }); + 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, ..Default::default() })); + }); + 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, ..Default::default() })); + }); + 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"); +} + +/// `depth` direct arrays (`array`), dictionaries with a key (`dict`) or both in turn (`mixed`) +/// around `null`. +fn nested(depth: usize, kind: &str) -> String { + let is_dict = |level| kind == "dict" || (kind == "mixed" && level % 2 == 0); + let mut value = String::new(); + for level in 0..depth { + value.push_str(if is_dict(level) { "<< /N " } else { "[" }); + } + value.push_str("null"); + for level in (0..depth).rev() { + value.push_str(if is_dict(level) { " >>" } else { "]" }); + } + value +} + +/// `depth` dictionaries nested without keys (`<< << >> >>`), closed or not. +fn keyless(depth: usize, closed: bool) -> String { + let mut value = "<< ".repeat(depth); + if closed { + value.push_str(&">> ".repeat(depth)); + } + value +} + +/// A 40 × 40 page that runs `content`, with a correct xref table and `trailer` at the end +/// of a trailer that also points at an `/Info` title. +fn nesting_pdf(trailer: &str, content: &str) -> Vec { + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = vec![0]; + for (index, body) in [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << >> >>".to_string(), + format!("<< /Length {} >> stream\n{content}\nendstream", content.len()), + "<< /Title (Nested) >>".to_string(), + ] + .iter() + .enumerate() + { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", index + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n"); + for offset in &offsets[1..] { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer << /Size 6 /Root 1 0 R /Info 5 0 R {trailer} >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + bytes +} + +/// Opens `bytes` and renders its one page, which must render without an error. +fn nesting_render(bytes: Vec) -> RenderedPage { + let parsed = Pdf::new(bytes.clone()).expect("xref repair can recover the catalog when the trailer is malformed"); + assert_eq!(parsed.pages().len(), 1); + let mut renderer = PageRenderer::new(Arc::new(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), (40, 40)); + page +} + +/// Direct arrays and dictionaries used to recurse without a bound while skipping even +/// unused trailer values and content operands. Exercise the vendored patch in workspace CI. +#[test] +fn deeply_nested_direct_objects_do_not_overflow_the_stack() { + use hayro_syntax::object::Object; + use hayro_syntax::reader::{Reader, ReaderExt}; + let pdf = |unused: &str, content: &str| nesting_pdf(&format!("/Unused {unused}"), content); + let paint = "1 0 0 rg 0 0 4 4 re f"; + for kind in ["array", "dict", "mixed"] { + // Run the crash reproducer first, so the unpatched red state is a stack overflow. + let page = nesting_render(pdf(&nested(100_000, kind), paint)); + assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "{kind}: repaired trailer"); + for depth in [20, 128, 129] { + let value = nested(depth, kind); + let siblings = format!("{value} [] []"); + let mut reader = Reader::new(siblings.as_bytes()); + assert_eq!(reader.skip::>(false).is_some(), depth <= 128, "{kind}: depth {depth}"); + if depth > 128 { + assert_eq!(reader.offset(), 0, "failed skips restore the offset"); + reader.jump(value.len()); + } + // A failed skip must unwind its depth; successful siblings must not accumulate it. + for _ in 0..2 { + reader.skip_white_spaces(); + assert!(reader.skip::>(false).is_some()); + } + let page = nesting_render(pdf(&value, paint)); + assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "{kind}: depth {depth}"); + } + // Many siblings exercise the offset constructor and ensure depth is per nesting. + let siblings = "[] ".repeat(256); + let mut reader = Reader::new_with(siblings.as_bytes(), 0); + for _ in 0..256 { + assert!(reader.skip::>(false).is_some()); + reader.skip_white_spaces(); + } + } + // Content-stream arrays take the Object path, rather than MaybeRef. + for depth in [100_000, 20, 128, 129] { + let value = nested(depth, "array"); + let mut reader = Reader::new(value.as_bytes()); + assert_eq!(reader.skip::>(true).is_some(), depth <= 128, "content: depth {depth}"); + let content = format!("{value} discard {paint}"); + let page = nesting_render(pdf("null", &content)); + if depth <= 128 { + assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "content: depth {depth}"); + } + } +} + +/// A dictionary with an object where a key belongs is repaired by reading that object, not +/// skipping it, and that read recursed without a bound when the object was another such +/// dictionary: `<<` 100,000 times aborted the process. Here as a content-stream operand. +#[test] +fn dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack() { + for closed in [true, false] { + let content = format!("1 0 0 rg 0 0 4 4 re f {} discard", keyless(100_000, closed)); + let page = nesting_render(nesting_pdf("", &content)); + assert_eq!(px40(&page, 1, 38), &[255, 0, 0, 255], "closed: {closed}"); + } +} + +/// The same, where a key of the trailer belongs: the xref table can't be used, and repair +/// finds the catalog. +#[test] +fn dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack() { + for closed in [true, false] { + let page = nesting_render(nesting_pdf(&keyless(100_000, closed), "1 0 0 rg 0 0 4 4 re f")); + assert_eq!(px40(&page, 1, 38), &[255, 0, 0, 255], "closed: {closed}"); + } +} + +/// The same as the properties of marked content (`/Tag <<<<<< … BDC`). +#[test] +fn dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack() { + for closed in [true, false] { + let properties = if closed { "<<".repeat(100_000) + &">>".repeat(100_000) } else { "<<".repeat(100_000) }; + let content = format!("1 0 0 rg 0 0 4 4 re f /Tag {properties} BDC 0 0 1 rg 36 36 4 4 re f EMC"); + let page = nesting_render(nesting_pdf("", &content)); + assert_eq!(px40(&page, 1, 38), &[255, 0, 0, 255], "closed: {closed}"); + } +} + +/// Through the same three paths, dictionaries without keys are read up to the nesting cap +/// (128 dictionaries in all) and refused one past it. +#[test] +fn dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap() { + use hayro_syntax::object::{FromBytes, Object}; + use hayro_syntax::reader::{Reader, ReaderExt}; + let (red, blue, white) = ([255, 0, 0, 255], [0, 0, 255, 255], [255, 255, 255, 255]); + for depth in [128, 129] { + let within = depth <= 128; + let value = keyless(depth, true); + assert_eq!(Object::from_bytes(value.as_bytes()).is_some(), within, "object: {depth}"); + assert_eq!(Reader::new(value.as_bytes()).read_without_context::>().is_some(), within, "operand: {depth}"); + // Content reading stops at an operand it can't read, so the blue square is drawn + // only when the dictionaries were read. + let properties = "<<".repeat(depth) + &">>".repeat(depth); + for (path, operand) in [("operand", format!("{value} discard")), ("BDC", format!("/Tag {properties} BDC"))] { + let content = format!("1 0 0 rg 0 0 4 4 re f {operand} 0 0 1 rg 36 36 4 4 re f"); + let page = nesting_render(nesting_pdf("", &content)); + assert_eq!(px40(&page, 1, 38), &red, "{path}: {depth}"); + assert_eq!(px40(&page, 38, 1), if within { &blue } else { &white }, "{path}: {depth}"); + } + // The trailer is the outermost dictionary. Within the cap it is read (with its + // /Info); past it, repair recovers the catalog but not the trailer. + let bytes = nesting_pdf(&keyless(depth - 1, true), "1 0 0 rg 0 0 4 4 re f"); + let parsed = Pdf::new(bytes.clone()).expect("the trailer or repair finds the catalog"); + assert_eq!(parsed.metadata().title, within.then(|| b"Nested".to_vec()), "trailer: {depth}"); + assert_eq!(px40(&nesting_render(bytes), 1, 38), &red, "trailer: {depth}"); + } +} + +/// The cap must also fit small stacks: skip, read, iterate down with the array iterators and +/// format (`{:?}`) the deepest nesting it admits, of arrays, dictionaries, both and +/// dictionaries without keys, on a 512 KiB thread in a debug build. Measured when this was +/// written (macOS arm64, dev profile): at most about 100 KiB, for `{:?}` of 128 dictionaries. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn nesting_at_the_cap_fits_a_512_kib_stack() { + use hayro_syntax::object::{FromBytes, Object}; + use hayro_syntax::reader::{Reader, ReaderExt}; + let worker = std::thread::Builder::new().stack_size(512 * 1024).spawn(|| { + // (kind, arrays and dictionaries `{:?}` shows, levels walked down to the end) + for (kind, arrays, dicts, levels) in [("array", 128, 0, 129), ("dict", 0, 128, 129), ("mixed", 64, 64, 129), ("keyless", 0, 1, 1)] { + let value = if kind == "keyless" { keyless(128, true) } else { nested(128, kind) }; + assert!(Reader::new(value.as_bytes()).skip::>(false).is_some(), "{kind}: skip"); + assert!(Reader::new(value.as_bytes()).skip::>(true).is_some(), "{kind}: skip in content"); + assert!(Reader::new(value.as_bytes()).read_without_context::>().is_some(), "{kind}: operand"); + let object = Object::from_bytes(value.as_bytes()).expect("the cap admits 128 levels"); + let shown = format!("{object:?}"); + assert_eq!((shown.matches("Array(").count(), shown.matches("Dict(").count()), (arrays, dicts), "{kind}: {{:?}}"); + let (mut walked, mut next) = (0, Some(object)); + while let Some(object) = next.take() { + walked += 1; + next = match object { + Object::Array(array) => { + assert_eq!(array.raw_iter().count(), 1, "{kind}: raw_iter"); + assert!(array.flex_iter().next::>().is_some(), "{kind}: flex_iter"); + array.iter::>().next() + } + Object::Dict(dict) => dict.get::>("N"), + _ => None, + }; + } + assert_eq!(walked, levels, "{kind}: iterate"); + } + }); + // A stack overflow aborts the process; a failed check is a panic, shown as it is. + if let Err(panic) = worker.expect("spawn a 512 KiB thread").join() { + std::panic::resume_unwind(panic); + } +} + +/// 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 = 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, ®ion); + 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, ..Default::default() })); + }); + 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 = (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, ..Default::default() })); + }); + 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"); + } +} + +/// Item 1 of storytold/pdfcraft#623: a page's content used to be cut at `MAX_DECODED_STREAM`, +/// so everything after the cut was lost (the text and the title block of the A1 sample). Content +/// that expands by a few times over is read to its end, however large it is. +#[test] +fn page_content_past_the_old_cap_renders() { + let limit = hayro::hayro_syntax::MAX_DECODED_STREAM; + // Blocks of a comment and a run of blanks. Each compresses about thirty times over, well + // within the budget that `ContentReader` allows. + let mut content = Vec::with_capacity(limit + (8 << 20)); + let mut state = 0x2545_f491_4f6c_dd1d_u64; + while content.len() < limit + (8 << 20) { + content.push(b'%'); + for _ in 0..3 { + state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1); + content.push(b'a' + ((state >> 33) % 26) as u8); + } + content.push(b'\n'); + content.resize(content.len() + 128, b' '); + } + content.extend_from_slice(b"1 0 0 rg 0 0 4 4 re f"); + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut z, &content).unwrap(); + let stream = z.finish().unwrap(); + assert!(content.len() < 64 * stream.len(), "the content expands by less than the budget allows"); + + 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 >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Filter /FlateDecode /Length {} >> stream\n", stream.len()).as_bytes()); + pdf.extend_from_slice(&stream); + 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, preview: false })); + }); + let page = rx.recv_timeout(std::time::Duration::from_secs(120)).expect("a large page must render"); + assert!(page.error.is_none(), "{:?}", page.error); + assert!(!page.warnings.contains(&RenderWarning::ContentTruncated), "nothing was cut"); + assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the square after the old cap draws"); +} + +/// The content stream is interpreted a window at a time. An inline image with more data than +/// a window is read whole, and what follows it is still drawn. +#[test] +fn inline_images_longer_than_a_window_are_read_whole() { + let mut content = b"q 300 0 0 300 0 0 cm BI /W 300 /H 300 /BPC 8 /CS /G ID ".to_vec(); + content.extend(std::iter::repeat_n(0x80_u8, 300 * 300)); + content.extend_from_slice(b" EI Q 1 0 0 rg 0 0 4 4 re f"); + let pdf = one_page_pdf(&content, ""); + 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, preview: false })); + }); + let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("an inline image must not stall the renderer"); + assert!(page.error.is_none(), "{:?}", page.error); + let pixel = |x: u32, y: u32| { + let offset = ((y * page.width + x) * 4) as usize; + [page.rgba[offset], page.rgba[offset + 1], page.rgba[offset + 2], page.rgba[offset + 3]] + }; + assert_eq!(pixel(1, 38), [255, 0, 0, 255], "the square after the image draws"); + let grey = pixel(30, 5); + assert!(grey[0] == grey[1] && grey[1] == grey[2] && grey[0] < 255, "the image covers the page: {grey:?}"); +} + +/// A five-point open polyline is not a rectangle. `path_as_rect` took any five-element path +/// whose points covered the four corners of its bounding box for that box, and a `MoveTo` with +/// four `LineTo` is five elements, so an open zigzag stroked with `S` was painted as its +/// bounding rectangle: the peak between its ends vanished and the open side came out closed. +#[test] +fn a_five_point_polyline_is_not_painted_as_its_bounding_box() { + // The ends and the points beside them sit on the four corners of the bounding box; the + // peak at (20, 20) lies between them and is on no corner. + let content = b"0 0 0 RG 2 w 30 30 m 30 10 l 20 20 l 10 10 l 10 30 l S".to_vec(); + let pdf = one_page_pdf(&content, ""); + 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, preview: false })); + }); + let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("the polyline must render"); + assert!(page.error.is_none(), "{:?}", page.error); + // Device y counts down the 40 pt page, so the two ends at y = 30 are on row 10. + let dark = |x: u32, y: u32| { + let offset = ((y * page.width + x) * 4) as usize; + page.rgba[offset] < 128 + }; + assert!((17..=23).any(|x| (17..=23).any(|y| dark(x, y))), "the peak between the ends draws"); + assert!(!(14..=26).any(|x| dark(x, 10)), "the side the path leaves open stays open"); +} + +/// Five points all on the corners still need not trace the rectangle: three sides and a step +/// back along the third is such a path, and it was painted as the box -- drawing a fourth +/// side the path does not have (FredApps on #3327). +#[test] +fn a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle() { + // Bottom, right, top, then back along the top. The left side is never drawn. + let content = b"0 0 0 RG 2 w 10 10 m 30 10 l 30 30 l 10 30 l 30 30 l S".to_vec(); + let pdf = one_page_pdf(&content, ""); + 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, preview: false })); + }); + let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("the path must render"); + assert!(page.error.is_none(), "{:?}", page.error); + let dark = |x: u32, y: u32| { + let offset = ((y * page.width + x) * 4) as usize; + page.rgba[offset] < 128 + }; + // Device y counts down the 40 pt page: the bottom side at y = 10 is row 30. + assert!((14..=26).any(|x| dark(x, 30)), "the sides the path does draw are drawn"); + assert!(!(14..=26).any(|y| dark(10, y)), "the left side it never draws is not drawn"); +} + +/// A rectangle still takes the rectangle route, written either way: `m l l l` and closed, or +/// `m l l l l` back to its first corner. +#[test] +fn a_rectangle_is_still_painted_as_a_rectangle() { + for content in [&b"0 0 0 rg 10 10 m 30 10 l 30 30 l 10 30 l f"[..], &b"0 0 0 rg 10 10 m 30 10 l 30 30 l 10 30 l 10 10 l f"[..]] { + let pdf = one_page_pdf(content, ""); + 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, preview: false })); + }); + let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("the rectangle must render"); + assert!(page.error.is_none(), "{:?}", page.error); + let pixel = |x: u32, y: u32| { + let offset = ((y * page.width + x) * 4) as usize; + page.rgba[offset] + }; + assert!(pixel(20, 20) < 128, "the rectangle is filled"); + assert_eq!(pixel(5, 5), 255, "and nothing outside it is"); + } +} + +/// A one-page PDF whose content is the stream `stream`, with the entries `dict` in its +/// dictionary. +fn one_page_pdf(stream: &[u8], dict: &str) -> Vec { + 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 >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << {dict} /Length {} >> stream\n", stream.len()).as_bytes()); + pdf.extend_from_slice(stream); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + pdf +} + +/// A document whose `pages` page dictionaries all live in one (uncompressed) object stream, +/// found through a cross-reference stream: every page shows the content `0 0 1 rg 10 10 80 80 +/// re f` on a 100 pt square, except the last, which is 50 × 100 pt. +fn pages_in_one_object_stream(pages: usize) -> Vec { + // 1 catalog, 2 page tree, 3 content, 4 the object stream, 5.. the pages, then the xref. + let page = |n: usize| { + let size = if n + 1 == pages { "50 100" } else { "100 100" }; + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {size}] /Contents 3 0 R >>\n") + }; + let (mut index, mut body) = (String::new(), String::new()); + for n in 0..pages { + index.push_str(&format!("{} {} ", n + 5, body.len())); + body.push_str(&page(n)); + } + index.push('\n'); + let kids: Vec = (0..pages).map(|n| format!("{} 0 R", n + 5)).collect(); + let content = "0 0 1 rg 10 10 80 80 re f"; + let mut pdf = b"%PDF-1.5\n".to_vec(); + let mut offsets = Vec::new(); + for (num, obj) in [ + (1, "<< /Type /Catalog /Pages 2 0 R >>".to_string()), + (2, format!("<< /Type /Pages /Kids [{}] /Count {pages} >>", kids.join(" "))), + (3, format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())), + ( + 4, + format!( + "<< /Type /ObjStm /N {pages} /First {} /Length {} >>\nstream\n{index}{body}\nendstream", + index.len(), + index.len() + body.len() + ), + ), + ] { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{num} 0 obj\n{obj}\nendobj\n").as_bytes()); + } + // Cross-reference stream rows of /W [1 4 4]: free 0, objects 1–4 in the file, the pages + // at their index in object 4, and the cross-reference stream itself. + let xref_num = pages + 5; + let xref_at = pdf.len(); + let mut rows = vec![0u8, 0, 0, 0, 0, 0, 0, 0xff, 0xff]; + let row = |kind: u8, a: usize, b: usize| [[kind].as_slice(), &(a as u32).to_be_bytes(), &(b as u32).to_be_bytes()].concat(); + for at in &offsets { + rows.extend(row(1, *at, 0)); + } + for n in 0..pages { + rows.extend(row(2, 4, n)); + } + rows.extend(row(1, xref_at, 0)); + pdf.extend_from_slice( + format!("{xref_num} 0 obj\n<< /Type /XRef /Size {} /W [1 4 4] /Root 1 0 R /Length {} >>\nstream\n", xref_num + 1, rows.len()).as_bytes(), + ); + pdf.extend_from_slice(&rows); + pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref_at}\n%%EOF\n").as_bytes()); + pdf +} + +/// An object stream's index of `objnum offset` pairs is read once, not for every object +/// fetched from it (vendored hayro-syntax patch 22). Read again each time, all of a stream of +/// N objects took N² steps: a Firefox page keeping 65,542 objects in one stream took 37 s to +/// draw and 40 s to extract its text. These 40,000 pages in one stream took about a minute +/// to open that way; now a fraction of a second. The last page, at the end of the index, +/// still has its own size. +#[test] +fn objects_in_one_large_object_stream_are_read_in_linear_time() { + const PAGES: usize = 40_000; + let pdf = pages_in_one_object_stream(PAGES); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let first = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + let last = r.render(RenderRequest { page: PAGES - 1, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + let _ = tx.send((first, last)); + }); + let (first, last) = + rx.recv_timeout(std::time::Duration::from_secs(20)).expect("reading every object of one object stream must not take quadratic time"); + assert!(first.error.is_none(), "{:?}", first.error); + assert_eq!((first.width, first.height), (100, 100)); + assert_eq!(rgb_at(&first.rgba, first.width as usize, 50, 50), [0, 0, 255]); + assert!(last.error.is_none(), "{:?}", last.error); + assert_eq!((last.width, last.height), (50, 100), "the last page is read from its own offset"); +} + +/// #2946: ScanSoft PDF Create! writes the `post` table version of every font it embeds with +/// its 16-bit halves swapped (`00 00 00 03` for 3.0). Such a font must still be used, drawing +/// exactly as with a correct version; a version that is neither (9.0) is still refused and +/// drawn with a substitute, as before. +#[test] +fn a_font_whose_post_version_has_swapped_halves_is_still_used() { + let font = include_bytes!("../../../../assets/fonts/JetBrainsMono-Regular.ttf"); + // The `post` table's offset, from the table directory. + let tables = usize::from(u16::from_be_bytes([font[4], font[5]])); + let post = (0..tables) + .map(|i| &font[12 + 16 * i..28 + 16 * i]) + .find(|e| &e[..4] == b"post") + .map(|e| u32::from_be_bytes([e[8], e[9], e[10], e[11]]) as usize) + .expect("the test font has a post table"); + let render_with = |version: [u8; 4], encoding: &str| -> Vec { + let mut program = font.to_vec(); + program[post..post + 4].copy_from_slice(&version); + let content = b"BT /F1 28 Tf 10 40 Td (Hamburg 0123) Tj ET"; + let widths = vec!["600"; 95].join(" "); + let objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 80] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", content.len()).into_bytes(), content.to_vec(), b"\nendstream".to_vec()].concat(), + format!("<< /Type /Font /Subtype /TrueType /BaseFont /JetBrainsMono-Regular /FirstChar 32 /LastChar 126 /Widths [{widths}] /FontDescriptor 6 0 R /Encoding {encoding} >>").into_bytes(), + b"<< /Type /FontDescriptor /FontName /JetBrainsMono-Regular /Flags 32 /FontBBox [-1000 -300 1000 1000] /ItalicAngle 0 /Ascent 1020 /Descent -300 /CapHeight 730 /StemV 80 /FontFile2 7 0 R >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", program.len()).into_bytes(), program, b"\nendstream".to_vec()].concat(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + pdf.extend_from_slice(o); + pdf.extend_from_slice(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + let page = PageRenderer::new(Arc::new(pdf), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 2.0, + tag: 0, + preview: false, + }); + assert!(page.error.is_none(), "{:?}", page.error); + page.rgba.to_vec() + }; + let render = |version: [u8; 4]| render_with(version, "/WinAnsiEncoding"); + let correct = render([0, 3, 0, 0]); + assert_eq!(render([0, 0, 0, 3]), correct, "3.0 with swapped halves draws with the embedded font"); + assert_eq!(render([0, 0, 0, 2]), render([0, 2, 0, 0]), "2.0 with swapped halves too"); + assert_ne!(render([0, 9, 0, 0]), correct, "an unknown version is still refused"); + // A /Differences name only the font's own post table knows (W's alternate, `W.cv07`) + // is found through a swapped 2.0 table too (review on #2949). + let alternate = |version: [u8; 4]| render_with(version, "<< /BaseEncoding /WinAnsiEncoding /Differences [72 /W.cv07] >>"); + assert_ne!(alternate([0, 2, 0, 0]), render([0, 2, 0, 0]), "the alternate W draws instead of H"); + assert_eq!(alternate([0, 0, 0, 2]), alternate([0, 2, 0, 0]), "and also with swapped halves"); +} + +/// A page whose content expands far beyond its compressed stream stops at the expansion budget, +/// which is 64 times the compressed size and at least `MAX_DECODED_STREAM`, and the page says so. +#[test] +fn content_bombs_stop_at_the_budget_and_say_so() { + let bomb = zlib_zeros(hayro::hayro_syntax::MAX_DECODED_STREAM + (32 << 20)); + let pdf = one_page_pdf(&bomb, "/Filter /FlateDecode"); + 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, preview: false })); + }); + let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("a content bomb must not stall the renderer"); + assert!(page.error.is_none(), "{:?}", page.error); + assert!(page.warnings.contains(&RenderWarning::ContentTruncated), "the cut content is observable"); +} + +/// A stream read in pieces of any size gives the same bytes: stored and dynamic blocks, and in +/// a `/Contents` array a stream that is not Flate-coded, between two that are. +#[test] +fn content_reader_returns_the_same_bytes_in_any_pieces() { + use hayro::hayro_syntax::Pdf; + use std::io::Write; + let mut plain = Vec::new(); + for i in 0..40_000_u32 { + plain.extend_from_slice(format!("{} {} l\n", i % 977, i % 613).as_bytes()); + } + let zlib = |data: &[u8], level| { + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), level); + z.write_all(data).unwrap(); + z.finish().unwrap() + }; + let read = |pdf: Vec, piece: usize| { + let doc = Pdf::new(pdf).expect("parses"); + let page = doc.pages().first().expect("one page"); + let mut content = page.content_reader(); + let mut out = Vec::new(); + while content.read_into(&mut out, piece) > 0 {} + out + }; + let pieces = [1, 3, 64, 4096, 65_536, 1_000_000]; + + for level in [flate2::Compression::none(), flate2::Compression::best()] { + let pdf = one_page_pdf(&zlib(&plain, level), "/Filter /FlateDecode"); + for piece in pieces { + assert!(read(pdf.clone(), piece) == plain, "piece {piece}"); + } + } + + // A `/Contents` array: Flate, then ASCIIHex (decoded whole), then Flate. As before, each + // stream is followed by a space. + let hex: String = plain[..100].iter().map(|b| format!("{b:02x}")).collect::() + ">"; + let first = zlib(&plain, flate2::Compression::default()); + let last = zlib(&plain[..50], flate2::Compression::default()); + let mut expected = plain.clone(); + expected.push(b' '); + expected.extend_from_slice(&plain[..100]); + expected.push(b' '); + expected.extend_from_slice(&plain[..50]); + expected.push(b' '); + 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 5 0 R 6 0 R] >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Filter /FlateDecode /Length {} >> stream\n", first.len()).as_bytes()); + pdf.extend_from_slice(&first); + pdf.extend_from_slice(format!("\nendstream endobj\n5 0 obj << /Filter /ASCIIHexDecode /Length {} >> stream\n", hex.len()).as_bytes()); + pdf.extend_from_slice(hex.as_bytes()); + pdf.extend_from_slice(format!("\nendstream endobj\n6 0 obj << /Filter /FlateDecode /Length {} >> stream\n", last.len()).as_bytes()); + pdf.extend_from_slice(&last); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + for piece in pieces { + assert!(read(pdf.clone(), piece) == expected, "the array: piece {piece}"); + } +} + +/// Zlib data that inflates to `len` zero bytes. +fn zlib_zeros(len: usize) -> Vec { + 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 { + 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 { + 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, RunLength and ASCII85 +/// (#1796: each `z` is four bytes, here after Flate) 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 mut a85 = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best()); + std::io::Write::write_all(&mut a85, &b"z".repeat(big / 4 + 1)).unwrap(); + let streams: [(&str, Vec); 5] = [ + ("/FlateDecode", zlib_zeros(big)), + ("/FlateDecode", deflate_for_the_fallback(big)), + ("/LZWDecode", lzw_zeros(big / 7_000_000 + 1)), + ("/RunLengthDecode", run_length), + ("[/FlateDecode /ASCII85Decode]", a85.finish().unwrap()), + ]; + 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> = doc.objects().into_iter().filter_map(|o| if let Object::Stream(s) = o { Some(s) } else { None }).collect(); + assert_eq!(found.len(), 5, "all five 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(¶ms).expect("decodes as an image"); + assert!(image.data.len() <= 128, "{what}: an 8 × 8 image decodes at most 128 bytes, not {}", image.data.len()); + } +} + +#[test] +fn form_cycles_are_bounded_and_finite_nesting_paints() { + let cases = [ + ("self-cycle", "/A Do 0 0 1 rg 0 0 8 8 re f", "0 0 1 rg 0 0 8 8 re f"), + ("two-form-cycle", "/B Do 0 0 1 rg 0 0 8 8 re f", "/A Do 0 0 1 rg 0 0 8 8 re f"), + ("finite-nesting", "/B Do", "0 0 1 rg 0 0 8 8 re f"), + ]; + let page_content = "/A Do 1 0 0 rg 32 32 8 8 re f"; + for (case, form_a, form_b) in cases { + let 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 /MediaBox [0 0 40 40] >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /A 5 0 R /B 6 0 R >> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{page_content}\nendstream endobj\n\ + 5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length {} >> stream\n{form_a}\nendstream endobj\n\ + 6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length {} >> stream\n{form_b}\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + page_content.len(), + form_a.len(), + form_b.len() + ) + .into_bytes(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let _ = tx.send(renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() })); + }); + let page = rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap_or_else(|_| panic!("{case}: render did not finish")); + assert!(page.error.is_none(), "{case}: {:?}", page.error); + assert_eq!((page.width, page.height), (40, 40), "{case}"); + let pixel = |x: u32, y: u32| { + let offset = ((y * page.width + x) * 4) as usize; + [page.rgba[offset], page.rgba[offset + 1], page.rgba[offset + 2], page.rgba[offset + 3]] + }; + assert_eq!(pixel(3, 35), [0, 0, 255, 255], "{case}: nested form content paints"); + assert_eq!(pixel(35, 4), [255, 0, 0, 255], "{case}: page content after the form still paints"); + } +} + +/// 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, ..Default::default() })); + }); + // Only a guard against a hang: the shared budget is checked by the warnings below, which + // come out the same on every machine. The render decodes and reads 512 MiB of content, + // a third of a second on a desktop, and a busy FreeBSD VM in CI ran past the minute this + // used to allow. + let page = rx.recv_timeout(std::time::Duration::from_secs(600)).expect("a self-painting form must not stall the renderer"); + assert!(page.error.is_none(), "{:?}", page.error); + assert!(page.warnings.contains(&RenderWarning::ContentTruncated), "the skipped nested content is observable"); + assert_eq!( + page.warnings.iter().filter(|w| **w == RenderWarning::ContentTruncated).count(), + 1, + "reported once per page, not once per nested paint" + ); + 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, ..Default::default() }); + 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, ..Default::default() })); + }); + 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"); +} + +/// A sampled function kept its samples in a hash map keyed by each point of its grid, built +/// again every time a shading using it was painted (vendored hayro-interpret patch 31). +/// InDesign pages paint 4096-sample gradient ramps dozens of times, and `bug1721218` in the +/// pdf.js corpus spent most of its 1.5 s hashing them. The samples are now one flat array +/// indexed by strides: this page paints a 2,097,152-sample ramp (the most a table may hold) +/// 30 times, which took 38 seconds through the hash map. +/// +/// The build each paint does is timed against reading the function's samples, the part of it +/// no table can skip, not against a deadline: through the hash map it took about a hundred +/// times as long as the read, through the flat array about as long. A deadline can't tell +/// them apart on every machine: the hash map's 30 paints took 14 s on one desktop, the flat +/// array's over 10 s on a busy FreeBSD VM in CI. No counter does either (both read every +/// sample on every build), so the test compares two times taken on this thread, the fastest +/// of several rounds of each: a stall from other work (other tests, swapping) would have to +/// slow every build and spare a read to fail it. +#[test] +fn shadings_with_large_sampled_functions_paint_quickly() { + use hayro::hayro_interpret::Function; + use hayro_syntax::bit_reader::BitReader; + use hayro_syntax::object::{FromBytes, Object}; + use std::time::{Duration, Instant}; + + const SAMPLES: usize = 1 << 21; + let samples = zlib_zeros(SAMPLES); + let function = format!( + "<< /FunctionType 0 /Domain [0 1] /Range [0 1] /Size [{SAMPLES}] /BitsPerSample 8 /Filter /FlateDecode /Length {} >>", + samples.len() + ); + // Black ramp on the left half, 30 times over; a red square after it. + let content = "q 0 0 20 40 re W n /Sh0 sh Q\n".repeat(30) + "1 0 0 rg 36 36 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}\nendstream endobj\n", content.len()).as_bytes()); + pdf.extend_from_slice(b"5 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 {function} stream\n").as_bytes()); + pdf.extend_from_slice(&samples); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + let page = render_first_page(pdf); + assert_eq!(rgb_at(&page.rgba, 40, 5, 20), [0, 0, 0], "the ramp is drawn"); + assert_eq!(rgb_at(&page.rgba, 40, 30, 20), [255, 255, 255], "outside the clip"); + assert_eq!(rgb_at(&page.rgba, 40, 38, 2), [255, 0, 0], "the rest of the page draws"); + + // What each paint does: build the function from its stream. Timed against reading the + // stream's samples, the part of the build no table can skip. + let mut bytes = format!("{function} stream\n").into_bytes(); + bytes.extend_from_slice(&samples); + bytes.extend_from_slice(b"\nendstream"); + let object = Object::from_bytes(&bytes).expect("a stream object"); + let Object::Stream(stream) = &object else { panic!("the function is a stream") }; + let (mut build, mut read) = (Duration::MAX, Duration::MAX); + for _ in 0..8 { + let started = Instant::now(); + let function = Function::new(&object); + assert!(function.is_some(), "a sampled function"); + drop(function); + build = build.min(started.elapsed()); + + let started = Instant::now(); + let decoded = stream.decoded().expect("the samples decode"); + let mut reader = BitReader::new(&decoded); + let mut values = Vec::new(); + while values.len() < SAMPLES + && let Some(value) = reader.read(8) + { + values.push(value); + } + assert_eq!(std::hint::black_box(&values).len(), SAMPLES); + drop(values); + read = read.min(started.elapsed()); + } + assert!( + build < read * 10, + "building a {SAMPLES}-sample function took {build:?}, over ten times the {read:?} reading its samples takes (a hash map took a hundred times)" + ); +} + +/// Patch 31's flat table gives every sampled function the values the hash map gave: points +/// on the grid, multilinear interpolation between them (first input varying fastest), and no +/// value where a sample the interpolation needs is missing from a short stream. +#[test] +fn sampled_functions_interpolate_their_grid_as_before() { + use hayro::hayro_interpret::Function; + use hayro_syntax::object::{FromBytes, Object}; + + // Size [3 2], two outputs: out0 = 40 i + 60 j (linear), out1 = 100 i j (bilinear only). + let mut samples = Vec::new(); + for j in 0..2u8 { + for i in 0..3u8 { + samples.extend_from_slice(&[40 * i + 60 * j, 100 * i * j]); + } + } + let function = |data: &[u8]| { + let mut bytes = + format!("<< /FunctionType 0 /Domain [0 1 0 1] /Range [0 1 0 1] /Size [3 2] /BitsPerSample 8 /Length {} >> stream\n", data.len()) + .into_bytes(); + bytes.extend_from_slice(data); + bytes.extend_from_slice(b"\nendstream"); + bytes + }; + let whole = function(&samples); + let f = Function::new(&Object::from_bytes(&whole).expect("a stream object")).expect("a sampled function"); + for (x, y) in [(0.0, 0.0), (1.0, 1.0), (0.5, 0.0), (0.25, 0.5), (0.75, 0.25), (0.1, 0.9), (1.0, 0.3)] { + let (gi, gj) = (2.0 * x, y); + let got = f.eval([x, y].into_iter().collect()).expect("every sample is present"); + let want = [(40.0 * gi + 60.0 * gj) / 255.0, 100.0 * gi.min(1.0) * gj / 255.0 + 100.0 * (gi - 1.0).max(0.0) * gj / 255.0]; + assert!(got.len() == 2 && got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-5), "({x}, {y}): got {got:?}, want {want:?}"); + } + // Only the j = 0 row: points on it evaluate, points needing j = 1 have no value. + let short = function(&samples[..6]); + let f = Function::new(&Object::from_bytes(&short).expect("a stream object")).expect("a sampled function"); + assert_eq!(f.eval([0.5, 0.0].into_iter().collect()).map(|v| v.to_vec()), Some(vec![40.0 / 255.0, 0.0])); + assert_eq!(f.eval([0.5, 0.5].into_iter().collect()), None); +} + +/// 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| { + 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, ..Default::default() })); + }); + 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, ..Default::default() }); + 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:?}"); +} + +/// Coons and tensor-product patches put each corner's colour at its corner and blend them +/// in between. Patch 33 interpolates each point of a patch's 20 × 20 triangle grid once +/// instead of once per cell touching it, and moves vertices instead of copying them; these +/// colours (taken before that change) pin that every vertex still gets its own point's colour. +#[test] +fn patch_meshes_keep_their_corner_colours() { + // Points on a 4 × 4 grid over the 40 pt page, in stream order: the 12 boundary points + // (both patch types), then a tensor patch's four interior ones. + let grid = |x: u8, y: u8| [x * 85, y * 85]; + let boundary = [(0, 0), (0, 1), (0, 2), (0, 3), (1, 3), (2, 3), (3, 3), (3, 2), (3, 1), (3, 0), (2, 0), (1, 0)]; + let interior = [(1, 1), (1, 2), (2, 2), (2, 1)]; + let colours = [255u8, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 0]; + let patch = |points: &[(u8, u8)]| { + let mut data = vec![0u8]; + data.extend(points.iter().flat_map(|(x, y)| grid(*x, *y))); + data.extend_from_slice(&colours); + data + }; + let render = |shading_type: u8, data: Vec| { + let content = b"/Sh0 sh"; + 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(format!("\nendstream endobj\n5 0 obj << /ShadingType {shading_type} /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 40 0 40 0 1 0 1 0 1] /Length {} >> stream\n", data.len()).as_bytes()); + pdf.extend_from_slice(&data); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + render_first_page(pdf) + }; + let coons = render(6, patch(&boundary)); + let tensor = render(7, patch(&[&boundary[..], &interior[..]].concat())); + // Near each corner (top-left, bottom-left, bottom-right, top-right in the raster), the + // centre, and a point between them. + let want = [ + ((5, 5), [32, 199, 28]), + ((5, 34), [217, 61, 5]), + ((34, 34), [217, 190, 32]), + ((34, 5), [32, 61, 190]), + ((20, 20), [127, 128, 64]), + ((12, 27), [172, 110, 25]), + ]; + for (what, page) in [("Coons", &coons), ("tensor", &tensor)] { + for ((x, y), colour) in want { + let got = rgb_at(&page.rgba, 40, x, y); + assert!(got.iter().zip(colour).all(|(g, w)| g.abs_diff(w) <= 1), "{what} patch at ({x}, {y}): got {got:?}, want {colour:?}"); + } + } +} + +/// 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, ..Default::default() })); + }); + 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:?}"); +} + +/// Two triangles of a mesh that share an edge cover it together. The sampler tested each +/// pixel's corner, and only strictly inside a triangle, so the diagonal two triangles share +/// was left white wherever it ran through the samples, and so was a pixel row or column +/// along the mesh's own sides. Vendored hayro-interpret patch 36. +#[test] +fn mesh_triangles_sharing_an_edge_leave_no_seam() { + // A red square from 10 to 40 on a 50 × 50 page, cut along its diagonal; in device space + // the diagonal runs through pixel centres and corners alike. + let vertex = |flag: u8, x: u8, y: u8| [flag, x, y, 255, 0, 0]; + let (lo, hi) = (10, 40); + let mut triangles = Vec::new(); + for [a, b, c] in [[(lo, lo), (hi, lo), (hi, hi)], [(lo, lo), (hi, hi), (lo, hi)]] { + for (x, y) in [a, b, c] { + triangles.extend(vertex(0, x, y)); + } + } + // Each triangle as its own three vertices, and the second one as a strip (flag 1). + let mut strip = Vec::new(); + for (flag, (x, y)) in [(0, (lo, lo)), (0, (hi, lo)), (0, (hi, hi)), (2, (lo, hi))] { + strip.extend(vertex(flag, x, y)); + } + for (label, data) in [("separate triangles", triangles), ("a strip", strip)] { + let content = b"/Sh0 sh"; + 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 50 50] /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(format!("\nendstream endobj\n5 0 obj << /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Length {} >> stream\n", data.len()).as_bytes()); + pdf.extend_from_slice(&data); + 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, ..Default::default() }); + assert!(page.error.is_none(), "{label}: {:?}", page.error); + assert_eq!((page.width, page.height), (50, 50)); + let (mut white_inside, mut red_outside) = (Vec::new(), Vec::new()); + for y in 0..50_usize { + for x in 0..50_usize { + let p = &page.rgba[(y * 50 + x) * 4..][..4]; + let inside = (10..40).contains(&x) && (10..40).contains(&y); + if inside && p != [255, 0, 0, 255] { + white_inside.push((x, y)); + } else if !inside && p != [255, 255, 255, 255] { + red_outside.push((x, y)); + } + } + } + assert!(white_inside.is_empty(), "{label}: unshaded pixels inside the square: {white_inside:?}"); + assert!(red_outside.is_empty(), "{label}: shaded pixels outside the square: {red_outside:?}"); + } + + // One triangle alone, the square's upper left half: a pixel whose corner lies on its + // diagonal is taken by its centre, so one wholly across the diagonal (x + y ≥ 50 in + // device space) is not shaded, and one wholly inside is. + let mut data = Vec::new(); + for (x, y) in [(lo, lo), (hi, hi), (lo, hi)] { + data.extend(vertex(0, x, y)); + } + let content = b"/Sh0 sh"; + 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 50 50] /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(format!("\nendstream endobj\n5 0 obj << /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Length {} >> stream\n", data.len()).as_bytes()); + pdf.extend_from_slice(&data); + 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, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + let (mut spilled, mut missing) = (Vec::new(), Vec::new()); + for y in 10..40_usize { + for x in 10..40_usize { + let p = &page.rgba[(y * 50 + x) * 4..][..4]; + if x + y >= 50 && p == [255, 0, 0, 255] { + spilled.push((x, y)); + } else if x + y + 2 <= 50 && p != [255, 0, 0, 255] { + missing.push((x, y)); + } + } + } + assert!(spilled.is_empty(), "shaded pixels wholly across the diagonal: {spilled:?}"); + assert!(missing.is_empty(), "unshaded pixels wholly inside the triangle: {missing:?}"); +} + +/// A pixel whose corner is inside one triangle and whose centre is inside its neighbour +/// takes the colour at its corner, whichever triangle comes first (patch 36): the +/// centre-coverage pass only fills pixels no triangle has given a colour yet. Here the +/// shared diagonal lies at x + y = 50.25 in device space, so pixel (20, 30) has its corner +/// in the red triangle and its centre in the blue one. +#[test] +fn a_shared_edge_pixel_keeps_the_colour_at_its_corner() { + let vertex = |x: u8, y: u8, rgb: [u8; 3]| [0, x, y, rgb[0], rgb[1], rgb[2]]; + let (lo, hi) = (10, 40); + let (red, blue) = ([255, 0, 0], [0, 0, 255]); + // In PDF space the red triangle is the square's upper left half, the blue one the lower + // right; `cm` moves both down a quarter point. + let red_triangle = [(lo, lo, red), (hi, hi, red), (lo, hi, red)]; + let blue_triangle = [(lo, lo, blue), (hi, lo, blue), (hi, hi, blue)]; + for (label, order) in [("red first", [red_triangle, blue_triangle]), ("blue first", [blue_triangle, red_triangle])] { + let mut data = Vec::new(); + for triangle in order { + for (x, y, rgb) in triangle { + data.extend(vertex(x, y, rgb)); + } + } + let content = b"1 0 0 1 0 -0.25 cm /Sh0 sh"; + 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 50 50] /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(format!("\nendstream endobj\n5 0 obj << /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Length {} >> stream\n", data.len()).as_bytes()); + pdf.extend_from_slice(&data); + 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, ..Default::default() }); + assert!(page.error.is_none(), "{label}: {:?}", page.error); + let pixel = |x: usize, y: usize| page.rgba[(y * 50 + x) * 4..][..4].to_vec(); + assert_eq!(pixel(20, 30), [255, 0, 0, 255], "{label}: the corner of pixel (20, 30) is in the red triangle"); + // Controls: wholly inside one triangle or the other. + assert_eq!(pixel(15, 20), [255, 0, 0, 255], "{label}"); + assert_eq!(pixel(35, 30), [0, 0, 255, 255], "{label}"); + } +} + +/// 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| { + 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, ..Default::default() })); + }); + 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, ..Default::default() })); + }); + 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, ..Default::default() })); + }); + 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]); +} + +/// Hex-encodes `bytes` for an `/ASCIIHexDecode` stream. +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// An 8×4 source of 2×2 blocks: block column `x / 2` and row `y / 2` takes `levels[(y / 2) * 4 + x / 2]`. +fn block_source(levels: [T; 8]) -> Vec { + (0..32).map(|i| levels[(i / 16) * 4 + (i % 8) / 2]).collect() +} + +/// Draws one `size` image of colour space `space` (`data` as raw samples, with an optional gray +/// `/SMask` of the same size) at 1:1 on a `device`-sized page, writing `/Interpolate` when given. +fn draw_minified_image( + space: &str, + size: (u32, u32), + interpolate: Option, + data: &[u8], + alpha: Option<&[u8]>, + device: (u32, u32), +) -> RenderedPage { + let (width, height) = size; + let interp = interpolate.map_or_else(String::new, |value| format!("/Interpolate {value} ")); + let (smask, mask) = match alpha { + Some(alpha) => ( + "/SMask 6 0 R ", + format!( + "6 0 obj << /Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length {} >> stream\n{}>\nendstream endobj\n", + hex(alpha).len() + 1, + hex(alpha) + ), + ), + None => ("", String::new()), + }; + let image = hex(data); + let content = format!("q {} 0 0 {} 0 0 cm /Im0 Do Q", device.0, device.1); + 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 {} {}] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /{space} /BitsPerComponent 8 {interp}{smask}/Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\ + {mask}trailer << /Root 1 0 R >>\n%%EOF\n", + device.0, + device.1, + content.len(), + image.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), device); + page +} + +/// Issue #624: an image drawn below its source resolution was pre-resampled with Catmull-Rom +/// even when `/Interpolate` was false (or absent, whose PDF default is false). That blends +/// neighboring samples before the renderer's later non-interpolating image sampler sees them, +/// making scans, screenshots and rasterized text look soft. +#[test] +fn non_interpolated_images_are_not_smoothed_when_minified() { + // Top band K W R G, bottom band B Y M C. Nearest 8×4 → 4×2 sampling picks each block's + // pixel, so every device pixel is one palette colour; Catmull-Rom blends across blocks. + let palette: [[u8; 3]; 8] = [[0, 0, 0], [255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [255, 0, 255], [0, 255, 255]]; + let rgb: Vec = block_source(palette).concat(); + let exact: Vec<[u8; 4]> = palette.iter().map(|c| [c[0], c[1], c[2], 255]).collect(); + let pixels = |p: &RenderedPage| p.rgba.as_chunks::<4>().0.to_vec(); + + // `/Interpolate` defaults to false, and explicit false is equivalent. + let omitted = draw_minified_image("DeviceRGB", (8, 4), None, &rgb, None, (4, 2)); + assert_eq!(pixels(&omitted), exact, "default interpolation"); + let disabled = draw_minified_image("DeviceRGB", (8, 4), Some(false), &rgb, None, (4, 2)); + assert_eq!(&omitted.rgba[..], &disabled.rgba[..], "/Interpolate false must match the default"); + + // The fix must not disable interpolation: the same source asking for it blends blocks. + let enabled = draw_minified_image("DeviceRGB", (8, 4), Some(true), &rgb, None, (4, 2)); + assert_ne!(pixels(&enabled), exact, "/Interpolate true should still blend"); + assert!(pixels(&enabled).iter().any(|px| px[..3].iter().any(|&c| (32..=223).contains(&c))), "/Interpolate true made no mid-tone"); +} + +/// A shrunk, interpolated image whose edges fall between device pixels is snapped to whole +/// pixels (shrunk once to that size, drawn 1:1). Before, the shrunk raster was sampled a second +/// time at the fractional offset, so every edge of a scan was a blended, soft pixel. +#[test] +fn interpolated_minified_image_edges_snap_to_whole_pixels() { + let black = vec![0u8; 40 * 40]; + let content = "q 10 0 0 10 0.4 0.4 cm /Im0 Do Q"; + let image = hex(&black); + 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 11 11] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /XObject /Subtype /Image /Width 40 /Height 40 /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate true /Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF\n", + content.len(), + image.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), (11, 11)); + let gray = |x: usize, y: usize| page.rgba[(y * 11 + x) * 4]; + // Device box 0.4..10.4 × 0.6..10.6 snaps to columns 0..10 and rows 1..11. + for y in 1..11 { + for x in 0..10 { + assert_eq!(gray(x, y), 0, "pixel ({x}, {y}) inside the snapped image"); + } + assert_eq!(gray(10, y), 255, "column 10 is outside the snapped image (row {y})"); + } + for x in 0..11 { + assert_eq!(gray(x, 0), 255, "row 0 is outside the snapped image (column {x})"); + } +} + +#[test] +fn non_interpolated_gray_images_are_not_smoothed_when_minified() { + let levels = [0u8, 255, 85, 170, 40, 200, 120, 230]; + let page = draw_minified_image("DeviceGray", (8, 4), None, &block_source(levels), None, (4, 2)); + let expected: Vec<[u8; 4]> = levels.iter().map(|&v| [v, v, v, 255]).collect(); + assert_eq!(page.rgba.as_chunks::<4>().0.to_vec(), expected); +} + +#[test] +fn non_integer_minification_samples_the_same_columns() { + // Ten columns drawn six pixels wide: centre sampling reads columns 0, 2, 4, 5, 7 and 9. + let ramp = [10u8, 20, 30, 40, 50, 60, 70, 80, 90, 100]; + let page = draw_minified_image("DeviceGray", (10, 1), None, &ramp, None, (6, 1)); + let grays: Vec = page.rgba.as_chunks::<4>().0.iter().map(|px| px[0]).collect(); + assert_eq!(grays, [10, 30, 50, 60, 80, 100]); +} + +#[test] +fn non_integer_minification_samples_colour_and_mask_from_the_same_columns() { + // Ten gray columns with a soft mask, drawn six pixels wide: columns 0, 2, 4, 5, 7 and 9 are + // sampled, so the mask must pick the same columns as the colours. + let ramp = [10u8, 20, 30, 40, 50, 60, 70, 80, 90, 100]; + let mask = [255u8, 0, 0, 0, 255, 255, 0, 0, 0, 255]; + let page = draw_minified_image("DeviceGray", (10, 1), None, &ramp, Some(&mask), (6, 1)); + // Opaque columns keep their gray; transparent columns show the white page. + assert_eq!( + page.rgba.as_chunks::<4>().0.to_vec(), + vec![[10, 10, 10, 255], [255, 255, 255, 255], [50, 50, 50, 255], [60, 60, 60, 255], [255, 255, 255, 255], [100, 100, 100, 255]] + ); +} + +#[test] +fn non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified() { + let palette: [[u8; 3]; 8] = [[0, 0, 0], [255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [255, 0, 255], [0, 255, 255]]; + let rgb: Vec = block_source(palette).concat(); + let alpha = block_source([255u8, 0, 128, 255, 0, 255, 64, 128]); + let page = draw_minified_image("DeviceRGB", (8, 4), None, &rgb, Some(&alpha), (4, 2)); + // Premultiplied colours composited over the white page: alpha 0 shows the page. + let expected = vec![ + [0, 0, 0, 255], + [255, 255, 255, 255], + [255, 127, 127, 255], + [0, 255, 0, 255], + [255, 255, 255, 255], + [255, 255, 0, 255], + [255, 191, 255, 255], + [127, 255, 255, 255], + ]; + assert_eq!(page.rgba.as_chunks::<4>().0.to_vec(), expected); +} + +/// Issue #1649: Word can save text as a tiny black image whose `/SMask` is a high-resolution +/// bitmap of the glyphs, so the mask alone carries the text. Shrinking that mask by +/// nearest-neighbour keeps one sample in N, and a stroke thinner than N mask pixels vanished: +/// the text came out broken and aliased. Here a one-column stroke of an 8-column mask is drawn +/// 4 pixels wide; nearest reads columns 1, 3, 5 and 7 and misses the stroke in column 2. +#[test] +fn shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes() { + let mask: [u8; 8] = [0, 0, 255, 0, 0, 0, 0, 0]; + let content = "q 4 0 0 1 0 0 cm /Im0 Do Q"; + 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 4 1] /Contents 4 0 R /Resources << /XObject << /Im0 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 /ColorSpace /DeviceGray /BitsPerComponent 8 /SMask 6 0 R /Filter /ASCIIHexDecode /Length 3 >> stream\n00>\nendstream endobj\n\ + 6 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length {} >> stream\n{}>\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF\n", + content.len(), + hex(&mask).len() + 1, + hex(&mask) + ); + 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), (4, 1)); + let grays: Vec = page.rgba.as_chunks::<4>().0.iter().map(|px| px[0]).collect(); + // The stroke covers half of device pixel 1, so it is a mid-tone; the far side stays white. + assert!((64..=191).contains(&grays[1]), "the stroke is lost or not a mid-tone: {grays:?}"); + assert_eq!(grays[3], 255, "{grays:?}"); +} + +/// Type 3 glyph images used to be sampled bicubically (`ImageQuality::High`) whatever their +/// `/Interpolate`, which blends the nearest pre-resize back in. The glyph is 4.6 × 2.3 pixels +/// and its image pre-resizes to 5 × 3, so the final draw samples fractionally and bicubic +/// filtering would blend palette colours. Interior pixels must stay palette colours unless the +/// image asks for interpolation. +#[test] +fn non_interpolated_type3_glyph_images_are_not_smoothed_when_minified() { + let palette: [[u8; 3]; 8] = [[0, 0, 0], [255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [255, 0, 255], [0, 255, 255]]; + let data = hex(&block_source(palette).concat()); + let render = |interp: &str| { + let content = "BT /F1 1 Tf 0 0.7 Td (A) Tj ET"; + let glyph = "4.6 0 d0 q 4.6 0 0 2.3 0 0 cm /Im0 Do Q"; + 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 5 3] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /Font /Subtype /Type3 /FontBBox [0 0 10 10] /FontMatrix [1 0 0 1 0 0] /FirstChar 65 /LastChar 65 /Widths [5] /Encoding << /Differences [65 /a] >> /CharProcs << /a 6 0 R >> /Resources << /XObject << /Im0 7 0 R >> >> >> endobj\n\ + 6 0 obj << /Length {} >> stream\n{glyph}\nendstream endobj\n\ + 7 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 4 /ColorSpace /DeviceRGB /BitsPerComponent 8 {interp}/Filter /ASCIIHexDecode /Length {} >> stream\n{data}>\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF\n", + content.len(), + glyph.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), (5, 3)); + page + }; + // The glyph fully covers columns 0–3 and rows 0–1. + let interior = |p: &RenderedPage| -> Vec<[u8; 3]> { + (0..2).flat_map(|y| (0..4).map(move |x| (y * 5 + x) * 4)).map(|i| [p.rgba[i], p.rgba[i + 1], p.rgba[i + 2]]).collect() + }; + + let omitted = render(""); + for px in interior(&omitted) { + assert!(palette.contains(&px), "non-interpolated glyph image was smoothed to {px:?}"); + } + let enabled = render("/Interpolate true "); + assert!(interior(&enabled).iter().any(|px| !palette.contains(px)), "/Interpolate true glyph image should still be sampled bicubically"); +} + +/// 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, ..Default::default() }); + let text = r.render(RenderRequest { page: 0, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + 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 { + 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, ..Default::default() }); + 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, ..Default::default() }); + let text = r.render(RenderRequest { page: 0, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + 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"); +} + +/// Accented letters in non-embedded fonts (vendored hayro-interpret patch to +/// `StandardKind`): the stand-ins for the standard fonts hold only the glyphs of the +/// Standard, WinAnsi and Mac Roman encodings, so a letter a font names beyond those in its +/// /Differences drew nothing. iText 2 writes Turkish this way (`/Gbreve`, `/Idotaccent`, +/// `/Scedilla`, `/gbreve`, `/scedilla` over Ð, Ý, Þ, ð, þ), and a Turkish letter had blanks +/// for Ğ, İ, Ş, ğ and ş. Each is now its base letter with the accent above or below it. +#[test] +fn accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts() { + let letters = ["G", "Gbreve", "S", "Scedilla", "I", "Idotaccent", "g", "gbreve", "s", "scedilla"]; + let mut content = String::new(); + for (i, _) in letters.iter().enumerate() { + content.push_str(&format!("BT /F1 40 Tf {} 20 Td <{:02X}> Tj ET\n", 10 + 40 * i, 65 + i)); + } + let differences = letters.iter().map(|n| format!("/{n}")).collect::>().join(" "); + let pdf = format!( + "%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 420 80] /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 /TrueType /BaseFont /TimesNewRomanPSMT /FirstChar 65 /LastChar 74 + /Widths [722 722 556 556 333 333 500 500 389 389] /FontDescriptor 6 0 R + /Encoding << /Type /Encoding /Differences [65 {differences}] >> >> endobj +6 0 obj << /Type /FontDescriptor /FontName /TimesNewRomanPSMT /Flags 32 /FontBBox [-568 -306 2045 1039] + /ItalicAngle 0 /Ascent 693 /Descent -215 /CapHeight 662 /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, ..Default::default() }); + assert!(p.error.is_none(), "{:?}", p.error); + // The dark pixels of the i-th letter: how many, and its top and bottom rows (y down). + let ink = |i: u32| { + let (mut count, mut top, mut bottom) = (0, u32::MAX, 0); + for y in 0..p.height { + for x in (8 + 40 * i)..(46 + 40 * i).min(p.width) { + if p.rgba[((y * p.width + x) * 4) as usize] < 128 { + count += 1; + top = top.min(y); + bottom = bottom.max(y); + } + } + } + (count, top, bottom) + }; + for (pair, above) in [(0, true), (1, false), (2, true), (3, true), (4, false)] { + let (base, accented) = (ink(2 * pair), ink(2 * pair + 1)); + let name = letters[2 * pair as usize + 1]; + assert!(base.0 > 0, "{name}: the base letter draws"); + assert!(accented.0 > base.0, "{name} draws its letter and accent: {accented:?} vs {base:?}"); + if above { + assert!(accented.1 + 2 < base.1, "{name}: the accent sits above the letter: {accented:?} vs {base:?}"); + } else { + assert!(accented.2 > base.2 + 2, "{name}: the accent hangs below the letter: {accented:?} vs {base:?}"); + } + } +} + +/// Comma-below letters in non-embedded standard fonts (vendored hayro-interpret patch to +/// `StandardKind`). The stand-ins have neither these letters nor a `commaaccent` to build +/// them from, so Romanian ș Ș (a form laid out from its XFA template names `/scommaaccent`) +/// and Latvian ķ ļ ņ ŗ Ģ drew nothing, and ț Ț drew a cedilla. Each is now its letter with a +/// comma under it, apart from the letter and inside the descender, whichever name the font +/// gives it: the glyph list calls the Latvian letters `/Kcedilla`…, and `uni` names say only +/// the code point. +#[test] +fn comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter() { + let pairs = [ + ("s", "uni0219"), + ("t", "uni021B"), + ("T", "uni021A"), + ("K", "uni0136"), + ("k", "kcedilla"), + ("G", "uni0122"), + ("G", "Gcedilla"), + ("R", "uni0156"), + ("s", "scommaaccent"), + ("S", "Scommaaccent"), + ("t", "tcommaaccent"), + ("T", "Tcommaaccent"), + ("k", "kcommaaccent"), + ("K", "Kcommaaccent"), + ("l", "lcommaaccent"), + ("L", "Lcommaaccent"), + ("n", "ncommaaccent"), + ("N", "Ncommaaccent"), + ("r", "rcommaaccent"), + ("R", "Rcommaaccent"), + ("G", "Gcommaaccent"), + ]; + let letters: Vec<&str> = pairs.iter().flat_map(|(base, accented)| [*base, *accented]).collect(); + let (size, baseline) = (40u32, 60u32); + for font in ["Helvetica", "Times-Roman", "Courier", "Helvetica-BoldOblique"] { + let mut content = String::new(); + for i in 0..letters.len() { + content.push_str(&format!("BT /F1 {size} Tf {} {} Td <{:02X}> Tj ET\n", 10 + 40 * i, 80 - baseline, 1 + i)); + } + let differences = letters.iter().map(|n| format!("/{n}")).collect::>().join(" "); + let pdf = format!( + "%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 {} 80] /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 /Type1 /BaseFont /{font} + /Encoding << /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [1 {differences}] >> >> endobj +trailer << /Root 1 0 R >> +%%EOF", + 20 + 40 * letters.len(), + 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, ..Default::default() }); + assert!(p.error.is_none(), "{font}: {:?}", p.error); + // The rows (y down) of the i-th letter that have dark pixels. + let ink_rows = |i: u32| -> Vec { + (0..p.height).filter(|y| ((8 + 40 * i)..(48 + 40 * i).min(p.width)).any(|x| p.rgba[((y * p.width + x) * 4) as usize] < 128)).collect() + }; + for (pair, (_, name)) in pairs.iter().enumerate() { + let (base, accented) = (ink_rows(2 * pair as u32), ink_rows(2 * pair as u32 + 1)); + let (Some(&base_bottom), Some(&bottom)) = (base.last(), accented.last()) else { + panic!("{font} {name}: draws nothing ({} rows of its base letter)", base.len()); + }; + assert!(bottom > base_bottom + 2, "{font} {name}: a mark hangs below the letter ({bottom} vs {base_bottom})"); + assert!( + (base_bottom + 1..bottom).any(|y| !accented.contains(&y)), + "{font} {name}: the mark is apart from the letter, a comma and not a cedilla" + ); + assert!(bottom < baseline + size * 3 / 10, "{font} {name}: the comma stays within the descender ({bottom})"); + } + } +} + +// Contributor-original classic-xref vector fixtures; no embedded or external assets. +fn raw_crop_unit_pdf(unit: f64, rotation: i32, crop: [f64; 4], marker: Option<[f64; 4]>) -> Vec { + let bounds = crop.map(|n| n.to_string()).join(" "); + let body = marker.map(|rect| format!("0 0 0 rg {} re f\n", rect.map(|n| n.to_string()).join(" "))).unwrap_or_default(); + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_owned(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(), + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [{bounds}] /CropBox [{bounds}] /UserUnit {unit} /Rotate {rotation} /Resources << >> /Contents 4 0 R >>" + ), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n"); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer << /Size 5 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + bytes +} + +#[test] +fn raw_crop_user_unit_inspection_keeps_observed_dimensions() { + let bytes = raw_crop_unit_pdf(4096.0, 0, [0.0, 0.0, 0.1, 0.1], None); + let info = crate::inspect(Arc::new(bytes), None).expect("original valid raw-0.1 PDF"); + assert_eq!(info.pages.len(), 1); + assert!(info.warnings.is_empty(), "{:?}", info.warnings); + let page = &info.pages[0]; + assert!( + (page.width - 409.6).abs() < 0.0001 && (page.height - 409.6).abs() < 0.0001, + "raw 0.1 at UserUnit4096 is 409.6 points, got {} by {}", + page.width, + page.height + ); +} + +#[test] +fn raw_crop_unit_floor_corners_match_nonzero_origin_and_rotations() { + use hayro::hayro_interpret::TransformExt; + + for (unit, width, height, dx, dy) in + [(1.0, 1.0, 1.0, 0.125, 0.25), (4096.0, 512.0, 1024.0, 512.0, 1024.0), (75000.0, 9375.0, 18750.0, 9375.0, 18750.0)] + { + for rotation in [0, 90, 180, 270] { + let bytes = raw_crop_unit_pdf(unit, rotation, [10.0, 20.0, 10.125, 20.25], None); + let info = crate::inspect(Arc::new(bytes.clone()), None).expect("original valid offset crop"); + assert!(info.warnings.is_empty(), "{:?}", info.warnings); + let dimensions = if rotation % 180 == 0 { (width as f32, height as f32) } else { (height as f32, width as f32) }; + assert_eq!((info.pages[0].width, info.pages[0].height), dimensions, "unit={unit}, rotation={rotation}"); + let pdf = Pdf::new(Arc::new(bytes)).expect("same PDF parser"); + let page = pdf.pages().first().expect("one page"); + assert_eq!(page.base_dimensions(), ((width / unit) as f32, (height / unit) as f32)); + let transform = page.initial_transform(true).to_kurbo(); + let (origin, opposite) = match rotation { + 0 => ([0.0, height], [dx, height - dy]), + 90 => ([0.0, 0.0], [dy, dx]), + 180 => ([width, 0.0], [width - dx, dy]), + _ => ([height, width], [height - dy, width - dx]), + }; + for (point, expected) in [(kurbo::Point::new(10.0, 20.0), origin), (kurbo::Point::new(10.125, 20.25), opposite)] { + let actual = transform * point; + assert!( + (actual.x - expected[0]).abs() < 1e-9 && (actual.y - expected[1]).abs() < 1e-9, + "unit={unit}, rotation={rotation}, point={point:?}: {actual:?} != {expected:?}" + ); + } + } + } +} + +#[test] +fn raw_crop_unit_floor_reciprocal_does_not_trigger_letter_fallback() { + for unit in [1.0, 41.0, 47.0] { + let bytes = raw_crop_unit_pdf(unit, 0, [0.0, 0.0, 0.01, 0.01], None); + let pdf = Pdf::new(Arc::new(bytes.clone())).expect("ordinary small crop"); + let page = pdf.pages().first().expect("one page"); + let (width, height) = page.base_dimensions(); + // In f32, the nearest reciprocal of 41 or 47 multiplies back to 0.99999994. + assert!( + width * page.user_unit() >= 1.0 && height * page.user_unit() >= 1.0, + "unit={unit}: floor must not round below one physical point" + ); + let info = crate::inspect(Arc::new(bytes), None).expect("inspect the same small crop"); + let page = &info.pages[0]; + assert!( + (1.0..=1.000001).contains(&page.width) && (1.0..=1.000001).contains(&page.height), + "unit={unit}: one-point floor must not become a Letter placeholder: {} by {}", + page.width, + page.height + ); + assert!(info.warnings.is_empty(), "{:?}", info.warnings); + } +} + +#[test] +fn raw_crop_unit_floor_preserves_nearly_zero_fallback() { + for unit in [1.0, 4096.0, 75000.0] { + for side in [0.0, 0.0001] { + let bytes = raw_crop_unit_pdf(unit, 0, [0.0, 0.0, side, side], None); + let pdf = Pdf::new(Arc::new(bytes)).expect("existing degenerate-page fallback"); + let page = pdf.pages().first().expect("one page"); + // Existing A4 raw fallback remains raw A4, then scales by the page unit. + let expected = (595.2755905511812_f64 as f32, 841.8897637795277_f64 as f32); + assert_eq!(page.base_dimensions(), expected, "unit={unit}, raw side={side}"); + assert_eq!(page.render_dimensions(), (expected.0 * unit as f32, expected.1 * unit as f32)); + } + } +} + +fn raw_crop_ink(page: &RenderedPage) -> ([u32; 4], usize) { + let (mut left, mut top, mut right, mut bottom, mut count) = (page.width, page.height, 0, 0, 0); + for (i, pixel) in page.rgba.as_chunks::<4>().0.iter().enumerate() { + if pixel[0] < 128 && pixel[1] < 128 && pixel[2] < 128 { + let (x, y) = (i as u32 % page.width, i as u32 / page.width); + left = left.min(x); + top = top.min(y); + right = right.max(x + 1); + bottom = bottom.max(y + 1); + count += 1; + } + } + assert!(count > 0, "original vector marker must draw"); + ([left, top, right, bottom], count) +} + +#[test] +fn raw_crop_user_unit_vector_rasters_match_physical_controls() { + for (unit, scale, width, height, physical_width, physical_height, rects) in [ + ( + 4096.0, + 0.5, + 256, + 512, + 512.0, + 1024.0, + [[32.0, 320.0, 96.0, 448.0], [64.0, 32.0, 192.0, 96.0], [160.0, 64.0, 224.0, 192.0], [320.0, 160.0, 448.0, 224.0]], + ), + ( + 75000.0, + 0.04, + 375, + 750, + 9375.0, + 18750.0, + [ + [46.875, 468.75, 140.625, 656.25], + [93.75, 46.875, 281.25, 140.625], + [234.375, 93.75, 328.125, 281.25], + [468.75, 234.375, 656.25, 328.125], + ], + ), + ] { + for (index, rotation) in [0, 90, 180, 270].into_iter().enumerate() { + let raw_crop = [10.0, 20.0, 10.125, 20.25]; + let raw_marker = [10.015625, 20.03125, 0.03125, 0.0625]; + let control_crop = raw_crop.map(|n| n * unit); + let control_marker = raw_marker.map(|n| n * unit); + let physical = if rotation % 180 == 0 { (physical_width, physical_height) } else { (physical_height, physical_width) }; + let pixels = if rotation % 180 == 0 { (width, height) } else { (height, width) }; + let render = |bytes: Vec| { + let info = crate::inspect(Arc::new(bytes.clone()), None).expect("bounded original vector PDF"); + assert_eq!((info.pages[0].width, info.pages[0].height), physical, "check dimensions before any raster allocation"); + let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!((page.width, page.height), pixels); + page + }; + let high = render(raw_crop_unit_pdf(unit, rotation, raw_crop, Some(raw_marker))); + let control = render(raw_crop_unit_pdf(1.0, rotation, control_crop, Some(control_marker))); + let (bbox, count) = raw_crop_ink(&high); + for (actual, expected) in bbox.into_iter().zip(rects[index]) { + assert!((f64::from(actual) - expected).abs() <= 1.0, "unit={unit}, rotation={rotation}: {bbox:?} != {:?}", rects[index]); + } + let (control_bbox, control_count) = raw_crop_ink(&control); + assert_eq!(bbox, control_bbox, "unit={unit}, rotation={rotation}: marker placement"); + assert_eq!(count, control_count, "unit={unit}, rotation={rotation}: visible marker ink"); + assert!(high.rgba == control.rgba, "unit={unit}, rotation={rotation}: physically equivalent crops render identically"); + } + } +} + +/// A page's `/UserUnit` (the size of its unit in points) scales the page and everything on +/// it, after the crop box and before `/Rotate`, as Acrobat shows such pages: with +/// `/UserUnit 2`, scale 1 renders pixel for pixel what scale 2 renders without it. As in +/// Acrobat, values below 1, values that aren't numbers and a value on a parent `/Pages` +/// node count as 1, and values over 75,000 count as 75,000. +#[test] +fn user_unit_scales_the_page_and_everything_on_it() { + let content = "1 0 0 rg 20 10 30 20 re f BT /F1 18 Tf 15 50 Td (Hg) Tj ET q 16 0 0 8 60 30 cm BI /W 2 /H 2 /CS /RGB /BPC 8 /F /AHx ID 0000FFFF000000FF00FFFF00> EI Q"; + let pdf = |page: &str, parent: &str| { + format!( + "%PDF-1.4 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 {parent} >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 80] /CropBox [10 6 90 76] {page} /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 /Type1 /BaseFont /Helvetica >> endobj +6 0 obj 2 endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len() + ) + .into_bytes() + }; + let render = |bytes: Vec, scale: f32| { + let mut r = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale, tag: 0, ..Default::default() }); + assert!(p.error.is_none(), "{:?}", p.error); + p + }; + let glyphs = |bytes: Vec| { + let parsed = Pdf::new(Arc::new(bytes)).expect("parses"); + crate::text::extract_page(&parsed, 0, &InterpreterSettings::default()).expect("text").glyphs + }; + let size = |bytes: Vec| { + let p = &crate::inspect(Arc::new(bytes), None).expect("opens").pages[0]; + (p.width, p.height) + }; + for rotate in [0, 90, 180, 270] { + let turned = format!("/Rotate {rotate}"); + let (w, h) = if rotate % 180 == 0 { (80.0, 70.0) } else { (70.0, 80.0) }; + let plain = render(pdf(&turned, ""), 2.0); + let one = render(pdf(&turned, ""), 1.0); + let plain_glyphs = glyphs(pdf(&turned, "")); + for unit in ["/UserUnit 2", "/UserUnit 6 0 R"] { + let page = format!("{turned} {unit}"); + assert_eq!(size(pdf(&page, "")), (w * 2.0, h * 2.0), "{page}"); + let doubled = render(pdf(&page, ""), 1.0); + assert_eq!((doubled.width, doubled.height), (plain.width, plain.height), "{page}"); + assert!(doubled.rgba == plain.rgba, "{page}: the page renders as at twice the scale"); + let doubled_glyphs = glyphs(pdf(&page, "")); + assert_eq!(doubled_glyphs.len(), plain_glyphs.len(), "{page}"); + for (d, p) in doubled_glyphs.iter().zip(&plain_glyphs) { + assert_eq!(d.rect, p.rect.map(|v| v * 2.0), "{page}: text layer"); + } + } + for unit in ["/UserUnit 1", "/UserUnit 0", "/UserUnit -1", "/UserUnit 0.5", "/UserUnit /Two", "/UserUnit (2)"] { + let page = format!("{turned} {unit}"); + assert_eq!(size(pdf(&page, "")), (w, h), "{page}"); + assert!(render(pdf(&page, ""), 1.0).rgba == one.rgba, "{page} counts as 1"); + } + assert_eq!(size(pdf(&turned, "/UserUnit 2")), (w, h), "not inherited from /Pages"); + assert!(render(pdf(&turned, "/UserUnit 2"), 1.0).rgba == one.rgba, "not inherited from /Pages"); + for unit in ["/UserUnit 75000", "/UserUnit 100000"] { + assert_eq!(size(pdf(&format!("{turned} {unit}"), "")), (w * 75_000.0, h * 75_000.0), "{unit}"); + } + } +} + +#[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, ..Default::default() }); + 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, ..Default::default() }); + 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}"); + } + } +} + +/// White-on-black: the page clears black, the black filled rectangle maps to the text colour, +/// and the image's pixels are byte-identical to the plain render — photos never pass through +/// the mapping, not even its white quadrant, which would map to black if they did. +#[test] +fn reading_colors_map_solid_paints_and_leave_images() { + let content = "0 0 0 rg 10 10 30 20 re f\nq 20 0 0 10 60 20 cm /Im Do Q"; + let pdf = format!( + "%PDF-1.4\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 100 50] /Contents 4 0 R /Resources << /XObject << /Im 6 0 R >> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 6 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 25 >> stream\nFF000000FF00000000FFFFFFFF>\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ) + .into_bytes(); + let req = RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0, preview: false }; + let render = |reading_colors: ReadingColors| { + let mut r = PageRenderer::new(Arc::new(pdf.clone()), RenderConfig { reading_colors, ..Default::default() }); + let page = r.render(req); + assert!(page.error.is_none(), "{:?}", page.error); + page + }; + let off = render(ReadingColors::default()); + let on = render(ReadingColors::WHITE_ON_BLACK); + assert_eq!((on.width, on.height), (200, 100)); + fn pixel(page: &RenderedPage, x: u32, y: u32) -> &[u8] { + &page.rgba[((y * page.width + x) * 4) as usize..][..4] + } + + // The page clears to the setting's background instead of white. + assert_eq!(pixel(&off, 5, 5), &[255, 255, 255, 255]); + assert_eq!(pixel(&on, 5, 5), &[0, 0, 0, 255]); + // The black rectangle maps to the text colour. + assert_eq!(pixel(&off, 50, 60), &[0, 0, 0, 255]); + assert_eq!(pixel(&on, 50, 60), &[255, 255, 255, 255]); + // The whole image area (the interior of device pixels 120..160 × 40..60) is untouched. + for y in 44..56u32 { + for x in 124..156u32 { + assert_eq!(pixel(&on, x, y), pixel(&off, x, y), "image pixel {x},{y}"); + } + } + assert_eq!(pixel(&on, 150, 55), &[255, 255, 255, 255], "the image's white quadrant stays white"); +} + +/// #2975: a stencil mask (`/ImageMask true`) is painted in the fill colour at the fill +/// opacity, once: at `ca 0.5` it is as opaque as a rectangle filled at `ca 0.5` beside it +/// (it was a quarter as opaque, the opacity applied by both the image's group and its paint). +/// The same holds when the fill is a pattern, whose paint keeps the opacity in force when it +/// was set with `scn`: an uncoloured tiling pattern and a shading pattern. +#[test] +fn stencil_masks_are_drawn_at_the_fill_opacity_once() { + let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..3].to_vec(); + let fills = [("colour", "0 0 1 rg"), ("uncoloured tiling pattern", "/CsP cs 0 0 1 /Tp scn"), ("shading pattern", "/Pattern cs /Sp scn")]; + for (fill_name, fill) in fills { + for alpha in ["1", "0.5", "0.25"] { + let content = format!("/A gs {fill} q 100 0 0 100 20 50 cm /Msk Do Q 160 50 100 100 re f"); + let tile = "0 0 10 10 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 300 200] /Contents 4 0 R /Resources << /ExtGState << /A << /ca {alpha} >> >> /XObject << /Msk 5 0 R >> /ColorSpace << /CsP [/Pattern /DeviceRGB] >> /Pattern << /Tp 6 0 R /Sp 7 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ImageMask true /Length 2 >> stream +\0\0 +endstream endobj +6 0 obj << /Type /Pattern /PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >> /Length {} >> stream +{tile} +endstream endobj +7 0 obj << /Type /Pattern /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 300 0] /Extend [true true] /Function << /FunctionType 2 /Domain [0 1] /C0 [0 0 1] /C1 [0 0 1] /N 1 >> >> >> endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + tile.len() + ); + let p = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + ..Default::default() + }); + assert!(p.error.is_none(), "{fill_name}, ca {alpha}: {:?}", p.error); + // Pixel rows run top down: PDF (70, 100) is in the mask, (210, 100) in the rectangle. + let (mask, rect) = (px(&p, 70, 100), px(&p, 210, 100)); + assert!(rect != [255, 255, 255], "{fill_name}, ca {alpha}: the rectangle is painted"); + assert!(mask.iter().zip(&rect).all(|(m, r)| m.abs_diff(*r) <= 1), "{fill_name}, ca {alpha}: mask {mask:?}, rectangle {rect:?}"); + } + } +} + +/// #2971: in a knockout transparency group (`/K true`) each element composites with the +/// group's initial backdrop, not with the elements drawn before it in the group, over the +/// element's shape. A blue square at 50 % opacity over a red one at 50 % shows only the blue +/// where they overlap; without `/K` the two blend. The same holds for an image (its shape +/// is its rectangle; a soft-masked image's shape is its mask, so its transparent part leaves +/// what is under it) and for a nested group (its shape is the union of its elements' +/// shapes, while its own elements still blend with each other). +#[test] +fn knockout_groups_replace_what_their_elements_cover() { + let render_as = |group: &str, form: &str, preview: bool| { + 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 300 200] /Contents 4 0 R /Resources << /XObject << /Fm1 5 0 R >> /ExtGState << /G << /ca 0.5 >> >> >> >> endobj +4 0 obj << /Length 39 >> stream +/Fm1 Do /G gs 0 1 0 rg 10 10 30 30 re f +endstream endobj +5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency {group} >> /Resources << /ExtGState << /A << /ca 0.5 >> >> /XObject << /In 6 0 R /Msk 7 0 R /Neu 8 0 R /Img 9 0 R /Sm 10 0 R /Nimg 12 0 R /Deep 13 0 R /Shg 16 0 R /Big 17 0 R >> >> /Length {} >> stream +{form} +endstream endobj +6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /A << /ca 0.5 >> >> >> /Length 34 >> stream +/A gs 0 0 1 rg 100 50 150 120 re f +endstream endobj +7 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ImageMask true /Filter /ASCIIHexDecode /Length 5 >> stream +0000> +endstream endobj +8 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /H << /ca 0.5 >> >> >> /Length 62 >> stream +/H gs 0 1 0 rg 80 40 100 100 re f 0 0 1 rg 130 80 100 100 re f +endstream endobj +9 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 7 >> stream +0000FF> +endstream endobj +10 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask 11 0 R /Filter /ASCIIHexDecode /Length 13 >> stream +0000FF0000FF> +endstream endobj +11 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 5 >> stream +00FF> +endstream endobj +12 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /H << /ca 0.5 >> >> /XObject << /Img 9 0 R >> >> /Length 38 >> stream +/H gs q 160 0 0 100 60 50 cm /Img Do Q +endstream endobj +13 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /H << /ca 0.5 >> >> /XObject << /In3 14 0 R >> >> /Length 13 >> stream +/H gs /In3 Do +endstream endobj +14 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Length 28 >> stream +0 0 1 rg 100 50 150 120 re f +endstream endobj +15 0 obj << /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 1 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [0 0 1] /C1 [0 0 1] /N 1 >> /Extend [true true] /BBox [100 50 140 170] >> >> endobj +16 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /Pattern << /P 15 0 R >> >> /Length 38 >> stream +/Pattern cs /P scn 100 50 150 120 re f +endstream endobj +17 0 obj << /Type /XObject /Subtype /Image /Width 20000 /Height 20000 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 7 >> stream +0000FF> +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + form.len() + ); + let p = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview, + }); + assert!(p.error.is_none(), "{group} {form}: {:?}", p.error); + p + }; + let render = |group: &str, form: &str| render_as(group, form, false); + // Pixel rows run top down: PDF (x, y) is pixel (x, 200 - y). + let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[((200 - y) * p.width + x) as usize * 4..][..3].to_vec(); + let check = |p: &RenderedPage, x: u32, y: u32, want: [u8; 3], what: &str| { + let got = px(p, x, y); + assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 2), "{what}: {got:?}, expected {want:?}"); + }; + let red = "/A gs 1 0 0 rg 20 20 150 150 re f "; + let ko = "/I true /K true"; + + let squares = render(ko, &format!("{red}0 0 1 rg 100 50 150 120 re f")); + check(&squares, 130, 100, [128, 128, 255], "knocked out"); + check(&squares, 50, 40, [255, 128, 128], "red alone"); + check(&squares, 210, 100, [128, 128, 255], "blue alone"); + check(&squares, 280, 190, [255, 255, 255], "paper"); + // Page content after the group composites normally again: green at 50 % over the red. + check(&squares, 30, 30, [128, 191, 64], "after the group"); + let plain = render("/I true", &format!("{red}0 0 1 rg 100 50 150 120 re f")); + check(&plain, 130, 100, [128, 64, 191], "blended without /K"); + + // An image at 50 % replaces the red under its rectangle. + let image = render(ko, &format!("{red}q 160 0 0 100 60 50 cm /Img Do Q")); + check(&image, 100, 100, [128, 128, 255], "image knocks out"); + check(&image, 40, 40, [255, 128, 128], "red beside the image"); + // A soft-masked image: its transparent left half leaves the red, its opaque half replaces it. + let masked = render(ko, "1 0 0 rg 20 20 150 150 re f q 160 0 0 100 60 50 cm /Sm Do Q"); + check(&masked, 100, 100, [255, 0, 0], "under the transparent half"); + check(&masked, 155, 100, [0, 0, 255], "under the opaque half"); + + // A nested group entered at 50 % (blue at 25 % in all) replaces the red under its shape. + let nested = render(ko, &format!("{red}/In Do")); + check(&nested, 130, 100, [191, 191, 255], "nested group knocks out"); + check(&nested, 50, 40, [255, 128, 128], "red beside the nested group"); + // A nested group at full opacity: it knocks out the red as a whole, and its own two + // squares at 50 % still blend with each other where they overlap. + let neutral = render(ko, "1 0 0 rg 20 20 150 150 re f /Neu Do"); + check(&neutral, 100, 60, [128, 255, 128], "green over the knocked-out red"); + check(&neutral, 150, 110, [64, 128, 191], "the nested squares blend"); + check(&neutral, 40, 40, [255, 0, 0], "red beside the nested group"); + + // An image inside a nested group is drawn once (blue at 50 %), over the knocked-out red. + let nested_image = render(ko, "1 0 0 rg 20 20 150 150 re f /Nimg Do"); + check(&nested_image, 100, 100, [128, 128, 255], "image in a nested group"); + // A group inside a nested group knocks out the red as well. + let deep = render(ko, "1 0 0 rg 20 20 150 150 re f /Deep Do"); + check(&deep, 130, 100, [128, 128, 255], "group in a nested group"); + check(&deep, 40, 40, [255, 0, 0], "red beside it"); + + // A stencil mask must not erase the red beside it. + let stencil = render(ko, &format!("{red}0 0 1 rg q 150 0 0 120 100 50 cm /Msk Do Q")); + check(&stencil, 50, 40, [255, 128, 128], "red beside a stencil"); + // Where the stencil paints (every sample 0), its blue is drawn (review on #2974: the check + // above alone passed even if the stencil drew nothing). A stencil composites as it did + // before rather than knocking the red out, and its exact colour also depends on #2975 (a + // stencil's fill opacity applied twice), so only "blue was drawn here" is pinned. + let under = px(&stencil, 130, 100); + assert!(under != [255, 128, 128] && under[2] >= 150 && under[0] < 230, "stencil drew nothing: {under:?}"); + + // A shading's /BBox clips it (ISO 32000-2 Table 77), so a shading fill's shape is its path + // inside the box: the erase pass must clear only that, or the red outside the box but + // inside the path stays erased (review on #2974). + let shaded = render(ko, &format!("{red}/Shg Do")); + check(&shaded, 120, 100, [128, 128, 255], "the shading inside its box"); + check(&shaded, 155, 100, [255, 128, 128], "red outside the shading's box"); + + // An image that isn't decoded (here over the renderer's pixel limit) draws nothing, so it + // must not clear what is under its rectangle either (review on #2974). + let undecoded = render(ko, &format!("{red}q 160 0 0 100 60 50 cm /Big Do Q")); + check(&undecoded, 100, 100, [255, 128, 128], "red under an image that isn't drawn"); + // Nor one a preview skips for covering only a few device pixels (review on #2974): the + // red under a 3 x 3 image stays in a preview, and a final render knocks it out. + let tiny = format!("{red}q 3 0 0 3 100 100 cm /Img Do Q"); + check(&render_as(ko, &tiny, true), 101, 101, [255, 128, 128], "red under a preview's skipped image"); + check(&render_as(ko, &tiny, false), 101, 101, [128, 128, 255], "the tiny image in a final render"); +} + +/// #2970: a page's objects composite with a transparent backdrop and the page group is then +/// put on the paper, so a separable blend mode only blends where something was drawn. A +/// green fill with `/BM /Screen` (or Lighten, Difference…) half over a red square is yellow +/// (or whatever the mode gives) over the red, and plain green over the empty page: blending +/// with the white paper made it white or magenta. +#[test] +fn blend_modes_over_an_empty_part_of_the_page_keep_their_colour() { + let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec(); + for (mode, over_red) in [ + ("Normal", Some([0, 255, 0])), + ("Multiply", Some([0, 0, 0])), + ("Screen", Some([255, 255, 0])), + ("Lighten", Some([255, 255, 0])), + ("Difference", Some([255, 255, 0])), + ("Exclusion", Some([255, 255, 0])), + ("ColorDodge", Some([255, 0, 0])), + ("Overlay", Some([255, 0, 0])), + // The non-separable modes mix hue, saturation and luminosity; over an empty part of + // the page they too must keep the source colour. + ("Hue", None), + ("Saturation", None), + ("Color", None), + ("Luminosity", None), + ] { + let content = "1 0 0 rg 20 20 150 150 re f /G1 gs 0 1 0 rg 100 50 150 120 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 300 200] /Contents 4 0 R /Resources << /ExtGState << /G1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /ExtGState /BM /{mode} >> endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len() + ); + let p = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + ..Default::default() + }); + assert!(p.error.is_none(), "{mode}: {:?}", p.error); + // Pixel rows run top down: PDF (210, 100) and (130, 100) are rows 100 from the top. + assert_eq!(px(&p, 210, 100), vec![0, 255, 0, 255], "{mode} over the empty page"); + if let Some(over_red) = over_red { + assert_eq!(px(&p, 130, 100)[..3], over_red, "{mode} over the red square"); + } + assert_eq!(px(&p, 280, 10), vec![255, 255, 255, 255], "{mode}: the paper stays white"); + } +} + +/// A transparent page leaves what nothing paints see-through, so it can be laid over another +/// raster: with reading colours on, and in a tile, too. Painted pixels stay premultiplied. +#[test] +fn transparent_pages_paint_only_their_content() { + let content = "0 0 1 rg 10 10 30 20 re f /GS gs 0.5 g 60 10 30 20 re f"; + let pdf = format!( + "%PDF-1.4\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 100 50] /Contents 4 0 R /Resources << /ExtGState << /GS << /ca 0.5 >> >> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ) + .into_bytes(); + let pixel = |page: &RenderedPage, x: u32, y: u32| page.rgba[((y * page.width + x) * 4) as usize..][..4].to_vec(); + for reading_colors in [ReadingColors::default(), ReadingColors::WHITE_ON_BLACK] { + let config = RenderConfig { transparent: true, reading_colors, ..Default::default() }; + let mut r = PageRenderer::new(Arc::new(pdf.clone()), config.clone()); + let page = r.render(RenderRequest { page: 0, scale: 2.0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!(pixel(&page, 5, 5), [0, 0, 0, 0], "{reading_colors:?}: nothing painted, nothing shown"); + assert_eq!(pixel(&page, 50, 60)[3], 255, "{reading_colors:?}: the opaque rectangle covers"); + assert_eq!(pixel(&page, 150, 60), [64, 64, 64, 128], "{reading_colors:?}: half-transparent grey, premultiplied"); + let tile = r.render(RenderRequest { page: 0, scale: 2.0, tile: Some(Tile { x: 0, y: 40, w: 100, h: 60 }), ..Default::default() }); + assert_eq!((pixel(&tile, 5, 5), pixel(&tile, 50, 20)), (pixel(&page, 5, 45), pixel(&page, 50, 60)), "{reading_colors:?}"); + let pool = RenderPool::new(Arc::new(pdf.clone()), 1, config); + pool.set_queue(vec![RenderRequest { page: 0, scale: 2.0, ..Default::default() }]); + let pooled = loop { + if let Some(p) = pool.try_recv() { + break p; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + }; + assert_eq!(pooled.rgba, page.rgba, "{reading_colors:?}: the pool renders the same"); + } + let opaque = PageRenderer::new(Arc::new(pdf), RenderConfig::default()).render(RenderRequest { page: 0, scale: 2.0, ..Default::default() }); + assert_eq!(pixel(&opaque, 5, 5), [255, 255, 255, 255], "pages are still white by default"); +} + +/// Reading colours replace *document* colours, never annotation colours (issue #1548): a +/// highlight keeps its `/C`, so on a dark background the content under it shows through the +/// Multiply blend in the highlight's colour instead of being darkened into invisibility. +/// Both annotation paths are covered: the `/AP` appearance stream and the no-`/AP` +/// `/QuadPoints` fallback. +#[test] +fn reading_colors_keep_highlight_annotations_readable() { + 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] /QuadPoints [15 65 120 65 15 35 120 35] /C [1 1 0] {{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 render = |annot: &str| { + let mut r = PageRenderer::new( + Arc::new(pdf.replace("{annot}", annot).into_bytes()), + RenderConfig { reading_colors: ReadingColors::WHITE_ON_BLACK, ..Default::default() }, + ); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + 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(); + + // No /AP: the page's black rectangle is remapped to white (document line art), the + // yellow fill is not, so Multiply leaves the content under the quad yellow — readable. + let p = render(""); + assert_eq!(px(&p, 5, 5), vec![0, 0, 0, 255], "the page clears to the background"); + assert_eq!(px(&p, 105, 50), vec![255, 255, 0, 255], "content under the quad shows through in the highlight's colour"); + assert_eq!(px(&p, 30, 50), vec![0, 0, 0, 255], "the band itself multiplies into the dark background, as in Acrobat"); + assert_eq!(px(&p, 150, 50), vec![0, 0, 0, 255], "outside the quad"); + + // With /AP the appearance's blue is an annotation colour too and stays blue instead of + // being remapped towards white by its luminance. + let p = render("/AP << /N 6 0 R >>"); + assert_eq!(px(&p, 30, 50), vec![0, 0, 255, 255], "the appearance keeps its own colour"); + assert_eq!(px(&p, 90, 50), vec![0, 0, 0, 255], "outside the appearance's own drawing"); +} + +/// A print-preview request (`PRINT_TAG`) renders the document's true colours even though +/// the document's config carries reading colours: a preview's job is fidelity to the +/// printout, which is imposed from the uncoloured document. Covers both render paths — +/// the inline `PageRenderer` and a pool worker. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn print_preview_requests_render_with_reading_colors_off() { + let content = "0 0 0 rg 10 10 30 20 re f"; + let pdf = Arc::new( + format!( + "%PDF-1.4\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 50] >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ) + .into_bytes(), + ); + fn px(page: &RenderedPage, x: u32, y: u32) -> &[u8] { + &page.rgba[((y * page.width + x) * 4) as usize..][..4] + } + + // The page and its black rectangle in their true colours. + let mut plain = PageRenderer::new(pdf.clone(), RenderConfig::default()); + let truth = plain.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0, preview: false }); + assert!(truth.error.is_none(), "{:?}", truth.error); + assert_eq!(px(&truth, 5, 5), &[255, 255, 255, 255]); + assert_eq!(px(&truth, 50, 60), &[0, 0, 0, 255]); + + let mut recoloured = PageRenderer::new(pdf.clone(), RenderConfig { reading_colors: ReadingColors::WHITE_ON_BLACK, ..Default::default() }); + // A plain request honours the setting: the page clears black, the rect maps white. + let on = recoloured.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0, preview: false }); + assert_eq!(px(&on, 5, 5), &[0, 0, 0, 255]); + assert_eq!(px(&on, 50, 60), &[255, 255, 255, 255]); + // A print-preview request ignores the setting and matches the true colours exactly. + let preview = RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: PRINT_TAG, preview: false }; + let off = recoloured.render(preview); + assert_eq!(px(&off, 5, 5), &[255, 255, 255, 255]); + assert_eq!(px(&off, 50, 60), &[0, 0, 0, 255]); + assert_eq!(off.rgba, truth.rgba); + + // The same override on the worker path the print dialog's canvas renders through. + let pool = RenderPool::new(pdf, 1, RenderConfig { reading_colors: ReadingColors::WHITE_ON_BLACK, ..Default::default() }); + pool.set_queue(vec![preview]); + let worker = loop { + if let Some(p) = pool.try_recv() { + break p; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + }; + assert!(worker.error.is_none(), "{:?}", worker.error); + assert_eq!(worker.rgba, truth.rgba); +} + +/// A recoloured tile must equal the crop of the recoloured full render, or zooming would +/// flash wrong colours at tile seams. +#[test] +fn reading_colors_tiles_match_full_render() { + let mut r = + PageRenderer::new(Arc::new(ONE_PAGE.to_vec()), RenderConfig { reading_colors: ReadingColors::WHITE_ON_BLACK, ..Default::default() }); + let full = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0, preview: false }); + 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, preview: false }); + assert!(full.error.is_none() && part.error.is_none(), "{:?} {:?}", full.error, part.error); + 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}"); + } + } + // The colours are really on: the page cleared black and the blue rectangle mapped towards + // white (its luminance is 7.2%, so 255·(1−0.0722) ≈ 237). + assert_eq!(&full.rgba[..4], &[0, 0, 0, 255]); + assert_eq!(&full.rgba[((60 * full.width + 50) * 4) as usize..][..4], &[237, 237, 237, 255]); +} + +// A tile seam must not change the raster when large-page work is stitched back together. +#[test] +fn stitched_render_partitions_match_a_whole_render() { + let content = + "0 0 1 rg 0 0 200 200 re f\n1 0 0 RG 2 w 0 100 m 200 100 l S\nBT /F1 18 Tf 20 150 Td (tile text) Tj ET\nq 40 0 0 40 100 100 cm /Im Do Q"; + let pdf = format!( + "%PDF-1.4\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 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im 6 0 R >> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n\ + 6 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 25 >> stream\nFF000000FF00000000FFFFFFFF>\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ); + let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let request = RenderRequest { scale: 6.0, ..Default::default() }; + let whole = renderer.render(request); + assert!(whole.error.is_none(), "{:?}", whole.error); + assert_eq!((whole.width, whole.height), (1200, 1200)); + + let mut assert_partition = |tile_width: u32, tile_height: u32| { + let mut stitched = vec![0; whole.rgba.len()]; + for y in (0..whole.height).step_by(tile_height as usize) { + for x in (0..whole.width).step_by(tile_width as usize) { + let tile = Tile { x, y, w: tile_width.min(whole.width - x), h: tile_height.min(whole.height - y) }; + let part = renderer.render(RenderRequest { tile: Some(tile), ..request }); + assert!(part.error.is_none(), "tile {tile:?}: {:?}", part.error); + assert_eq!((part.width, part.height), (tile.w, tile.h)); + for row in 0..tile.h { + let src = (row * tile.w * 4) as usize; + let dst = ((y + row) * whole.width * 4 + x * 4) as usize; + let len = (tile.w * 4) as usize; + stitched[dst..dst + len].copy_from_slice(&part.rgba[src..src + len]); + } + } + } + assert_eq!(stitched.as_slice(), whole.rgba.as_ref(), "partition {tile_width}×{tile_height}"); + }; + + for bands in [2, 4, 8] { + assert_partition(whole.width, whole.height.div_ceil(bands)); + } + assert_partition(1024, 1024); + assert_partition(127, 131); +} + +/// hayro skips drawing a path that can't paint the canvas (vendored patch (9)), so a tile no +/// longer strokes every path of its page. Every tile, and the whole page, is byte for byte +/// what it was without skipping, where paint reaches past a path into the next tile: round +/// and square caps, a sharp miter, a rotated and a stretched matrix, a hairline, dashes, a +/// curve, text, and a page shown rotated. +/// +/// Images are skipped the same way (vendored patch (10)): an image on a tile, one crossing +/// tile lines, and one fully off the page. +#[test] +fn skipping_paths_off_the_canvas_changes_no_pixel() { + // Tiles of 80 device pixels at scale 2 meet every 40 pt; each mark crosses such a line + // only with its stroke width, caps, miter or antialiasing. The last one is off the page. + let content = "1 0 0 RG 20 w 1 J 10 30 m 35 30 l S +2 J 10 60 m 35 70 l S +0 J 0 j 10 M 8 w 100 50 m 118 60 l 100 70 S +q 0.7071 0.7071 -0.7071 0.7071 160 20 cm 15 w 1 J 0 0 m 20 0 l S Q +0 w 0 0 0 RG 79.9 100 m 79.9 140 l S +1 0 0 rg 80.3 150 20 20 re f +0 0 1 RG 6 w [8 4] 0 d 1 J 130 100 m 205 160 l S [] 0 d +0 1 0 RG 3 w 10 200 m 10 290 70 290 70 200 c S +BT /F1 30 Tf 70 205 Td (Hg) Tj ET +q 4 0 0 0.25 0 0 cm 12 w 1 J 2 500 m 8 500 l S Q +300 300 m 320 320 l S +q 10 0 0 10 5 5 cm /Im0 Do Q +q 30 0 0 30 100 100 cm /Im0 Do Q +q 5 0 0 5 500 500 cm /Im0 Do Q"; + // A 4x4 RGB image of plain `A` bytes (no `endstream` inside, so the raw stream parses). + let pixels = "A".repeat(4 * 4 * 3); + for rotate in [0, 90] { + let pdf = format!( + "%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 240 240] /Rotate {rotate} /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj +6 0 obj << /Type /XObject /Subtype /Image /Width 4 /Height 4 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length {} >> stream +{pixels} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + pixels.len() + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let mut both = |tile| { + let req = RenderRequest { scale: 2.0, tile, ..Default::default() }; + let skipped = r.render(req); + hayro::set_skip_offscreen_paths(false); + let drawn = r.render(req); + hayro::set_skip_offscreen_paths(true); + assert!(skipped.error.is_none() && drawn.error.is_none(), "{:?}", skipped.error); + assert!(skipped.rgba == drawn.rgba, "/Rotate {rotate}: {tile:?} changed"); + }; + both(None); + for y in (0..480).step_by(80) { + for x in (0..480).step_by(80) { + both(Some(Tile { x, y, w: 80, h: 80 })); + } + } + } +} + +/// Previews skip images that would cover only a few device pixels (vendored patch (11)): +/// a tiny draw is missing from the preview but present in the final, while a large draw +/// is in both. Finals never set the flag, so they draw everything. +#[test] +fn preview_renders_skip_tiny_images_but_finals_draw_everything() { + let content = "q 50 0 0 50 100 100 cm /Im0 Do Q +q 0.5 0 0 0.5 10 10 cm /Im0 Do Q"; + let pixels = "A".repeat(4 * 4 * 3); + let pdf = format!( + "%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 240 240] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /XObject /Subtype /Image /Width 4 /Height 4 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length {} >> stream +{pixels} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + pixels.len() + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let mut render = |preview| r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview }); + let first = render(false); + let second = render(false); + assert!(first.error.is_none(), "{:?}", first.error); + assert_eq!(first.rgba, second.rgba, "finals are deterministic"); + let gray = first.rgba.as_chunks::<4>().0.iter().filter(|px| px[0] == 0x41 && px[1] == 0x41 && px[2] == 0x41).count(); + assert!(gray > 1000, "the large image draws {gray} pixels"); + let preview = render(true); + assert!(preview.error.is_none(), "{:?}", preview.error); + assert_ne!(preview.rgba, first.rgba, "the tiny image is skipped only in the preview"); +} + +/// A page whose stream objects 5, 6, … are `objects` (dictionary, data), drawn by `content` +/// with `resources`, on a 130 × 70 pt page. +fn image_cache_pdf(content: &str, resources: &str, objects: &[(&str, &[u8])]) -> Vec { + let mut pdf = format!( + "%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 130 70] /Contents 4 0 R /Resources {resources} >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +", + content.len() + ) + .into_bytes(); + for (i, (dict, data)) in objects.iter().enumerate() { + pdf.extend(format!("{} 0 obj {dict} stream\n", i + 5).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"); + pdf +} + +/// The RGB of the pixel at (`x`, `y`) (from the top left) of a `width`-pixel-wide RGBA raster. +fn rgb_at(rgba: &[u8], width: usize, x: usize, y: usize) -> [u8; 3] { + let i = (y * width + x) * 4; + [rgba[i], rgba[i + 1], rgba[i + 2]] +} + +const ONE_PIXEL_RGB: &str = "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 3 >>"; + +/// Two images whose dictionaries are byte for byte the same (uncompressed, one size) but +/// whose pixels differ each draw their own: the decoded-image cache tells them apart by the +/// stream, not by the dictionary's bytes alone, in one render and across renders sharing a +/// cache, and previews never hand their entries to a final render. +#[test] +fn decoded_images_with_identical_dictionaries_keep_their_own_pixels() { + let pdf = image_cache_pdf( + // B over A at the same place and size (so one decode target): B must show. A alone + // on the right, at a size of its own. + "q 50 0 0 50 10 10 cm /A Do Q q 50 0 0 50 10 10 cm /B Do Q q 40 0 0 40 80 15 cm /A Do Q", + "<< /XObject << /A 5 0 R /B 6 0 R >> >>", + &[(ONE_PIXEL_RGB, b"\xff\x00\x00"), (ONE_PIXEL_RGB, b"\x00\x00\xff")], + ); + let req = |preview| RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview }; + let mut r = PageRenderer::new(Arc::new(pdf.clone()), RenderConfig::default()); + let out = r.render(req(false)); + assert!(out.error.is_none(), "{:?}", out.error); + let w = out.width as usize; + assert_eq!(rgb_at(&out.rgba, w, 35, 35), [0, 0, 255], "the second image drew the first one's pixels"); + assert_eq!(rgb_at(&out.rgba, w, 100, 35), [255, 0, 0]); + // One cache across a preview and then a final render, as a render worker keeps it. + let pdf = parse(&Arc::new(pdf), None).expect("parses"); + let cache = RenderCache::new(); + let settings = InterpreterSettings::default(); + let preview = render_page(&pdf, &cache, &settings, &RenderConfig::default(), req(true)).expect("preview renders"); + let fin = render_page(&pdf, &cache, &settings, &RenderConfig::default(), req(false)).expect("final renders"); + let fresh = render_page(&pdf, &RenderCache::new(), &settings, &RenderConfig::default(), req(false)).expect("final renders"); + assert_eq!(rgb_at(&preview.2, w, 35, 35), [0, 0, 255]); + assert_eq!(&fin.2[..], &fresh.2[..], "a final render after a preview matches one with a fresh cache"); +} + +/// An image drawn under a transfer function (`/TR`) is decoded with it, even when the same +/// image was drawn without one just before (and the other way round). +#[test] +fn decoded_images_under_a_transfer_function_are_not_shared() { + let pdf = image_cache_pdf( + // The image, then the same image at the same place under the inverting function + // (one decode target), then the image alone on the right. + "q 50 0 0 50 10 10 cm /A Do Q q /Inv gs 50 0 0 50 10 10 cm /A Do Q q 40 0 0 40 80 15 cm /A Do Q", + "<< /XObject << /A 5 0 R >> /ExtGState << /Inv << /TR 6 0 R >> >> >>", + &[(ONE_PIXEL_RGB, b"\xff\x00\x00"), ("<< /FunctionType 4 /Domain [0 1] /Range [0 1] /Length 14 >>", b"{ 1 exch sub }")], + ); + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let out = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(out.error.is_none(), "{:?}", out.error); + let w = out.width as usize; + assert_eq!(rgb_at(&out.rgba, w, 35, 35), [0, 255, 255], "the transfer function was not applied to a cached image"); + assert_eq!(rgb_at(&out.rgba, w, 100, 35), [255, 0, 0]); +} + +/// The decoded-image cache keyed every decode by the size the image is drawn at, but only +/// a JPX stream decodes differently for a size: a scan was decoded again at every zoom step +/// and kept once per zoom level (vendored hayro-interpret patch 32). An image drawn at five +/// sizes is now decoded once, unless JPX decodes it or its soft mask. +#[test] +fn decoded_images_serve_every_size_they_are_drawn_at() { + 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}; + use std::io::Write; + + /// Asks for every raster image at the size it is drawn at, as the renderer does, and + /// counts the decoded images it was handed. + #[derive(Default)] + struct Draws(usize); + impl<'a> Device<'a> for Draws { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {} + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) { + let [a, b, c, d, ..] = transform.as_coeffs(); + let target = ((a.hypot(b) * f64::from(image.width())).ceil() as u32, (c.hypot(d) * f64::from(image.height())).ceil() as u32); + if let Image::Raster(r) = image { + r.with_rgba(|_, _| self.0 += 1, Some(target)); + } + } + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + + let flate = |data: &[u8]| { + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + z.write_all(data).unwrap(); + z.finish().unwrap() + }; + let pixels = flate(&(0..64 * 64 * 3).map(|i| (i % 251) as u8).collect::>()); + let image = |extra: &str| { + format!( + "<< /Type /XObject /Subtype /Image /Width 64 /Height 64 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode {extra}/Length {} >>", + pixels.len() + ) + }; + let (plain, jpx_masked) = (image(""), image("/SMask 7 0 R ")); + let jpx_mask = + "<< /Type /XObject /Subtype /Image /Width 64 /Height 64 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /JPXDecode /Length 4 >>"; + // `name` drawn at five sizes, from 10 to 40 pt square. + let decodes = |name: &str| { + let content = [(10, 0, 0), (20, 12, 0), (30, 34, 0), (40, 66, 0), (25, 100, 40)] + .map(|(s, x, y)| format!("q {s} 0 0 {s} {x} {y} cm /{name} Do Q")) + .join(" "); + let bytes = image_cache_pdf( + &content, + "<< /XObject << /A 5 0 R /B 6 0 R >> >>", + &[(&plain, &pixels), (&jpx_masked, &pixels), (jpx_mask, b"\0\0\0\0")], + ); + let parsed = Pdf::new(Arc::new(bytes)).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, 130.0, 70.0), &cache, page.xref(), InterpreterSettings::default()); + let mut device = Draws::default(); + interpret_page(page, &mut ctx, &mut device); + assert_eq!(device.0, 5, "every draw of /{name} was handed its pixels"); + cache.decoded_image_decodes() + }; + assert_eq!(decodes("A"), 1, "a Flate image decodes the same at every size"); + assert_eq!(decodes("B"), 5, "a JPX soft mask decodes for the size it is drawn at"); +} + +// ICC profiles whose stream dictionaries are the same (vendored hayro-interpret patch 48). +mod icc_profile_cache; + +// A symbolic TrueType font draws through its (3, 0) cmap subtable first (#4156). +mod symbolic_truetype_cmap; + +/// Render `pdf`'s first page at 72 dpi with no time limit beyond the test's own. +fn render_first_page(pdf: Vec) -> RenderedPage { + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let out = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(out.error.is_none(), "{:?}", out.error); + out +} + +/// A spot-colour image runs its tint transform once per possible sample, not once per pixel +/// (vendored hayro-interpret patch 28). Fillable character sheets draw their background art +/// as an 8-bit DeviceN image whose tint transform is a PostScript calculator program: run for +/// each of a 3304 × 4105 background's pixels, every page took four seconds and 430 MB to +/// draw, again after each field was filled. This image (2048 × 2048, a 9,000-operator +/// program) took about 40 seconds that way; from a 256-entry table it takes milliseconds. +/// The runs are counted (patch 34) rather than timed: a deadline also failed when a slow, +/// busy CI machine took longer than it allowed, and the count is the same on every machine. +#[test] +fn spot_colour_images_run_their_tint_transform_once_per_sample_value() { + const SIDE: usize = 2048; + // 8,990 operators that change nothing (the calculator allows 10,000 a run), then + // tint t → RGB (1 − t, t, 0). + let program = format!("{{ {}dup 1 exch sub exch 0 }}", "dup pop ".repeat(4495)); + // Each row: tint 0 on the left half, tint 1 on the right. + let row: Vec = [vec![0u8; SIDE / 2], vec![255u8; SIDE / 2]].concat(); + let data = row.repeat(SIDE); + let image = format!( + "<< /Type /XObject /Subtype /Image /Width {SIDE} /Height {SIDE} /ColorSpace [/Separation /Spot /DeviceRGB 6 0 R] /BitsPerComponent 8 /Length {} >>", + data.len() + ); + let function = format!("<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Length {} >>", program.len()); + let pdf = + image_cache_pdf("q 130 0 0 70 0 0 cm /A Do Q", "<< /XObject << /A 5 0 R >> >>", &[(&image, &data), (&function, program.as_bytes())]); + // The count is per thread, and this render runs on this one. + let before = hayro::hayro_interpret::color::tint_transform_runs(); + let out = render_first_page(pdf); + let runs = hayro::hayro_interpret::color::tint_transform_runs() - before; + assert_eq!(runs, 256, "the tint transform runs once per possible sample value, not once for each of the {} pixels", SIDE * SIDE); + let w = out.width as usize; + assert_eq!(rgb_at(&out.rgba, w, 10, 35), [255, 0, 0], "tint 0"); + assert_eq!(rgb_at(&out.rgba, w, 120, 35), [0, 255, 0], "tint 1"); +} + +/// Images converted through patch 28's table keep the colour of every sample: the samples of +/// a pixel in order, each through its own `/Decode` range, rows that end mid-byte, and an image +/// whose data stops part-way through a row (that row is padded with decoded zeros, as before). +#[test] +fn images_converted_through_a_table_keep_every_samples_colour() { + let close = |got: [u8; 3], want: [u8; 3], at: (usize, usize)| { + assert!(got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 1), "pixel {at:?}: got {got:?}, want {want:?}"); + }; + // DeviceN of two inks at 4 bits (256 possible pixels) shown as RGB (a, b, 0), the first + // ink's range reversed. 32 × 16 pixels, each drawn 4 pt square. + let mut data = Vec::new(); + for y in 0..16 { + for x in 0..32 { + let (a, b) = (x % 16, (x / 16 + 2 * y) % 16); + data.push(((a << 4) | b) as u8); + } + } + let image = format!( + "<< /Type /XObject /Subtype /Image /Width 32 /Height 16 /ColorSpace [/DeviceN [/A /B] /DeviceRGB 6 0 R] /BitsPerComponent 4 /Decode [1 0 0 1] /Length {} >>", + data.len() + ); + let pdf = image_cache_pdf( + "q 128 0 0 64 0 0 cm /A Do Q", + "<< /XObject << /A 5 0 R >> >>", + &[(&image, &data), ("<< /FunctionType 4 /Domain [0 1 0 1] /Range [0 1 0 1 0 1] /Length 5 >>", b"{ 0 }")], + ); + let out = render_first_page(pdf); + let w = out.width as usize; + for y in 0..16 { + for x in 0..32 { + let (a, b) = (x % 16, (x / 16 + 2 * y) % 16); + // The 64 pt image sits at the bottom of the 70 pt page. + close(rgb_at(&out.rgba, w, 4 * x + 2, 6 + 4 * y + 2), [(255 - 17 * a) as u8, (17 * b) as u8, 0], (x, y)); + } + } + + // DeviceGray at 2 bits, reversed: 31 pixels (62 bits, so each row ends mid-byte) by 6. + let sample = |x: usize, y: usize| (x + y) % 4; + let mut data = Vec::new(); + for y in 0..6 { + let mut row = [0u8; 8]; + for x in 0..31 { + row[x / 4] |= (sample(x, y) as u8) << (6 - 2 * (x % 4)); + } + data.extend_from_slice(&row); + } + let image = format!( + "<< /Type /XObject /Subtype /Image /Width 31 /Height 6 /ColorSpace /DeviceGray /BitsPerComponent 2 /Decode [1 0] /Length {} >>", + data.len() + ); + let pdf = image_cache_pdf("q 124 0 0 60 0 0 cm /A Do Q", "<< /XObject << /A 5 0 R >> >>", &[(&image, &data)]); + let out = render_first_page(pdf); + let w = out.width as usize; + for y in 0..6 { + for x in 0..31 { + // The 60 pt image at the bottom of the page: rows 10 pt high. + close(rgb_at(&out.rgba, w, 4 * x + 2, 10 + 10 * y + 5), [(255 - 85 * sample(x, y)) as u8; 3], (x, y)); + } + } + + // DeviceGray at 8 bits through the range [0.2 0.8] (so not drawn from the raw bytes): + // 31 × 12 declared, data for 8 rows and the first 12 pixels of the 9th. + let sample = |x: usize, y: usize| (x * 7 + y * 3) % 256; + let mut data = Vec::new(); + for y in 0..9 { + let row: Vec = (0..31).map(|x| sample(x, y) as u8).collect(); + data.extend_from_slice(if y < 8 { &row[..] } else { &row[..12] }); + } + let image = format!( + "<< /Type /XObject /Subtype /Image /Width 31 /Height 12 /ColorSpace /DeviceGray /BitsPerComponent 8 /Decode [0.2 0.8] /Length {} >>", + data.len() + ); + let pdf = image_cache_pdf("q 124 0 0 60 0 0 cm /A Do Q", "<< /XObject << /A 5 0 R >> >>", &[(&image, &data)]); + let out = render_first_page(pdf); + let w = out.width as usize; + for y in 0..9 { + for x in 0..31 { + // The rest of the 9th row is decoded zero (black), not sample 0 (0.2 under this range). + let gray = if y < 8 || x < 12 { (51.0 + 0.6 * sample(x, y) as f64).round() as u8 } else { 0 }; + // The rows present keep the declared rows' height: 60 pt / 12. + close(rgb_at(&out.rgba, w, 4 * x + 2, 10 + 5 * y + 2), [gray; 3], (x, y)); + } + } +} + +/// Patch 28's table converts each entry on its own: a tint transform that gives the wrong +/// number of components for a sample value the image never uses leaves the colours of the +/// values it does use alone. Converted as one batch, the one component tint 0 gives here +/// shifted every later entry, and tint 1's red came out black (PR #1691 review). +#[test] +fn a_malformed_tint_for_an_unused_sample_keeps_the_used_colours() { + // Tint 0 gives one component instead of the three `/Range` declares; any other tint red. + let program = b"{ dup 0 eq { pop 0 } { pop 1 0 0 } ifelse }"; + // 32 × 32 = 1,024 pixels, more than the table's 256 entries, all sample 255 (tint 1). + let data = [255u8; 32 * 32]; + let centre = |space: &str| { + let image = format!( + "<< /Type /XObject /Subtype /Image /Width 32 /Height 32 /ColorSpace [{space} /DeviceRGB 6 0 R] /BitsPerComponent 8 /Length {} >>", + data.len() + ); + let function = format!("<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Length {} >>", program.len()); + let pdf = image_cache_pdf("q 130 0 0 70 0 0 cm /A Do Q", "<< /XObject << /A 5 0 R >> >>", &[(&image, &data), (&function, program)]); + let out = render_first_page(pdf); + rgb_at(&out.rgba, out.width as usize, 65, 35) + }; + assert_eq!([centre("/Separation /Spot"), centre("/DeviceN [/Spot]")], [[255, 0, 0]; 2], "Separation, then one-ink DeviceN"); +} + +/// 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, ..Default::default() }); + 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)"); + } +} + +// #3601: UniJIS-UCS2-HW-H maps code 0x0020 to CID 231 / U+2002. The PDF's +// explicit 500-unit width must win over the substitute's ordinary-space width. +fn japanese_spacing_pdf(content: &str, widths: &str) -> Vec { + 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 180 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 /MS-Mincho /Encoding /UniJIS-UCS2-HW-H /DescendantFonts [6 0 R] >> endobj +6 0 obj << /Type /Font /Subtype /CIDFontType2 /BaseFont /MS-Mincho /CIDSystemInfo << /Registry (Adobe) /Ordering (Japan1) /Supplement 4 >> + /FontDescriptor 7 0 R /DW 1000 {widths} >> endobj +7 0 obj << /Type /FontDescriptor /FontName /MS-Mincho /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() + ) + .into_bytes() +} + +#[test] +fn japanese_substitute_missing_spaces_draw_no_ink() { + if pdfcraft_fonts::document_japanese_font().is_none() { + eprintln!("built without craft-fonts (CRAFT_FONTS_DIR unset): no Japanese face to check"); + return; + } + let out = render_first_page(japanese_spacing_pdf("BT /F1 20 Tf 10 30 Td <002000200020> Tj ET", "/W [231 [500]]")); + assert!(out.rgba.as_chunks::<4>().0.iter().all(|pixel| *pixel == [255; 4]), "spacing glyphs must leave the page white"); +} + +#[test] +fn japanese_substitute_spaces_keep_pdf_widths_and_unicode() { + if pdfcraft_fonts::document_japanese_font().is_none() { + eprintln!("built without craft-fonts (CRAFT_FONTS_DIR unset): no Japanese face to check"); + return; + } + for (widths, second_x) in [("/W [231 [500]]", 50), ("", 70)] { + let pdf = japanese_spacing_pdf("BT /F1 20 Tf 10 30 Td <65E500200020672C> Tj ET", widths); + let out = render_first_page(pdf.clone()); + // 日 advances 20 pt; each space advances 10 pt (/W) or 20 pt (/DW). + // A second independent text matrix pins 本 at the expected original PDF position. + let reference = format!("BT /F1 20 Tf 1 0 0 1 10 30 Tm <65E5> Tj 1 0 0 1 {second_x} 30 Tm <672C> Tj ET"); + let expected = render_first_page(japanese_spacing_pdf(&reference, widths)); + assert!(expected.rgba.as_chunks::<4>().0.iter().any(|pixel| pixel[0] < 128), "the Japanese control must draw ink"); + assert!(out.rgba.as_ref() == expected.rgba.as_ref(), "spaces must be blank without changing {widths:?} advances"); + let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let text = renderer.render(RenderRequest { page: 0, kind: RequestKind::Text, ..Default::default() }); + assert!(text.error.is_none(), "{:?}", text.error); + let glyphs: String = text.text.unwrap().glyphs.iter().map(|glyph| glyph.text.as_str()).collect(); + assert_eq!(glyphs, "日\u{2002}\u{2002}本", "glyph substitution must not rewrite Unicode"); + } +} + +/// 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 = ('\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, ..Default::default() }); + 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(¬def)); + 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> 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, ..Default::default() }); + 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 噂"); + } +} + +/// InDesign fades are luminosity soft masks whose group is one extended axial `sh`: a +/// gradient over the whole page. hayro 0.7 sampled each one pixel by pixel through the +/// shading function, so a report page with 40 of them took 15 s at 144 dpi. Vendored hayro +/// patch (backport of hayro 0.8): axial and radial shadings that a native gradient can +/// represent exactly are drawn as one, with stops placed at stitching-function bounds. +#[test] +fn axial_and_radial_shadings_draw_as_native_gradients() { + use hayro::hayro_interpret::font::Glyph; + use hayro::hayro_interpret::hayro_syntax::Pdf; + use hayro::hayro_interpret::pattern::Pattern; + use hayro::hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, Paint, PathDrawMode, SoftMask, TransformExt, interpret_page, + }; + use kurbo::{Affine, BezPath, Rect, Shape}; + + /// For every shading painted, the offsets of its native gradient stops, or `None` when + /// it has to be sampled pixel by pixel. + #[derive(Default)] + struct Gradients(Vec>>); + impl<'a> Device<'a> for Gradients { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &Paint<'a>, _: &PathDrawMode) { + if let Paint::Pattern(pattern) = paint + && let Pattern::Shading(s) = pattern.as_ref() + { + let bbox = (transform * path.clone()).bounding_box(); + let native = s.encode().as_svg_gradient(bbox, 0.01); + self.0.push(native.map(|g| g.stops.iter().map(|stop| stop.offset).collect())); + } + } + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {} + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + + // A 40 × 20 pt page painting `/Sh0 sh`, with optional extra objects from 6 0 obj on. + let pdf = |shading: &str, extra: &str| { + let content = "/Sh0 sh"; + 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 20] /Contents 4 0 R /Resources << /Shading << /Sh0 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj {shading} endobj +{extra}trailer << /Root 1 0 R >> +%%EOF", + content.len() + ) + .into_bytes() + }; + let gradients = |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, 40.0, 20.0), &cache, page.xref(), InterpreterSettings::default()); + let mut device = Gradients::default(); + interpret_page(page, &mut ctx, &mut device); + device.0 + }; + let render = |bytes: Vec| { + let mut r = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(p.error.is_none(), "{:?}", p.error); + p + }; + let grey = |p: &RenderedPage, x: u32| i32::from(p.rgba[((10 * p.width + x) * 4) as usize]); + let ramp = "/FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1"; + // A function-based shading (always sampled) that draws the same picture is the reference; + // its /Range clamps to 0..1. + let sampled = |code: &str| { + let extra = + format!("6 0 obj << /FunctionType 4 /Domain [0 40 0 20] /Range [0 1] /Length {} >> stream\n{code}\nendstream endobj\n", code.len()); + pdf("<< /ShadingType 1 /ColorSpace /DeviceGray /Domain [0 40 0 20] /Function 6 0 R >>", &extra) + }; + + // A steep extended axial ramp, x 10 to 14: one stop at each end, drawn at pixel centres + // (a half-pixel shift would move each pixel 32 levels) and padded on both sides. + let axial = pdf(&format!("<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [10 0 14 0] /Extend [true true] /Function << {ramp} >> >>"), ""); + assert_eq!(gradients(&axial), vec![Some(vec![0.0, 1.0])], "an extended axial shading is a native gradient"); + let native = render(axial); + let reference = render(sampled("{ pop 10 sub 4 div }")); + for (x, expected) in [(2, 0), (9, 0), (10, 32), (11, 96), (12, 159), (13, 223), (14, 255), (30, 255)] { + assert!((grey(&native, x) - expected).abs() <= 4, "axial, x {x}: {} instead of {expected}", grey(&native, x)); + assert!( + (grey(&native, x) - grey(&reference, x)).abs() <= 4, + "axial, x {x}: native {} vs sampled {}", + grey(&native, x), + grey(&reference, x) + ); + } + + // An extended radial shading matches the sampled one everywhere. + let radial = + pdf(&format!("<< /ShadingType 3 /ColorSpace /DeviceGray /Coords [20 10 0 20 10 8] /Extend [true true] /Function << {ramp} >> >>"), ""); + assert!(matches!(gradients(&radial).as_slice(), [Some(_)]), "an extended radial shading is a native gradient"); + let native = render(radial); + let reference = render(sampled("{ 10 sub dup mul exch 20 sub dup mul add sqrt 8 div }")); + let worst = native.rgba.iter().zip(reference.rgba.iter()).map(|(a, b)| a.abs_diff(*b)).max(); + assert!(worst.is_some_and(|d| d <= 4), "radial: native and sampled differ by {worst:?} levels"); + + // A stitched function peaking at a quarter of the way gets a stop exactly at its bound. + let stitched = pdf( + "<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 40 0] /Extend [true true] /Function << /FunctionType 3 /Domain [0 1] /Bounds [0.25] /Encode [0 1 0 1] /Functions [<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>] >> >>", + "", + ); + let stops = gradients(&stitched); + assert!(matches!(stops.as_slice(), [Some(offsets)] if offsets.contains(&0.25)), "a stop at the stitching bound: {stops:?}"); + let native = render(stitched); + for (x, expected) in [(0, 13), (9, 242), (10, 250), (39, 4)] { + assert!((grey(&native, x) - expected).abs() <= 4, "stitched, x {x}: {} instead of {expected}", grey(&native, x)); + } + + // Without /Extend the area beyond the axis keeps the background, which a native gradient + // can't leave out: still sampled. Function-based shadings are always sampled. + let unextended = pdf(&format!("<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [10 0 14 0] /Function << {ramp} >> >>"), ""); + assert_eq!(gradients(&unextended), vec![None], "an axial shading without /Extend that covers more than its axis"); + assert_eq!(gradients(&sampled("{ pop pop 0.5 }")), vec![None], "a function-based shading"); + + // The report's case: a luminosity soft mask whose group is one extended axial `sh` over + // the page. Red through a mask fading in from x 0 to 40 over white: 255, 255 − t, 255 − t. + let content = "/M gs 1 0 0 rg 0 0 40 20 re f"; + let masked = 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 20] /Contents 4 0 R /Resources << /ExtGState << /M 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /ExtGState /SMask << /Type /Mask /S /Luminosity /G 6 0 R >> >> endobj +6 0 obj << /Type /XObject /Subtype /Form /BBox [-32768 -32768 32767 32767] /Group << /S /Transparency /CS /DeviceGray >> /Resources << /Shading << /Sh0 << /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 40 0] /Extend [true true] /Function << {ramp} >> >> >> >> /Length 7 >> stream +/Sh0 sh +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len() + ); + let p = render(masked.into_bytes()); + for x in [2u32, 20, 37] { + let px = &p.rgba[((10 * p.width + x) * 4) as usize..][..4]; + let faded = 255 - (255.0 * (f64::from(x) + 0.5) / 40.0).round() as i32; + assert!(px[0] == 255 && (i32::from(px[1]) - faded).abs() <= 4 && px[1] == px[2], "masked, x {x}: {px:?}, green and blue {faded}"); + } +} + +/// 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, ..Default::default() }); + 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"); +} + +/// Issue #2128: PowerPoint fades a gradient fill (an axial shading pattern) through a soft +/// mask. vello_cpu 0.0.8 copies an opaque gradient straight over fully covered tiles, +/// skipping the mask, so the fade came out solid. Images get around that by declaring +/// transparency while a mask or blend mode is set; native gradients can't, so they are +/// sampled as a texture then. +#[test] +fn soft_mask_applies_to_a_shading_pattern_fill() { + let content = "/Pattern cs /P0 scn /M gs 0 0 100 100 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 100 100] /Contents 4 0 R /Resources << /ExtGState << /M 5 0 R >> /Pattern << /P0 7 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +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 /CS /DeviceRGB >> /Length 20 >> stream +0 g 0 0 50 100 re f +endstream endobj +7 0 obj << /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Extend [true true] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [1 0 0] /N 1 >> >> >> 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, ..Default::default() }); + 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"); +} + +/// Issue #3176: `CA` / `ca` belong to the graphics state at paint time, so a `gs` after +/// `SCN` / `scn` still fades a shading pattern, and a stroke takes the stroke alpha. The +/// pattern took the fill alpha current at `scn`/`SCN`, so both shapes came out opaque. +#[test] +fn shading_pattern_uses_the_alpha_current_at_paint_time() { + let content = "/Pattern CS /P0 SCN /S1 gs 10 w 0 75 m 100 75 l S /Pattern cs /P0 scn /F1 gs 0 0 100 50 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 100 100] /Contents 4 0 R + /Resources << /ExtGState << /S1 << /CA 0.25 >> /F1 << /ca 0.25 >> >> /Pattern << /P0 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Extend [true true] + /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [1 0 0] /N 1 >> >> >> 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, ..Default::default() }); + 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(); + // Red at 25% over white is about (255, 191, 191); opaque red would be (255, 0, 0). + for (what, x, y) in [("the stroke under /CA 0.25", 50, 25), ("the fill under /ca 0.25", 50, 75)] { + let c = px(x, y); + assert!(c[0] >= 250 && (185..=197).contains(&c[1]) && (185..=197).contains(&c[2]), "{what}: {c:?}"); + } +} + +/// 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, ..Default::default() }); + 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"); + } +} + +/// ISO 32000-2 §9.3.6: text in a Type 3 font is rendered from its glyph descriptions in every +/// text rendering mode but 3 and 7 (vendored hayro-interpret patches 42 and 64 in +/// `show_glyph`). Print-stream PDFs set `2 Tr` for Type 3 bitmap fonts, and the glyph +/// procedure ran twice (fill, then stroke), so its anti-aliased edges doubled up and the text +/// looked bold; under `1 Tr` an uncoloured glyph took the stroking colour. Modes 3 and 7 +/// render nothing (`tests/it/type3_text_clip.rs` covers the clip). Both glyph kinds have +/// edges between pixels. +#[test] +fn rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled() { + let render = |glyph: &str, mode: u8| { + let content = format!("BT 1 0 0 RG 6 w 0 g /F1 20 Tf {mode} Tr 10.3 10.3 Td (A) 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 40 40] /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 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 +{glyph} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + glyph.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, ..Default::default() }); + assert!(p.error.is_none(), "{glyph} {mode} Tr: {:?}", p.error); + p.rgba + }; + for glyph in ["1 0 0 0 1 1 d1 0.12 0.12 0.71 0.71 re f", "1 0 d0 0.12 0.12 0.71 0.71 re f"] { + let filled = render(glyph, 0); + assert!(filled.as_chunks::<4>().0.iter().any(|p| p[0] < 128), "{glyph}: the glyph paints"); + for mode in [1, 2, 4, 5, 6] { + assert!(render(glyph, mode) == filled, "{glyph}: {mode} Tr draws as 0 Tr"); + } + for mode in [3, 7] { + assert!(render(glyph, mode).as_chunks::<4>().0.iter().all(|p| p[..3] == [255, 255, 255]), "{glyph}: {mode} Tr hides the glyph"); + } + } +} + +// #1201: /DefaultGray, /DefaultRGB and /DefaultCMYK (vendored hayro-interpret patch 38). +mod default_colour_spaces; + +// #4260: DeviceN colours with many inks, and tint transforms that cannot be evaluated +// (vendored hayro-syntax patch 28, hayro-interpret patch 55). +mod devicen_inks; + +/// #3109 / #3116: clipping-only text (`7 Tr`) installs a clip for subsequent painting +/// but draws no pixels itself. +#[test] +fn test_clip_only_text_raster_preserves_clipping_without_painting() { + let pdf_bytes = |content: &str| { + Arc::new( + 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 200 100] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF", + content.len() + ) + .into_bytes(), + ) + }; + let render = |content: &str| { + let mut r = PageRenderer::new(pdf_bytes(content), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(p.error.is_none(), "{content}: {:?}", p.error); + p + }; + let white = [255, 255, 255, 255]; + + // The text alone paints nothing. + let p = render("BT /F1 40 Tf 7 Tr 20 60 Td (Hi) Tj ET"); + assert!(p.rgba.as_chunks::<4>().0.iter().all(|c| *c == white), "7 Tr alone leaves the page blank"); + + // A fill afterwards shows only through the text-shaped clip. + let p = render("q BT /F1 40 Tf 7 Tr 20 60 Td (Hi) Tj ET 0 0 1 rg 0 0 200 100 re f Q"); + let pixels = p.rgba.as_chunks::<4>().0; + assert!(pixels.contains(&[0, 0, 255, 255]), "fill shows through the 7 Tr clip"); + assert!(pixels.contains(&white), "the clip is text-shaped, not the whole page"); +}