diff --git a/ROADMAP.md b/ROADMAP.md index 3662b5843..fccd2ed30 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 8673b7064..1c8c8590c 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -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; diff --git a/crates/geom/src/pixels.rs b/crates/geom/src/pixels.rs new file mode 100644 index 000000000..64258a71f --- /dev/null +++ b/crates/geom/src/pixels.rs @@ -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 { + (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); + } +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index e0719ef22..fb3812d95 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -1680,10 +1680,18 @@ fn path_from(b: &kurbo::BezPath) -> Option { /// 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>) -> Option { + 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>, budget: u64) -> Option { 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>) -> Option { 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)] diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 1b470745c..f45c4eb90 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -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 { + fn chunk(out: &mut Vec, 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()); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 1126e4ee1..df7bfa2f1 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -58,7 +58,11 @@ impl Rendered { /// PNG bytes (empty on failure, logged). pub fn to_png(&self) -> Vec { 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 { + 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 { + // 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 { + fn chunk(out: &mut Vec, 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();