M2: a page past the rasterizer's coverage renders what fitted instead of failing (view.render-error-isolation)

vello_cpu keeps every path's coverage (alpha) values in one buffer until
the scene is rasterized, and a strip packs its index into that buffer in
31 bits; Strip::new asserts it fits. A page that painted one form 2^22
times over (22 nested forms, each drawing the next twice) grew the
buffer past 2^31 values at about 7 GB and panicked, and the page failed.

- vello_common (2): StripStorage::room_for_path{,_within} says whether
  one more path's coverage, at most the whole surface counted twice,
  still fits below that limit or a smaller one.
- vello_cpu (1): the single-threaded dispatcher leaves out fills,
  strokes, fast rectangles and layer clips once there is no room,
  records it (RenderContext::coverage_full) and can be kept to fewer
  values (set_max_coverage).
- hayro (31): RenderSettings::max_coverage passes that limit down, and
  a page whose paths were left out reports
  InterpreterWarning::ContentTruncated, which PdfCraft already shows as
  content skipped past the page's safety budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
This commit is contained in:
mtlcl410
2026-11-06 22:03:27 +08:00
co-authored by Claude Opus 5.5
parent dc5cb122db
commit 0e75ad08c8
10 changed files with 220 additions and 6 deletions
+1
View File
@@ -408,6 +408,7 @@ fn render_page<'a>(
y_offset: t.y as f32,
bg_color,
color_replacement,
..Default::default()
}
}
None => {
+3
View File
@@ -8196,6 +8196,9 @@ fn decoded_images_serve_every_size_they_are_drawn_at() {
// ICC profiles whose stream dictionaries are the same (vendored hayro-interpret patch 48).
mod icc_profile_cache;
// A page past its rasterizer coverage leaves the rest out and says so (vendored vello_cpu patch 1).
mod coverage_budget;
// A symbolic TrueType font draws through its (3, 0) cmap subtable first (#4156).
mod symbolic_truetype_cmap;
@@ -0,0 +1,78 @@
//! A page that paints the same content over and over runs out of rasterizer coverage: what is
//! past it is left out and said to be, rather than panicking once a strip's alpha index no longer
//! fits (PDFBOX-5415's map page drew one form 2^22 times and panicked at 7 GB).
use super::*;
use std::sync::Mutex;
/// A page whose form `F0` draws `F1` twice, `F1` draws `F2` twice, and so on `levels` deep; the
/// last form fills a circle. The page draws `F0` once, so the circle is filled 2^`levels` times.
fn doubling_forms(levels: usize) -> Vec<u8> {
let mut objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
// Object 4 is the page's content; forms start at 5.
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /F0 5 0 R >> >> >>".into(),
String::new(),
];
let content = "/F0 Do";
objs[3] = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len());
let circle = "0 0 1 rg 100 160 m 133 160 160 133 160 100 c 160 67 133 40 100 40 c 67 40 40 67 40 100 c 40 133 67 160 100 160 c f";
for level in 0..=levels {
let (body, resources) = if level == levels {
(circle.to_string(), String::new())
} else {
("/F Do /F Do".to_string(), format!("/Resources << /XObject << /F {} 0 R >> >>", 6 + level))
};
objs.push(format!("<< /Type /XObject /Subtype /Form /BBox [0 0 200 200] {resources} /Length {} >>\nstream\n{body}\nendstream", body.len()));
}
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
/// Renders page 1 with the rasterizer kept to `max_coverage` values, returning its pixels and
/// whether it said content was left out.
fn render_kept_to(pdf: Vec<u8>, max_coverage: Option<usize>) -> (Vec<u8>, bool) {
let parsed = Pdf::new(Arc::new(pdf)).unwrap();
let truncated = Arc::new(Mutex::new(false));
let seen = truncated.clone();
let settings = InterpreterSettings {
warning_sink: Arc::new(move |w| {
if matches!(w, InterpreterWarning::ContentTruncated) {
*seen.lock().unwrap() = true;
}
}),
..Default::default()
};
let pixmap =
hayro::render(&parsed.pages()[0], &RenderCache::new(), &settings, &RenderSettings { bg_color: WHITE, max_coverage, ..Default::default() });
let cut = *truncated.lock().unwrap();
(pixmap.data_as_u8_slice().to_vec(), cut)
}
#[test]
fn a_page_past_its_coverage_leaves_the_rest_out_and_says_so() {
// 2^10 circles, each storing its edge tiles' coverage.
let pdf = doubling_forms(10);
let (whole, whole_cut) = render_kept_to(pdf.clone(), None);
assert!(!whole_cut, "within the limit nothing is left out");
let blue = |px: &[u8]| px.as_chunks::<4>().0.iter().filter(|p| p[2] > 200 && p[0] < 50).count();
assert!(blue(&whole) > 10_000, "the circle is drawn");
// Room for a few circles only: the rest are left out, the page still renders, and says so.
let (kept, cut) = render_kept_to(pdf, Some(200_000));
assert!(cut, "the page says content was left out");
assert_eq!(kept.len(), whole.len());
assert!(blue(&kept) > 10_000, "the circles drawn before the limit stay: {}", blue(&kept));
}
+2 -2
View File
@@ -830,8 +830,8 @@ milestone = "M3"
status = "shipped"
commands = []
tools = []
evidence = ["crates/render/src/raster/tests.rs::missing_page_and_garbage_input_fail_gracefully", "crates/render/src/raster/tests.rs::scale_is_clamped_for_huge_pages", "crates/render/src/raster/tests.rs::watchdog_skips_a_stuck_page_and_keeps_rendering"]
notes = ""
evidence = ["crates/render/src/raster/tests.rs::missing_page_and_garbage_input_fail_gracefully", "crates/render/src/raster/tests.rs::scale_is_clamped_for_huge_pages", "crates/render/src/raster/tests.rs::watchdog_skips_a_stuck_page_and_keeps_rendering", "crates/render/src/raster/tests/coverage_budget.rs::a_page_past_its_coverage_leaves_the_rest_out_and_says_so"]
notes = "A page that paints more coverage than the rasterizer can index (one form drawn millions of times over) renders what fitted and says content was left out, rather than failing the page."
[[feature]]
id = "view.render-priority"
+3 -2
View File
File diff suppressed because one or more lines are too long
+14
View File
@@ -157,6 +157,10 @@ pub struct RenderSettings {
/// PdfCraft patch: when set, solid text and line-art paint colours are remapped while
/// rendering; `None` renders exactly as without the patch.
pub color_replacement: Option<ColorReplacement>,
/// PdfCraft patch (31): the most coverage values the rasterizer keeps for the page; paths
/// past it are left out, with `InterpreterWarning::ContentTruncated`. `None` keeps as many
/// as a strip's index reaches (2^31).
pub max_coverage: Option<usize>,
}
impl Default for RenderSettings {
@@ -170,6 +174,7 @@ impl Default for RenderSettings {
x_offset: 0.0,
y_offset: 0.0,
color_replacement: None,
max_coverage: None,
}
}
}
@@ -254,6 +259,10 @@ pub fn render_into<'a>(
interpreter_settings.preview,
);
if let Some(max) = render_settings.max_coverage {
device.ctx.set_max_coverage(max);
}
// PdfCraft patch (18): the page renders as one transparency group (the page group). Its
// objects composite with an initially transparent backdrop, and the group is then put on
// the paper (ISO 32000-2, page group), so a blend mode only blends where something was
@@ -284,6 +293,11 @@ pub fn render_into<'a>(
}
interpret_page(page, &mut state, &mut device);
// PdfCraft patch (31): what the rasterizer left out once its coverage was full (a page that
// paints the same content millions of times over) is content left out, said as such.
if device.ctx.coverage_full() {
(interpreter_settings.warning_sink)(hayro_interpret::InterpreterWarning::ContentTruncated);
}
if !backdrop_inside {
device.pop_transparency_group();
+55
View File
@@ -69,6 +69,21 @@ impl StripStorage {
self.strips.is_empty() && self.alphas.is_empty()
}
/// PdfCraft patch (2): whether these strips have room for the coverage of one more path on a
/// `width` x `height` surface, which covers at most the whole surface (twice, to be safe). A
/// strip packs its index into the alphas in 31 bits and [`Strip::new`] asserts that it fits:
/// a scene that painted past it (a form drawn 2^22 times over) panicked.
pub fn room_for_path(&self, width: u16, height: u16) -> bool {
self.room_for_path_within(width, height, MAX_ALPHAS)
}
/// [`room_for_path`](Self::room_for_path) with a scene kept to `max_alphas` coverage values
/// (never more than a strip's index can reach).
pub fn room_for_path_within(&self, width: u16, height: u16, max_alphas: usize) -> bool {
let surface = usize::from(width) * usize::from(height);
self.alphas.len().saturating_add(surface.saturating_mul(2)) < max_alphas.min(MAX_ALPHAS)
}
/// Extend the current strip storage with the data from another storage.
pub fn extend(&mut self, other: &Self) {
self.strips.extend(&other.strips);
@@ -76,6 +91,10 @@ impl StripStorage {
}
}
/// PdfCraft patch (2): the most coverage (alpha) values a strip index can reach (31 bits; the
/// top bit of [`Strip`]'s packed index is its fill-gap flag).
const MAX_ALPHAS: usize = 1 << 31;
/// An object for easily generating strips for a filled/stroked path.
#[derive(Debug)]
pub struct StripGenerator {
@@ -439,3 +458,39 @@ mod tests {
assert_rect_fast_eq_path(Rect::new(18.0, 18.0, 2.0, 2.0), "inverted_both_axes");
}
}
#[cfg(test)]
mod pdfcraft_tests {
use super::*;
/// PdfCraft patch (2): room runs out a whole surface's coverage before the limit, so the path
/// that would cross it is never generated; at the real limit, an index of `MAX_ALPHAS` would
/// not fit a strip.
#[test]
fn strips_have_room_until_a_surface_short_of_the_limit() {
let mut storage = StripStorage::default();
assert!(storage.room_for_path_within(10, 10, 1_000));
storage.alphas.resize(799, 0);
assert!(
storage.room_for_path_within(10, 10, 1_000),
"799 + 200 < 1000"
);
storage.alphas.push(0);
assert!(
!storage.room_for_path_within(10, 10, 1_000),
"800 + 200 reaches the limit"
);
assert!(
storage.room_for_path_within(8192, 8192, usize::MAX),
"a large page's surface fits, a larger limit held to a strip's index"
);
assert!(
!storage.room_for_path_within(u16::MAX, u16::MAX, usize::MAX),
"the largest surface is counted without overflow, and cannot be promised room"
);
assert!(
Strip::new(0, 0, (MAX_ALPHAS - 1) as u32, true).fill_gap(),
"the last index below the limit fits"
);
}
}
+8
View File
@@ -70,6 +70,14 @@ pub(crate) trait Dispatcher: Debug + Send + Sync {
fn pop_layer(&mut self);
fn reset(&mut self);
fn flush(&mut self, encoded_paints: &[EncodedPaint]);
/// PdfCraft patch (1): whether a path was left out of this scene because its coverage was
/// full (only the single-threaded dispatcher keeps count).
fn coverage_full(&self) -> bool {
false
}
/// PdfCraft patch (1): keep the scene to `max` coverage values (never more than a strip's
/// index reaches); the single-threaded dispatcher leaves later paths out past it.
fn set_max_coverage(&mut self, _max: usize) {}
fn rasterize(
&self,
buffer: &mut [u8],
+42 -2
View File
@@ -42,6 +42,11 @@ pub(crate) struct SingleThreadedDispatcher {
layer_id_next: u32,
/// Dependency graph tracking layer relationships and filter effects.
render_graph: RenderGraph,
/// PdfCraft patch (1): a path was left out because the scene's coverage was full.
coverage_full: bool,
/// PdfCraft patch (1): the most coverage values the scene keeps
/// ([`Dispatcher::set_max_coverage`]).
max_coverage: usize,
}
impl SingleThreadedDispatcher {
@@ -79,9 +84,25 @@ impl SingleThreadedDispatcher {
level,
layer_id_next: 0,
render_graph,
coverage_full: false,
max_coverage: usize::MAX,
}
}
/// PdfCraft patch (1): whether the scene has room for the coverage of one more path
/// ([`StripStorage::room_for_path`]). Once it hasn't, the path and every later one are left
/// out, and [`Dispatcher::coverage_full`] says so.
fn room_for_path(&mut self) -> bool {
let (width, height) = (self.strip_generator.width(), self.strip_generator.height());
let room = self
.strip_storage
.room_for_path_within(width, height, self.max_coverage);
if !room {
self.coverage_full = true;
}
room
}
/// Rasterizes the scene using f32 precision (high quality).
///
/// This dispatches to the appropriate SIMD implementation based on the
@@ -539,6 +560,9 @@ impl Dispatcher for SingleThreadedDispatcher {
mask: Option<Mask>,
encoded_paints: &[EncodedPaint],
) {
if !self.room_for_path() {
return;
}
let wide = &mut self.wide;
// Convert path to coverage strips.
@@ -573,6 +597,9 @@ impl Dispatcher for SingleThreadedDispatcher {
mask: Option<Mask>,
encoded_paints: &[EncodedPaint],
) {
if !self.room_for_path() {
return;
}
let wide = &mut self.wide;
// Convert stroked path to coverage strips.
@@ -604,6 +631,9 @@ impl Dispatcher for SingleThreadedDispatcher {
mask: Option<Mask>,
encoded_paints: &[EncodedPaint],
) {
if !self.room_for_path() {
return;
}
let wide = &mut self.wide;
// Generate strips directly for the rectangle (bypasses path processing).
@@ -638,8 +668,9 @@ impl Dispatcher for SingleThreadedDispatcher {
// Allocate a new unique layer ID.
self.layer_id_next += 1;
// Generate clip coverage if a clip path is provided.
let clip = if let Some(c) = clip_path {
// Generate clip coverage if a clip path is provided. PdfCraft patch (1): without room for
// it the layer goes unclipped, as nothing can be drawn into it any more either.
let clip = if let Some(c) = clip_path.filter(|_| self.room_for_path()) {
self.strip_generator.generate_filled_path(
c,
fill_rule,
@@ -681,6 +712,7 @@ impl Dispatcher for SingleThreadedDispatcher {
self.strip_storage.clear();
self.render_graph.clear();
self.layer_id_next = 0;
self.coverage_full = false;
// Recreate root node as node 0 (required for proper execution order).
let root_node = self.render_graph.add_node(RenderNodeKind::RootLayer {
@@ -697,6 +729,14 @@ impl Dispatcher for SingleThreadedDispatcher {
// No-op for single-threaded dispatcher (no work queue to flush).
}
fn coverage_full(&self) -> bool {
self.coverage_full
}
fn set_max_coverage(&mut self, max: usize) {
self.max_coverage = max;
}
fn rasterize(
&self,
buffer: &mut [u8],
+14
View File
@@ -583,6 +583,20 @@ impl RenderContext {
self.filter = None;
}
/// PdfCraft patch (1): whether paths were left out of the scene because its coverage was
/// full (a page that paints the same content millions of times over). Cleared by
/// [`reset`](Self::reset).
pub fn coverage_full(&self) -> bool {
self.dispatcher.coverage_full()
}
/// PdfCraft patch (1): keep the scene to `max` coverage (alpha) values, fewer than the
/// 2^31 a strip's index reaches, after which [`coverage_full`](Self::coverage_full) says
/// paths were left out. Kept across [`reset`](Self::reset).
pub fn set_max_coverage(&mut self, max: usize) {
self.dispatcher.set_max_coverage(max);
}
/// Reset the render context.
pub fn reset(&mut self) {
self.dispatcher.reset();