M3: harden render and CLI failure surfaces (#669)

* M3: harden render and CLI failure surfaces

Make content truncation observable, reject unknown CLI options, expose deterministic render work counters, and scope render failures to exact requests. Refuse silently reduced whole-page export rasters while preserving tiled rendering, and add heavy-vector and CLI regression coverage.

Co-authored-by: Codex <[email protected]>

* Render: remember a failed render by page and kind again

A key with scale and tag made every zoom level and tile a new key: a
pathological page tied up a worker for the watchdog limit per request
(exhausting worker replacements) and was re-parsed each time. The
repeated-parsing check asks for a new generation and scale again.

* Export: pages too large at the chosen dpi export at the most the renderer allows, and say so

#669 made every Exporter refuse oversized whole-page renders, which
reached File > Export to image, doc_export_* and OCR: at 600 dpi any page
side over 13.65 in (Legal, A3, Tabloid) failed instead of exporting. The
exporter clamps again; Exporter::dpi_used reports the resolution a page
is drawn at, which the Export dialog's message and doc_export_png's
lower_dpi list mention. A source with reject_oversize still refuses
(CLI render keeps its strict config).

* doc_export_images: describe lower_dpi

* Render: ContentTruncated is reported once per page; the hayro-interpret patch is recorded

Nested forms, Type 3 glyphs and patterns are interpreted again and each
reported the exhausted budget; pdfcraft-render now keeps one. The warning
is part of hayro-interpret patch (18) (the page content budget): marked
in the code and described in vendor/README.md.

* CLI render: print render warnings (skipped content) to stderr

* CLI: the option check refuses unknown options only; commands report missing values themselves

text's own --page parsing (and its test on main) says what a page value
must be; the generic "needs a value" pre-empted it.

---------

Co-authored-by: Codex <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-09 22:32:48 -04:00
1 parent a5e378fd4b
commit 306bbcedf9
28 files changed
+535 -52

No files matched your search

+2 -1
View File
@@ -20,7 +20,8 @@ pub use inspect::{
};
pub use pixels::Pixels;
pub use raster::{
MAX_PIXELS, MAX_SIDE, PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderedPage, RequestKind, Tile, device_pixels, effective_scale,
MAX_PIXELS, MAX_SIDE, PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderStats, RenderWarning, RenderedPage, RequestKind, Tile,
device_pixels, effective_scale,
};
pub use text::{PageText, TextGlyph};
+300 -29
View File
@@ -10,7 +10,8 @@
use std::panic::{AssertUnwindSafe, catch_unwind};
#[cfg(not(target_arch = "wasm32"))]
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::AtomicBool;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::SyncSender;
#[cfg(not(target_arch = "wasm32"))]
use std::sync::mpsc::{Receiver, sync_channel};
@@ -20,9 +21,9 @@ use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use work_queue::{ResultReceiver, WorkQueue};
use hayro::hayro_interpret::InterpreterSettings;
use hayro::hayro_interpret::font::{FontData, FontQuery};
use hayro::hayro_interpret::hayro_cmap::CidFamily;
use hayro::hayro_interpret::{InterpreterSettings, InterpreterWarning};
use hayro::hayro_syntax::Pdf;
use hayro::vello_cpu::color::palette::css::WHITE;
use hayro::{RenderCache, RenderSettings, render_into, render_size};
@@ -57,6 +58,9 @@ pub struct RenderConfig {
pub layers: Arc<Vec<(i32, i32, bool)>>,
/// View ▸ Hide all comments: markup annotations aren't drawn (fields and links still are).
pub hide_comments: bool,
/// Refuse a whole-page raster that would be reduced by the renderer's hard size caps.
/// Tiled requests remain available for pages that exceed those caps.
pub reject_oversize: bool,
}
impl RenderConfig {
@@ -154,6 +158,22 @@ pub struct RenderRequest {
pub tag: u64,
}
/// What a render that panicked or overran the watchdog is remembered by: its page and kind. Not
/// its scale, tile or tag: the canvas tags every zoom level and tile anew, so a wider key would
/// let one pathological page tie up a worker for the full watchdog limit per zoom and per tile
/// (exhausting worker replacements) and re-parse the document for every new request.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
struct RequestFailureKey {
page: usize,
kind: RequestKind,
}
impl From<RenderRequest> for RequestFailureKey {
fn from(req: RenderRequest) -> Self {
Self { page: req.page, kind: req.kind }
}
}
#[derive(Debug)]
pub struct RenderedPage {
pub request: RenderRequest,
@@ -165,9 +185,74 @@ pub struct RenderedPage {
pub error: Option<String>,
/// For `RequestKind::Text`.
pub text: Option<Arc<crate::text::PageText>>,
/// Non-fatal conditions that made the result partial or otherwise noteworthy.
pub warnings: Vec<RenderWarning>,
pub millis: u32,
}
/// A non-fatal condition reported while interpreting a page.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RenderWarning {
/// Content was skipped after the per-page decoded-content budget was exhausted.
ContentTruncated,
}
/// Counters for work performed by a renderer or render pool.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct RenderStats {
/// Number of parser construction attempts.
pub parser_builds: u64,
/// Number of pixel page interpretations, including tiled requests.
pub page_interpretations: u64,
/// Number of render requests accepted for processing.
pub render_requests: u64,
/// Number of requests that render only a tile.
pub tile_requests: u64,
/// Number of text-layer interpretations.
pub text_interpretations: u64,
/// Number of pixel rasterizations.
pub rasterizations: u64,
}
#[derive(Default)]
struct AtomicRenderStats {
parser_builds: AtomicU64,
page_interpretations: AtomicU64,
render_requests: AtomicU64,
tile_requests: AtomicU64,
text_interpretations: AtomicU64,
rasterizations: AtomicU64,
}
impl AtomicRenderStats {
fn record_request(&self, req: RenderRequest) {
self.render_requests.fetch_add(1, Ordering::Relaxed);
if req.tile.is_some() {
self.tile_requests.fetch_add(1, Ordering::Relaxed);
}
match req.kind {
RequestKind::Pixels => {
self.page_interpretations.fetch_add(1, Ordering::Relaxed);
self.rasterizations.fetch_add(1, Ordering::Relaxed);
}
RequestKind::Text => {
self.text_interpretations.fetch_add(1, Ordering::Relaxed);
}
}
}
fn snapshot(&self) -> RenderStats {
RenderStats {
parser_builds: self.parser_builds.load(Ordering::Relaxed),
page_interpretations: self.page_interpretations.load(Ordering::Relaxed),
render_requests: self.render_requests.load(Ordering::Relaxed),
tile_requests: self.tile_requests.load(Ordering::Relaxed),
text_interpretations: self.text_interpretations.load(Ordering::Relaxed),
rasterizations: self.rasterizations.load(Ordering::Relaxed),
}
}
}
/// Clamp a requested scale so the output respects `MAX_SIDE` and `MAX_PIXELS`.
pub fn effective_scale(width_pt: f32, height_pt: f32, scale: f32) -> f32 {
let (w, h) = (width_pt.max(1.0), height_pt.max(1.0));
@@ -192,7 +277,13 @@ pub fn device_pixels(pt: f32, scale: f32) -> u32 {
/// and reported as `Err((message, panicked))`.
type Output = (u32, u32, Pixels, Option<Arc<crate::text::PageText>>);
fn render_page<'a>(pdf: &'a Pdf, cache: &RenderCache<'a>, settings: &InterpreterSettings, req: RenderRequest) -> Result<Output, (String, bool)> {
fn render_page<'a>(
pdf: &'a Pdf,
cache: &RenderCache<'a>,
settings: &InterpreterSettings,
reject_oversize: bool,
req: RenderRequest,
) -> Result<Output, (String, bool)> {
if req.kind == RequestKind::Text {
return match catch_unwind(AssertUnwindSafe(|| crate::text::extract_page(pdf, req.page, settings))) {
Ok(Some(t)) => Ok((0, 0, Pixels::default(), Some(Arc::new(t)))),
@@ -224,6 +315,13 @@ fn render_page<'a>(pdf: &'a Pdf, cache: &RenderCache<'a>, settings: &Interpreter
}
None => {
let scale = effective_scale(w, h, req.scale);
if reject_oversize && (!(req.scale.is_finite() && req.scale > 0.0) || req.scale > scale) {
return Err(format!(
"requested page {} raster scale {req_scale} exceeds renderer limits (maximum scale {scale})",
req.page + 1,
req_scale = req.scale
));
}
// hayro floors the size when none is given, losing the partial edge pixels. The
// scale keeps each side within MAX_SIDE (give or take float noise), so it fits u16.
let side = |pt: f32| device_pixels(pt, scale).min(MAX_SIDE as u32) as u16;
@@ -243,43 +341,89 @@ fn render_page<'a>(pdf: &'a Pdf, cache: &RenderCache<'a>, settings: &Interpreter
}
}
fn finish(req: RenderRequest, start: Stopwatch, r: Result<Output, (String, bool)>) -> RenderedPage {
fn finish(req: RenderRequest, start: Stopwatch, r: Result<Output, (String, bool)>, warnings: Vec<RenderWarning>) -> RenderedPage {
let millis = start.millis();
match r {
Ok((width, height, rgba, text)) => RenderedPage { request: req, width, height, rgba, error: None, text, millis },
Err((e, _)) => RenderedPage { request: req, width: 0, height: 0, rgba: Pixels::default(), error: Some(e), text: None, millis },
Ok((width, height, rgba, text)) => RenderedPage { request: req, width, height, rgba, error: None, text, warnings, millis },
Err((e, _)) => RenderedPage { request: req, width: 0, height: 0, rgba: Pixels::default(), error: Some(e), text: None, warnings, millis },
}
}
fn warning_settings(settings: &InterpreterSettings, warnings: Arc<Mutex<Vec<RenderWarning>>>) -> InterpreterSettings {
let target = warnings;
InterpreterSettings {
warning_sink: Arc::new(move |warning| {
// Nested forms, Type 3 glyphs and patterns are interpreted again and each reports the
// exhausted budget: once per page is enough.
if matches!(warning, InterpreterWarning::ContentTruncated) {
let mut warnings = lock(&target);
if !warnings.contains(&RenderWarning::ContentTruncated) {
warnings.push(RenderWarning::ContentTruncated);
}
}
}),
..settings.clone()
}
}
fn take_warnings(warnings: &Mutex<Vec<RenderWarning>>) -> Vec<RenderWarning> {
std::mem::take(&mut *lock(warnings))
}
/// A single-threaded renderer over one parsed document (used by the CLI and tests).
pub struct PageRenderer {
bytes: Arc<Vec<u8>>,
config: RenderConfig,
pdf: Option<Pdf>,
settings: InterpreterSettings,
stats: RenderStats,
}
impl PageRenderer {
pub fn new(bytes: Arc<Vec<u8>>, config: RenderConfig) -> Self {
let pdf = parse(&bytes, config.password.as_deref());
let settings = config.settings();
Self { bytes, config, pdf, settings }
Self { bytes, config, pdf, settings, stats: RenderStats { parser_builds: 1, ..RenderStats::default() } }
}
pub fn page_count(&self) -> usize {
self.pdf.as_ref().map(|p| p.pages().len()).unwrap_or(0)
}
/// Snapshot the work counters collected by this renderer.
pub fn stats(&self) -> RenderStats {
self.stats
}
/// Render one page. Never panics.
pub fn render(&mut self, req: RenderRequest) -> RenderedPage {
let start = Stopwatch::start();
let Some(pdf) = self.pdf.as_ref() else { return finish(req, start, Err(("the document could not be parsed".into(), false))) };
if self.pdf.is_some() {
self.stats.render_requests += 1;
if req.tile.is_some() {
self.stats.tile_requests += 1;
}
match req.kind {
RequestKind::Pixels => {
self.stats.page_interpretations += 1;
self.stats.rasterizations += 1;
}
RequestKind::Text => self.stats.text_interpretations += 1,
}
}
let Some(pdf) = self.pdf.as_ref() else {
return finish(req, start, Err(("the document could not be parsed".into(), false)), Vec::new());
};
let cache = RenderCache::new();
let r = render_page(pdf, &cache, &self.settings, req);
let warnings = Arc::new(Mutex::new(Vec::new()));
let settings = warning_settings(&self.settings, warnings.clone());
let r = render_page(pdf, &cache, &settings, self.config.reject_oversize, req);
let warnings = take_warnings(&warnings);
if matches!(r, Err((_, true))) {
self.pdf = parse(&self.bytes, self.config.password.as_deref());
self.stats.parser_builds += 1;
}
finish(req, start, r)
finish(req, start, r, warnings)
}
}
@@ -321,9 +465,10 @@ struct Shared {
/// Per worker id: the request it is rendering and since when.
#[cfg(not(target_arch = "wasm32"))]
busy: Mutex<Vec<Option<Busy>>>,
/// Pages (and request kinds) the watchdog gave up on: answered with an error at once, so a
/// pathological page cannot trap every worker in turn.
stuck: Mutex<std::collections::HashSet<(usize, RequestKind)>>,
/// Exact requests the watchdog gave up on: answered with an error at once, so a pathological
/// request cannot trap every worker in turn while other scales, tiles, or generations remain usable.
stuck: Mutex<std::collections::HashSet<RequestFailureKey>>,
stats: AtomicRenderStats,
/// Per worker id: set to stop that worker's render at its next content operator once nobody
/// can receive its answer (the pool was dropped, or the watchdog gave up on the render).
/// Lock `busy` first when holding both.
@@ -548,6 +693,11 @@ impl RenderPool {
self.inline.is_some()
}
/// Snapshot the work counters collected by this pool.
pub fn stats(&self) -> RenderStats {
self.inline.as_ref().map_or_else(|| self.shared.stats.snapshot(), |renderer| renderer.borrow().stats())
}
/// Change the watchdog limit (tests and benchmarks).
pub fn set_stuck_after(&mut self, limit: std::time::Duration) {
self.stuck_after = limit;
@@ -599,6 +749,7 @@ impl RenderPool {
rgba: Pixels::default(),
error: Some("all render workers exceeded their time limits; close and reopen the document to try again".into()),
text: None,
warnings: Vec::new(),
millis: 0,
})
})
@@ -640,7 +791,7 @@ impl RenderPool {
// the timeout to this page; do not blacklist it here.
self.shared.queue.retry(req);
} else {
lock(&self.shared.stuck).insert((req.page, req.kind));
lock(&self.shared.stuck).insert(req.into());
let what = if req.kind == RequestKind::Text { "text extraction for page" } else { "page" };
let error = format!(
"{what} {} took longer than {:.0} s and was skipped; the page may be damaged or extremely complex",
@@ -654,6 +805,7 @@ impl RenderPool {
rgba: Pixels::default(),
error: Some(error),
text: None,
warnings: Vec::new(),
millis: 0,
});
}
@@ -690,7 +842,8 @@ fn worker(
out: Arc<WorkQueue>,
stop: Arc<std::sync::atomic::AtomicBool>,
) {
let settings = worker_settings(&config, &stop);
let warnings = Arc::new(Mutex::new(Vec::new()));
let settings = warning_settings(&worker_settings(&config, &stop), warnings.clone());
// A local cell keeps Pdf borrows used by RenderCache stable. Drop both on an
// epoch change/panic before publishing an error or taking another request.
loop {
@@ -710,15 +863,25 @@ fn worker(
if shared.queue.cancel_obsolete(req) {
continue;
}
if lock(&shared.stuck).contains(&(req.page, req.kind)) {
if lock(&shared.stuck).contains(&req.into()) {
let error = format!("page {} was skipped earlier because rendering failed or took too long", req.page + 1);
let page = RenderedPage { request: req, width: 0, height: 0, rgba: Pixels::default(), error: Some(error), text: None, millis: 0 };
let page = RenderedPage {
request: req,
width: 0,
height: 0,
rgba: Pixels::default(),
error: Some(error),
text: None,
warnings: Vec::new(),
millis: 0,
};
if out.send_valid(page, None).is_err() {
return;
}
continue;
}
let start = Stopwatch::start();
shared.stats.record_request(req);
let stage = parsed.get().map_or(
BusyStage::Parsing,
|(_, _, shared_generation)| {
@@ -738,6 +901,7 @@ fn worker(
std::thread::sleep(delay);
}
}
shared.stats.parser_builds.fetch_add(1, Ordering::Relaxed);
parse(&bytes, config.password.as_deref())
};
match shared.parser.acquire(load, || matches!(wake.try_recv(), Err(std::sync::mpsc::TryRecvError::Disconnected))) {
@@ -768,7 +932,10 @@ fn worker(
if lock(&shared.busy).get_mut(id).is_none_or(|slot| slot.take().is_none()) {
return;
}
break Some((finish(req, start, Err(("the shared parser was retired".into(), false))), Some(shared.parser.valid.clone())));
break Some((
finish(req, start, Err(("the shared parser was retired".into(), false)), Vec::new()),
Some(shared.parser.valid.clone()),
));
}
#[cfg(test)]
{
@@ -785,17 +952,19 @@ fn worker(
std::thread::sleep(delay);
}
}
lock(&warnings).clear();
let r = catch_unwind(AssertUnwindSafe(|| {
#[cfg(test)]
if *lock(&shared.panic_page) == Some(req.page) {
panic!("injected page panic");
}
match pdf {
Some(pdf) => render_page(pdf, cache, &settings, req),
Some(pdf) => render_page(pdf, cache, &settings, config.reject_oversize, req),
None => Err(("the document could not be parsed".into(), false)),
}
}))
.unwrap_or_else(|panic| Err((format!("renderer crashed on page {}: {}", req.page + 1, panic_message(&panic)), true)));
let request_warnings = take_warnings(&warnings);
// If the watchdog cleared our slot meanwhile, it already answered for this request
// and started a replacement: drop the late result and retire.
let abandoned = lock(&shared.busy).get_mut(id).is_none_or(|slot| slot.take().is_none());
@@ -805,14 +974,14 @@ fn worker(
let panicked = matches!(r, Err((_, true)));
if panicked {
shared.parser.abandon();
lock(&shared.stuck).insert((req.page, req.kind));
break Some((finish(req, start, r), None));
lock(&shared.stuck).insert(req.into());
break Some((finish(req, start, r, request_warnings), None));
}
let valid = shared_generation.then(|| shared.parser.valid.clone());
if *shared_generation && !shared.parser.valid.load(Ordering::Acquire) {
break Some((finish(req, start, r), valid));
break Some((finish(req, start, r, request_warnings), valid));
}
if out.send_valid(finish(req, start, r), valid).is_err() {
if out.send_valid(finish(req, start, r, request_warnings), valid).is_err() {
return;
}
}
@@ -976,8 +1145,9 @@ mod tests {
assert!(matches!(*lock(&pool.shared.parser.state), ParserState::Private));
assert!((2..=4).contains(&pool.shared.parses.load(Ordering::Relaxed)));
let before = pool.shared.parses.load(Ordering::Relaxed);
pool.set_queue(vec![RenderRequest { page: 0, tag: 1, scale: 1.0, ..Default::default() }]);
assert!(receive_before_deadline(&pool).error.is_some());
// 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");
}
@@ -1063,8 +1233,8 @@ mod tests {
assert!(pages[0].error.as_deref().is_some_and(|message| message.contains("took longer")));
assert!(pages[1].error.is_none(), "{:?}", pages[1].error);
assert_eq!((pages[1].width, pages[1].height, pages[1].request.tag), (100, 50, 41));
assert!(lock(&pool.shared.stuck).contains(&(0, RequestKind::Pixels)));
assert!(!lock(&pool.shared.stuck).contains(&(1, RequestKind::Pixels)));
assert!(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");
}
@@ -1087,7 +1257,7 @@ mod tests {
assert!(exhausted.error.as_deref().is_some_and(|message| message.contains("all render workers") && message.contains("reopen")));
assert_eq!(pool.shared.parses.load(Ordering::Relaxed), 2);
assert_eq!(lock(&pool._workers).len(), 2, "no threads beyond the original replacement cap");
assert!(!lock(&pool.shared.stuck).contains(&(1, RequestKind::Pixels)), "the healthy page itself is not blacklisted");
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);
@@ -1101,6 +1271,26 @@ mod tests {
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() {
@@ -1132,7 +1322,7 @@ mod tests {
if skipped {
let req = RenderRequest { page: 0, ..Default::default() };
shared.queue.replace(vec![req]);
lock(&shared.stuck).insert((req.page, req.kind));
lock(&shared.stuck).insert(req.into());
}
let bytes = Arc::new(ONE_PAGE.to_vec());
let source = Arc::downgrade(&bytes);
@@ -1170,6 +1360,17 @@ mod tests {
}
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() {
@@ -1655,6 +1856,51 @@ endstream endobj
trailer << /Root 1 0 R >>
%%EOF";
fn heavy_vector_pdf(commands: usize, text_at_end: bool) -> Vec<u8> {
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
@@ -1774,6 +2020,25 @@ trailer << /Root 1 0 R >>
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));
}
/// 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]
@@ -2967,6 +3232,12 @@ trailer << /Root 1 0 R >>
});
let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("a self-painting form must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
assert!(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");
}
+10 -1
View File
@@ -296,7 +296,16 @@ mod tests {
}
fn page(req: RenderRequest, bytes: usize) -> RenderedPage {
RenderedPage { request: req, width: 1, height: 1, rgba: Pixels::from(vec![0u32; bytes / 4]), error: None, text: None, millis: 0 }
RenderedPage {
request: req,
width: 1,
height: 1,
rgba: Pixels::from(vec![0u32; bytes / 4]),
error: None,
text: None,
warnings: Vec::new(),
millis: 0,
}
}
#[test]