Rendering and PDF check image limits before allocating (#1321) (#2001)

* WIP for #1321

* Images: check size budgets before decoding and cap page rasters (#1321)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Images: charge decode peak, valid-CRC header tests, cap text-line rasters (#1321)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Dmitrii Khlapov
2026-10-18 04:42:17 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Dmitrii Khlapov Brandon Thomas
parent c9f3da2fc5
commit 3787af0a67
6 changed files with 209 additions and 12 deletions
+2
View File
@@ -137,6 +137,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096).
| Date | What landed |
| 2026-10-15 | Image size limits are checked before allocating (#1321): a picture whose header declares more RGBA bytes than the 512 MiB budget is refused before decoding (render and PDF share one helper in `wordcraft-geom`), and page rasters are capped at 100 million pixels in total, scaled down rather than only per side. |
| 2026-10-15 | Saved DOCX files keep the order of mixed right-to-left and left-to-right text in Word: Hebrew around a Latin name, numbers, brackets or an e-mail address no longer changes places, and Word reports the Hebrew as Hebrew rather than Arabic (#1543). |
| 2026-10-15 | Web: typing in Cyrillic or another non-Latin script no longer repeats text. WordCraft now honours the input method's request to erase around the caret before retyping (#1480). Chrome can still move the hidden text field's caret, so later letters may still land out of order. That cause is still open |
|---|---|
@@ -445,6 +446,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-16 | trivial | Progress log: Web Layout fills the window width (#1876) |
| 2026-10-17 | trivial | Progress log: DOCX glossary documents (building blocks) |
| 2026-10-15 | trivial | Progress log: move options in Track Changes Options (gap #5) |
| 2026-10-15 | trivial | Progress log: image size limits checked before allocation (#1321) |
| 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged |
| 2026-10-17 | trivial | Explicit DrawingML preset/custom outline dashes survive import/save and paint in raster/PDF, scaled to the effective stroke width even for zero-width paths. Synthetic controls; theme-inherited dashes and line caps remain unverified. |
| 2026-10-15 | trivial | `wordcraft-cli convert --update-fields` explicitly refreshes unlocked fields before conversion; ordinary conversion keeps cached results. Field refresh can change pagination |
+2
View File
@@ -10,6 +10,8 @@ use serde::{Deserialize, Serialize};
mod region;
pub use region::Region;
mod pixels;
pub use pixels::{MAX_DECODED_BYTES, MAX_RASTER_PIXELS, decode_fits, fit_pixels, rgba_bytes, rgba_fits};
/// Points per inch.
pub const PT_PER_IN: f32 = 72.0;
+65
View File
@@ -0,0 +1,65 @@
//! Pixel-budget arithmetic shared by every crate that decodes or allocates rasters: sizes are
//! checked before anything is allocated, so a hostile header or request cannot exhaust memory.
/// Bytes a decoded picture may take as RGBA8 (what the `image` crate's default decode budget is).
pub const MAX_DECODED_BYTES: u64 = 512 * 1024 * 1024;
/// Most pixels one page raster may hold (400 MB of RGBA8); larger requests are scaled down.
pub const MAX_RASTER_PIXELS: u64 = 100_000_000;
/// Bytes of a `w`×`h` RGBA8 raster, None on overflow.
pub fn rgba_bytes(w: u32, h: u32) -> Option<u64> {
(w as u64).checked_mul(h as u64)?.checked_mul(4)
}
/// Whether a `w`×`h` RGBA8 raster fits `budget` bytes.
pub fn rgba_fits(w: u32, h: u32, budget: u64) -> bool {
w > 0 && h > 0 && rgba_bytes(w, h).is_some_and(|b| b <= budget)
}
/// Whether decoding a `w`×`h` picture fits `budget` bytes at its peak: the decoder's buffer and
/// the RGBA conversion (or its premultiplied copy) are alive together, so two RGBA buffers.
pub fn decode_fits(w: u32, h: u32, budget: u64) -> bool {
w > 0 && h > 0 && rgba_bytes(w, h).and_then(|b| b.checked_mul(2)).is_some_and(|b| b <= budget)
}
/// `w`×`h` shrunk (aspect kept, each side at least 1) until it holds at most `max_pixels`.
pub fn fit_pixels(w: u32, h: u32, max_pixels: u64) -> (u32, u32) {
let (w, h) = (w.max(1), h.max(1));
let max_pixels = max_pixels.max(1);
let area = w as u64 * h as u64;
if area <= max_pixels {
return (w, h);
}
let f = (max_pixels as f64 / area as f64).sqrt();
let nh = ((h as f64 * f) as u64).clamp(1, h as u64);
let nw = ((w as f64 * f) as u64).clamp(1, w as u64).min(max_pixels / nh).max(1);
let nh = nh.min(max_pixels / nw).max(1);
(nw as u32, nh as u32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rgba_budget_boundary() {
assert_eq!(rgba_bytes(16, 8), Some(512));
assert!(rgba_fits(16, 8, 512));
assert!(!rgba_fits(16, 8, 511));
assert!(!rgba_fits(0, 8, 512), "empty is not a picture");
assert!(decode_fits(16, 8, 1024));
assert!(!decode_fits(16, 8, 1023), "decode + conversion hold two buffers");
assert!(!decode_fits(0, 8, u64::MAX));
}
#[test]
fn fit_pixels_keeps_normal_sizes_and_shrinks_huge_ones() {
assert_eq!(fit_pixels(612, 792, MAX_RASTER_PIXELS), (612, 792));
let (w, h) = fit_pixels(16_000, 16_000, 1_000_000);
assert!(w as u64 * h as u64 <= 1_000_000 && w >= 900, "{w}x{h}");
let (w, h) = fit_pixels(16_000, 1, 100);
assert!(w as u64 * h as u64 <= 100 && h == 1, "{w}x{h}");
assert_eq!(fit_pixels(0, 5, 10).0, 1);
}
}
+12 -4
View File
@@ -1680,10 +1680,18 @@ fn path_from(b: &kurbo::BezPath) -> Option<Path> {
/// Decode a picture for embedding (validated with the `image` crate first, so a broken file
/// can't fail the whole export).
fn load_image(data: &Arc<Vec<u8>>) -> Option<Image> {
load_image_within(data, wordcraft_geom::MAX_DECODED_BYTES)
}
/// [`load_image`] with an explicit RGBA byte budget (a small one in tests).
fn load_image_within(data: &Arc<Vec<u8>>, budget: u64) -> Option<Image> {
let fmt = image::guess_format(data).ok()?;
// The side check below used to run only after a full decode; applying it as a decode limit
// rejects a small file that declares a huge image at header time instead. The default
// allocation cap (512 MB, what `load_from_memory_with_format` applied) stays in force.
// Header-only size check before anything is allocated (decode buffer + RGBA conversion are
// charged); the side limits and the default 512 MB allocation cap stay as a second guard.
let (hw, hh) = image::ImageReader::new(std::io::Cursor::new(data.as_slice())).with_guessed_format().ok()?.into_dimensions().ok()?;
if !wordcraft_geom::decode_fits(hw, hh, budget) {
return None;
}
let mut reader = image::ImageReader::new(std::io::Cursor::new(data.as_slice())).with_guessed_format().ok()?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_IMAGE_SIDE);
@@ -1697,7 +1705,7 @@ fn load_image(data: &Arc<Vec<u8>>) -> Option<Image> {
if fmt == image::ImageFormat::Jpeg && matches!(dec.color(), image::ColorType::L8 | image::ColorType::Rgb8) {
return Image::from_jpeg(data.clone().into(), true).ok();
}
Some(Image::from_rgba8(dec.to_rgba8().into_raw(), w, h))
Some(Image::from_rgba8(dec.into_rgba8().into_raw(), w, h))
}
#[cfg(test)]
+39
View File
@@ -1461,6 +1461,32 @@ fn right_to_left_clusters_are_written_whole_left_to_right() {
assert_eq!(visual_order(text, &glyphs, &ranges, &parts), [5, 4, 2, 3, 0, 1]);
}
/// A PNG signature, an IHDR declaring `w`×`h` 8-bit RGBA and a short IDAT (all CRCs valid, so
/// the header parses), with no real pixel data.
fn ihdr_only_png(w: u32, h: u32) -> Vec<u8> {
fn chunk(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
let mut body = kind.to_vec();
body.extend_from_slice(data);
let mut crc = 0xFFFF_FFFFu32;
for b in &body {
crc ^= *b as u32;
for _ in 0..8 {
crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
}
}
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
out.extend_from_slice(&body);
out.extend_from_slice(&(!crc).to_be_bytes());
}
let mut out = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
let mut ihdr = w.to_be_bytes().to_vec();
ihdr.extend_from_slice(&h.to_be_bytes());
ihdr.extend_from_slice(&[8, 6, 0, 0, 0]);
chunk(&mut out, b"IHDR", &ihdr);
chunk(&mut out, b"IDAT", &[0x78, 0x9c, 0, 0]);
out
}
#[test]
fn extreme_page_index_returns_an_error() {
let d = Document::from_text("Hostile page selection");
@@ -1623,3 +1649,16 @@ fn zero_width_connector_dashes() {
assert!(raw.contains("[3 2.25]0 d"), "stroke-relative dash array for {kind:?}");
}
}
#[test]
fn image_budget_is_checked_from_the_header() {
let data = Arc::new(png());
// The decoded image and its RGBA conversion are charged: two buffers at peak.
assert!(load_image_within(&data, 1024).is_some());
assert!(load_image_within(&data, 1023).is_none());
// A header (valid CRC) claiming 30000x30000 with no pixel data reads fine, then the budget refuses it.
let hdr = ihdr_only_png(30_000, 30_000);
let dims = image::ImageReader::new(std::io::Cursor::new(hdr.as_slice())).with_guessed_format().unwrap().into_dimensions().unwrap();
assert_eq!(dims, (30_000, 30_000));
assert!(load_image(&Arc::new(hdr)).is_none());
}
+89 -8
View File
@@ -58,7 +58,11 @@ impl Rendered {
/// PNG bytes (empty on failure, logged).
pub fn to_png(&self) -> Vec<u8> {
let mut px = self.to_straight();
px.resize(self.width as usize * self.height as usize * 4, 0);
let Some(len) = wordcraft_geom::rgba_bytes(self.width, self.height).and_then(|b| usize::try_from(b).ok()) else {
log::error!("PNG encode: size {}×{} overflows", self.width, self.height);
return Vec::new();
};
px.resize(len, 0);
let Some(img) = image::RgbaImage::from_raw(self.width, self.height, px) else {
log::error!("PNG encode: bad size {}×{}", self.width, self.height);
return Vec::new();
@@ -175,12 +179,22 @@ const MAX_DECODE_SIDE: u32 = u16::MAX as u32;
/// Decode an encoded image to a premultiplied pixmap. The dimension limit above and the default
/// allocation cap (512 MB, what `load_from_memory` applied) bound the decode during paint.
pub fn decode_pixmap(bytes: &[u8]) -> Option<Pixmap> {
decode_pixmap_within(bytes, wordcraft_geom::MAX_DECODED_BYTES)
}
/// [`decode_pixmap`] with an explicit RGBA byte budget (a small one in tests).
fn decode_pixmap_within(bytes: &[u8], budget: u64) -> Option<Pixmap> {
// The header gives the size; refuse before the decoder (and the RGBA copy) allocate anything.
let (hw, hh) = image_size(bytes)?;
if !wordcraft_geom::decode_fits(hw, hh, budget) {
return None;
}
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_DECODE_SIDE);
limits.max_image_height = Some(MAX_DECODE_SIDE);
reader.limits(limits);
let img = reader.decode().ok()?.to_rgba8();
let img = reader.decode().ok()?.into_rgba8();
let (w, h) = img.dimensions();
premul_pixmap(w, h, img.as_raw())
}
@@ -495,7 +509,16 @@ pub fn render_page(doc: &Document, page: &Page, scale: f32, opts: &RenderOptions
/// Render a page into a `w`×`h` raster with `view` (page points → pixels).
pub fn render_region(doc: &Document, page: &Page, w: u32, h: u32, view: Affine, opts: &RenderOptions) -> Rendered {
let (w16, h16) = (w.clamp(1, MAX_SIDE) as u16, h.clamp(1, MAX_SIDE) as u16);
render_region_capped(doc, page, w, h, view, opts, wordcraft_geom::MAX_RASTER_PIXELS)
}
/// [`render_region`] with an explicit pixel cap (a small one in tests).
fn render_region_capped(doc: &Document, page: &Page, w: u32, h: u32, view: Affine, opts: &RenderOptions, max_pixels: u64) -> Rendered {
let (w, h) = (w.clamp(1, MAX_SIDE), h.clamp(1, MAX_SIDE));
let (cw, ch) = wordcraft_geom::fit_pixels(w, h, max_pixels);
// Over the cap the whole picture is drawn smaller, not cropped.
let view = if (cw, ch) == (w, h) { view } else { Affine::scale_non_uniform(cw as f64 / w as f64, ch as f64 / h as f64) * view };
let (w16, h16) = (cw as u16, ch as u16);
let mut ctx = RenderContext::new_with(w16, h16, vello_cpu::RenderSettings { num_threads: default_threads(), ..Default::default() });
ctx.set_transform(Affine::IDENTITY);
ctx.set_paint(color(opts.ink(doc.settings.page_color.unwrap_or(opts.paper)), 1.0));
@@ -578,7 +601,9 @@ pub fn render_text_line(face: &wordcraft_fonts::FontFace, text: &str, size: f32,
let fin = |v: f32, d: f32| if v.is_finite() && v >= 0.0 { v } else { d };
let (size, w, h, margin) = (fin(size, 12.0).min(1000.0) as f64, fin(w, 1.0), fin(h, 1.0), fin(margin, 0.0).min(1000.0) as f64);
let scale = if scale.is_finite() && scale > 0.0 { scale.min(16.0) } else { 1.0 };
let (w16, h16) = (((w * scale).ceil() as u32).clamp(1, MAX_SIDE) as u16, ((h * scale).ceil() as u32).clamp(1, MAX_SIDE) as u16);
let (pw, ph) = (((w * scale).ceil() as u32).clamp(1, MAX_SIDE), ((h * scale).ceil() as u32).clamp(1, MAX_SIDE));
let (cw, ch) = wordcraft_geom::fit_pixels(pw, ph, wordcraft_geom::MAX_RASTER_PIXELS);
let (w16, h16) = (cw as u16, ch as u16);
let mut ctx = RenderContext::new_with(w16, h16, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
ctx.set_transform(Affine::IDENTITY);
ctx.set_paint(color(Rgb(255, 255, 255), 1.0));
@@ -586,7 +611,7 @@ pub fn render_text_line(face: &wordcraft_fonts::FontFace, text: &str, size: f32,
ctx.set_paint(color(Rgb(0, 0, 0), 1.0));
let upem = if face.upem.is_finite() && face.upem > 0.0 { face.upem } else { 1000.0 };
let k = size / upem;
let view = Affine::scale(scale as f64);
let view = Affine::scale_non_uniform(cw as f64 / pw as f64, ch as f64 / ph as f64) * Affine::scale(scale as f64);
let cap = if face.cap_height.is_finite() { face.cap_height.clamp(upem * 0.3, upem) } else { upem * 0.7 };
let baseline = (h as f64 + cap * k) / 2.0;
let mut x = margin;
@@ -1117,9 +1142,12 @@ pub fn render_area(doc: &Document, page: &Page, x: f32, y: f32, w: f32, h: f32,
/// at `scale` px/pt: no paper, no watermark. A dragged object's preview.
pub fn render_items(doc: &Document, items: &[Draw], x: f32, y: f32, w: f32, h: f32, scale: f32, opts: &RenderOptions) -> Rendered {
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
let w16 = ((w * scale).ceil() as u32).clamp(1, MAX_SIDE) as u16;
let h16 = ((h * scale).ceil() as u32).clamp(1, MAX_SIDE) as u16;
let view = Affine::scale(scale as f64) * Affine::translate((-x as f64, -y as f64));
let (pw, ph) = (((w * scale).ceil() as u32).clamp(1, MAX_SIDE), ((h * scale).ceil() as u32).clamp(1, MAX_SIDE));
let (cw, ch) = wordcraft_geom::fit_pixels(pw, ph, wordcraft_geom::MAX_RASTER_PIXELS);
let (w16, h16) = (cw as u16, ch as u16);
let view = Affine::scale_non_uniform(cw as f64 / pw as f64, ch as f64 / ph as f64)
* Affine::scale(scale as f64)
* Affine::translate((-x as f64, -y as f64));
let mut ctx = RenderContext::new_with(w16, h16, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
let visible = view.inverse().transform_rect_bbox(kurbo::Rect::new(0.0, 0.0, w16 as f64, h16 as f64)).inflate(40.0, 40.0);
for it in items {
@@ -1392,6 +1420,59 @@ mod tests {
assert_eq!(&img.pixels[0..3], &[255, 255, 255]);
}
/// A PNG signature, an IHDR declaring `w`×`h` 8-bit RGBA and a short IDAT (all CRCs valid, so
/// the header parses), with no real pixel data.
fn ihdr_only_png(w: u32, h: u32) -> Vec<u8> {
fn chunk(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
let mut body = kind.to_vec();
body.extend_from_slice(data);
let mut crc = 0xFFFF_FFFFu32;
for b in &body {
crc ^= *b as u32;
for _ in 0..8 {
crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
}
}
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
out.extend_from_slice(&body);
out.extend_from_slice(&(!crc).to_be_bytes());
}
let mut out = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
let mut ihdr = w.to_be_bytes().to_vec();
ihdr.extend_from_slice(&h.to_be_bytes());
ihdr.extend_from_slice(&[8, 6, 0, 0, 0]);
chunk(&mut out, b"IHDR", &ihdr);
chunk(&mut out, b"IDAT", &[0x78, 0x9c, 0, 0]);
out
}
#[test]
fn decode_budget_is_checked_from_the_header() {
let mut buf = Vec::new();
let img = image::RgbaImage::from_fn(16, 8, |x, y| image::Rgba([x as u8, y as u8, 9, 255]));
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).unwrap();
// Decode, the RGBA conversion and the premultiplied copy are charged: two buffers at peak.
assert!(decode_pixmap_within(&buf, 1024).is_some());
assert!(decode_pixmap_within(&buf, 1023).is_none());
// A header (valid CRC) claiming 60000x60000 with no pixel data reads fine, then the budget refuses it.
let hdr = ihdr_only_png(60_000, 60_000);
assert_eq!(image_size(&hdr), Some((60_000, 60_000)));
assert!(decode_pixmap(&hdr).is_none());
}
#[test]
fn page_raster_is_capped_by_total_pixels() {
let d = Document::new();
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
let opts = RenderOptions::default();
let big = render_region_capped(&d, &l.pages[0], 3000, 2000, Affine::scale(4.0), &opts, 100_000);
assert!(big.width as u64 * big.height as u64 <= 100_000, "{}x{}", big.width, big.height);
assert!(big.width > 300 && big.height > 200);
assert_eq!(big.pixels.len() as u64, big.width as u64 * big.height as u64 * 4);
let ok = render_region_capped(&d, &l.pages[0], 300, 200, Affine::IDENTITY, &opts, 100_000);
assert_eq!((ok.width, ok.height), (300, 200));
}
#[test]
fn hostile_scale_is_clamped() {
let d = Document::new();