mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
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:
28 files changed
+535
-52
No files matched your search
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user