From c77899b2a70b9d35db9b2d7fb568cde506d182a1 Mon Sep 17 00:00:00 2001 From: Muhammad Saad Date: Thu, 15 Oct 2026 09:57:02 +0400 Subject: [PATCH 1/2] Text: admit the rasterizer's peak working bytes (#4979) rasterize_warped guarded its size with MAX_PIXELS alone, which says nothing about bytes. The buffers it keeps live at once are all as deep as the format: the f32 accumulator and the output surface are both stride deep, a finished coverage buffer sits beside them for the colour being drawn, and each faux-bold pass holds a second Coverage and the vector its finish builds. A CMYK bold render at f32 therefore reserves several times what a plain grey one does for the same pixel count, and every one of those buffers went through vec! or Vec::with_capacity, which abort instead of returning. Admit the peak before reserving any of it: fits_raster_budget derives it from the stride, the sample size and whether a bold pass runs, refuses an overflowing size rather than wrapping, and is checked against MAX_RASTER_BYTES (4 GiB, 1 GiB where usize is 32 bits, so the wasm build keeps room for the document). Coverage now reserves fallibly and finish returns an Option, and the accumulator goes through a fallible zeroed(). A refusal returns the empty Rendered the oversized path already returned, so callers see the existing "too big to draw" result rather than a crash, and the document and its cached appearance are untouched. Ordinary text is unaffected: the budget only binds above what MAX_PIXELS already allowed for narrow formats. Test: the peak tracks channels, sample width and faux bold against small injected allowances, an overflowing size is refused, and a buffer that cannot be served reserves nothing. --- crates/text/src/raster.rs | 30 ++++++++------ crates/text/src/render.rs | 83 +++++++++++++++++++++++++++++++++++---- 2 files changed, 95 insertions(+), 18 deletions(-) diff --git a/crates/text/src/raster.rs b/crates/text/src/raster.rs index bca52e38cf..4f32e9941e 100644 --- a/crates/text/src/raster.rs +++ b/crates/text/src/raster.rs @@ -76,8 +76,13 @@ pub struct Coverage { } impl Coverage { - pub fn new(width: usize, height: usize) -> Self { - Self { width, height, acc: vec![0.0; width * height + 2] } + /// A zeroed accumulator, or `None` when its bytes cannot be reserved. + pub fn try_new(width: usize, height: usize) -> Option { + let n = width.checked_mul(height)?.checked_add(2)?; + let mut acc = Vec::new(); + acc.try_reserve_exact(n).ok()?; + acc.resize(n, 0.0); + Some(Self { width, height, acc }) } /// Adds a line segment (pixel coordinates, y down). @@ -148,15 +153,18 @@ impl Coverage { } /// Resolves the accumulation into per-pixel coverage in 0..=1. - pub fn finish(self) -> Vec { + /// The accumulated coverage, or `None` when the output cannot be reserved. The input + /// accumulator is still live while this builds, so both count toward a caller's budget. + pub fn finish(self) -> Option> { let n = self.width * self.height; - let mut out = Vec::with_capacity(n); + let mut out = Vec::new(); + out.try_reserve_exact(n).ok()?; let mut sum = 0.0f32; for v in &self.acc[..n] { sum += v; out.push(sum.abs().min(1.0)); } - out + Some(out) } } @@ -240,9 +248,9 @@ mod tests { #[test] fn square_area_exact() { - let mut c = Coverage::new(10, 10); + let mut c = Coverage::try_new(10, 10).unwrap(); rect_to(&mut c, &Xform::IDENTITY, 2.25, 3.5, 6.75, 8.0); - let v = c.finish(); + let v = c.finish().unwrap(); assert!((sum(&v) - 4.5 * 4.5).abs() < 1e-3, "{}", sum(&v)); assert_eq!(v[5 * 10 + 4], 1.0); assert_eq!(v[0], 0.0); @@ -250,13 +258,13 @@ mod tests { #[test] fn triangle_area_and_clipping() { - let mut c = Coverage::new(8, 8); + let mut c = Coverage::try_new(8, 8).unwrap(); // Right triangle partly outside on the left and bottom. let pts = [(-4.0, 2.0), (6.0, 2.0), (-4.0, 12.0)]; for i in 0..3 { c.line(pts[i], pts[(i + 1) % 3]); } - let v = c.finish(); + let v = c.finish().unwrap(); // Visible part: region x in [0,6], y in [2,8] under the hypotenuse x + y <= 8. // Area = ∫_{y=2}^{8} (8 - y) dy = 18. assert!((sum(&v) - 18.0).abs() < 1e-3, "{}", sum(&v)); @@ -266,7 +274,7 @@ mod tests { #[test] fn circle_area_via_pen() { use skrifa::outline::OutlinePen; - let mut c = Coverage::new(40, 40); + let mut c = Coverage::try_new(40, 40).unwrap(); { let mut p = Pen::new(&mut c, Xform([1.0, 0.0, 0.0, -1.0, 20.0, 20.0])); // Cubic circle approximation, r = 15. @@ -278,7 +286,7 @@ mod tests { p.curve_to(k, -r, r, -k, r, 0.0); p.close(); } - let a = sum(&c.finish()); + let a = sum(&c.finish().unwrap()); let exact = std::f32::consts::PI * 225.0; assert!((a - exact).abs() / exact < 0.005, "{a} vs {exact}"); } diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs index af29dd10d6..51b9e1e5db 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -18,6 +18,38 @@ pub const FAUX_ITALIC_DEG: f32 = 12.0; pub const FAUX_BOLD_RADIUS: f32 = 0.036; /// Largest raster we produce (pixels), as a guard against absurd sizes. const MAX_PIXELS: u64 = 256 * 1024 * 1024; +/// Peak working bytes one rasterization may keep live. [`MAX_PIXELS`] does not bound them: the +/// accumulator and the output surface are both `stride` deep, and a faux-bold pass holds a second +/// coverage buffer, so a CMYK bold render reserves several times what a plain grey one does for +/// the same pixels. +const MAX_RASTER_BYTES: u64 = if cfg!(target_pointer_width = "64") { 4 << 30 } else { 1 << 30 }; + +/// Whether a `w` x `h` render's peak working bytes fit `max_bytes`: the f32 accumulator and the +/// output surface (both `stride` deep), the finished coverage of the colour being drawn and, with +/// faux bold, the second coverage buffer and the vector its `finish` builds beside it. `false` +/// when the arithmetic overflows, so an absurd size is refused rather than wrapping. +fn fits_raster_budget(w: usize, h: usize, stride: usize, sample: SampleType, bold: bool, max_bytes: u64) -> bool { + let f32s = size_of::() as u64; + let peak = (w as u64).checked_mul(h as u64).and_then(|px| { + let deep = px.checked_mul(stride as u64)?.checked_mul(f32s + sample.bytes() as u64)?; + let coverage = px.checked_mul(if bold { 3 } else { 1 })?.checked_mul(f32s)?; + deep.checked_add(coverage) + }); + peak.is_some_and(|b| b <= max_bytes) +} + +/// A zeroed buffer, or `None` when its bytes cannot be reserved. +fn zeroed(len: usize) -> Option> { + let mut v = Vec::new(); + v.try_reserve_exact(len).ok()?; + v.resize(len, 0.0); + Some(v) +} + +/// What a refused or unrenderable layout returns. +fn nothing(format: PixelFormat) -> Rendered { + Rendered { surface: Surface::new(format), rect: Rect::new(0, 0, 0, 0) } +} /// Rendered text: pixels in document space plus the covered rectangle. pub struct Rendered { @@ -252,8 +284,14 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo } let n = format.mode.color_channels(); let stride = n + 1; + let bold = layout.glyphs.iter().any(|g| is_bold(layout, g)); + if !fits_raster_budget(w, h, stride, format.sample, bold, MAX_RASTER_BYTES) { + return nothing(format); + } // Premultiplied accumulation. - let mut acc = vec![0.0f32; w * h * stride]; + let Some(mut acc) = zeroed(w * h * stride) else { + return nothing(format); + }; let xf = Xform([1.0, 0.0, 0.0, 1.0, -rect.x0 as f64, -rect.y0 as f64]).mul(&Xform(transform.m)); let mut colors: Vec = Vec::new(); for st in &layout.styles { @@ -262,14 +300,23 @@ pub fn rasterize_warped(layout: &TextLayout, transform: &Affine, format: PixelFo } } for c in &colors { - let mut cov = Coverage::new(w, h); + let Some(mut cov) = Coverage::try_new(w, h) else { + return nothing(format); + }; draw_pass(layout, &xf, &mut cov, Some(c), warp, Pass::Plain); - let mut cov = cov.finish(); - if layout.glyphs.iter().any(|g| is_bold(layout, g)) { + let Some(mut cov) = cov.finish() else { + return nothing(format); + }; + if bold { for k in 0..BOLD_OFFSETS.len() { - let mut bc = Coverage::new(w, h); + let Some(mut bc) = Coverage::try_new(w, h) else { + return nothing(format); + }; draw_pass(layout, &xf, &mut bc, Some(c), warp, Pass::Bold(k)); - for (a, b) in cov.iter_mut().zip(bc.finish()) { + let Some(bold_cov) = bc.finish() else { + return nothing(format); + }; + for (a, b) in cov.iter_mut().zip(bold_cov) { *a = a.max(b); } } @@ -398,7 +445,8 @@ pub fn outlines(layout: &TextLayout, transform: &Affine, warp: Option<&Warp>) -> #[cfg(test)] mod tests { - use super::{MAX_PIXELS, rect_from_bounds}; + use super::{MAX_PIXELS, fits_raster_budget, rect_from_bounds, zeroed}; + use photocraft_color::SampleType; use photocraft_geom::Rect; #[test] @@ -419,4 +467,25 @@ mod tests { assert_eq!(rect_from_bounds(bounds), first, "bounds: {bounds:?}"); } } + + /// #4979: the pixel cap says nothing about bytes, so the peak is admitted from the channel + /// depth of the accumulator and surface, the sample size and the faux-bold coverage buffers. + /// A 100 x 100 grey+alpha render at 8-bit keeps 2 x (4 + 1) + 4 = 14 bytes a pixel live. + #[test] + fn the_byte_budget_follows_channels_depth_and_faux_bold() { + let px = 100 * 100; + assert!(fits_raster_budget(100, 100, 2, SampleType::U8, false, px * 14)); + assert!(!fits_raster_budget(100, 100, 2, SampleType::U8, false, px * 14 - 1), "the allowance is exact"); + + // The same pixels cost more with more channels, a wider sample, or a bold pass. + assert!(!fits_raster_budget(100, 100, 5, SampleType::U8, false, px * 14), "CMYK is deeper"); + assert!(!fits_raster_budget(100, 100, 2, SampleType::F32, false, px * 14), "f32 output is wider"); + assert!(!fits_raster_budget(100, 100, 2, SampleType::U8, true, px * 14), "bold holds two more buffers"); + assert!(fits_raster_budget(100, 100, 2, SampleType::U8, true, px * 22), "2 x 5 + 3 x 4 bytes a pixel"); + + // A size whose peak overflows is refused rather than wrapping to a small number. + assert!(!fits_raster_budget(usize::MAX, usize::MAX, 5, SampleType::F32, true, u64::MAX)); + // Nothing is reserved for a buffer that cannot be served. + assert!(zeroed(usize::MAX).is_none()); + } } From f13731a51a74667fbc7bd6b1a79fab1955c11683 Mon Sep 17 00:00:00 2001 From: Muhammad Saad Date: Sat, 17 Oct 2026 14:26:50 +0400 Subject: [PATCH 2/2] Text: count both coverage buffers on the plain path (#4979) Coverage::finish allocates its output while the accumulator it reads is still live, so a plain pass peaks at two coverage buffers rather than one. The budget charged one, which contradicted finish's own doc comment and left the plain path under its real peak. The bold path's three buffers were already right. The exactness assertion in the budget test now pins 18 bytes a pixel for a grey 8-bit plain render, and fails if the undercount comes back. Caught in review by @FredApps. --- crates/text/src/render.rs | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs index 51b9e1e5db..13dccc44c3 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -19,20 +19,22 @@ pub const FAUX_BOLD_RADIUS: f32 = 0.036; /// Largest raster we produce (pixels), as a guard against absurd sizes. const MAX_PIXELS: u64 = 256 * 1024 * 1024; /// Peak working bytes one rasterization may keep live. [`MAX_PIXELS`] does not bound them: the -/// accumulator and the output surface are both `stride` deep, and a faux-bold pass holds a second -/// coverage buffer, so a CMYK bold render reserves several times what a plain grey one does for -/// the same pixels. +/// accumulator and the output surface are both `stride` deep, and a faux-bold pass keeps one more +/// coverage buffer live, so a CMYK bold render reserves several times what a plain grey one does +/// for the same pixels. const MAX_RASTER_BYTES: u64 = if cfg!(target_pointer_width = "64") { 4 << 30 } else { 1 << 30 }; /// Whether a `w` x `h` render's peak working bytes fit `max_bytes`: the f32 accumulator and the -/// output surface (both `stride` deep), the finished coverage of the colour being drawn and, with -/// faux bold, the second coverage buffer and the vector its `finish` builds beside it. `false` -/// when the arithmetic overflows, so an absurd size is refused rather than wrapping. +/// output surface (both `stride` deep), plus the coverage buffers live at the peak. A plain pass +/// peaks inside `Coverage::finish`, which holds its accumulator and the vector it builds beside +/// it; a faux-bold pass peaks one buffer higher, since the resolved plain coverage is kept while +/// the bold pass resolves into it. `false` when the arithmetic overflows, so an absurd size is +/// refused rather than wrapping. fn fits_raster_budget(w: usize, h: usize, stride: usize, sample: SampleType, bold: bool, max_bytes: u64) -> bool { let f32s = size_of::() as u64; let peak = (w as u64).checked_mul(h as u64).and_then(|px| { let deep = px.checked_mul(stride as u64)?.checked_mul(f32s + sample.bytes() as u64)?; - let coverage = px.checked_mul(if bold { 3 } else { 1 })?.checked_mul(f32s)?; + let coverage = px.checked_mul(if bold { 3 } else { 2 })?.checked_mul(f32s)?; deep.checked_add(coverage) }); peak.is_some_and(|b| b <= max_bytes) @@ -470,17 +472,17 @@ mod tests { /// #4979: the pixel cap says nothing about bytes, so the peak is admitted from the channel /// depth of the accumulator and surface, the sample size and the faux-bold coverage buffers. - /// A 100 x 100 grey+alpha render at 8-bit keeps 2 x (4 + 1) + 4 = 14 bytes a pixel live. + /// A 100 x 100 grey+alpha render at 8-bit keeps 2 x (4 + 1) + 2 x 4 = 18 bytes a pixel live. #[test] fn the_byte_budget_follows_channels_depth_and_faux_bold() { let px = 100 * 100; - assert!(fits_raster_budget(100, 100, 2, SampleType::U8, false, px * 14)); - assert!(!fits_raster_budget(100, 100, 2, SampleType::U8, false, px * 14 - 1), "the allowance is exact"); + assert!(fits_raster_budget(100, 100, 2, SampleType::U8, false, px * 18)); + assert!(!fits_raster_budget(100, 100, 2, SampleType::U8, false, px * 18 - 1), "the allowance is exact"); // The same pixels cost more with more channels, a wider sample, or a bold pass. - assert!(!fits_raster_budget(100, 100, 5, SampleType::U8, false, px * 14), "CMYK is deeper"); - assert!(!fits_raster_budget(100, 100, 2, SampleType::F32, false, px * 14), "f32 output is wider"); - assert!(!fits_raster_budget(100, 100, 2, SampleType::U8, true, px * 14), "bold holds two more buffers"); + assert!(!fits_raster_budget(100, 100, 5, SampleType::U8, false, px * 18), "CMYK is deeper"); + assert!(!fits_raster_budget(100, 100, 2, SampleType::F32, false, px * 18), "f32 output is wider"); + assert!(!fits_raster_budget(100, 100, 2, SampleType::U8, true, px * 18), "bold holds one more buffer"); assert!(fits_raster_budget(100, 100, 2, SampleType::U8, true, px * 22), "2 x 5 + 3 x 4 bytes a pixel"); // A size whose peak overflows is refused rather than wrapping to a small number.