Browse / github.com/storytold/gridcraft / patch
Security: PDF export keeps a bounded amount of picture data across pages
Security: PDF export keeps a bounded amount of picture data across pages Refs #816. Complements #818. **Problem.** `render_pdf` memoizes picture *decodes* (#2416), but `draw_objects` still calls `Page::image_rgba` for every page a picture overlaps. Each call keeps a fresh RGB (+alpha) copy in `Page::images` until `finish()`. Nothing bounds those copies across the document: `MAX_PAGE_CONTENT` caps only the content string. Anchor extents come from the file (clamped to ±1e7 px), so one picture can cover every page. On a release build of `main` (45f5ab18), a 3 190-byte xlsx with one 4000×4000 PNG anchored at 3000×12000 px: - grew about 29 MiB per page; - passed 2 GiB
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Higgs32584 (imported from pull request #2720, open)
- Base
45f5ab18e4e57bf50ee88d2526deb4b87c5b24cb- Size
- +248 −32 in 6 files
- Review
- pending
- Shared
- 2026-10-28
- Id
056e865bbe058b425570c072
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/cmd/print.rs crates/engine/src/cmd/print/draw.rs crates/engine/src/cmd/print/objects.rs crates/engine/src/cmd/print/tests.rs crates/pdf/src/lib.rs crates/pdf/src/tests.rs
@@ -32,7 +32,7 @@ use layout::bands; pub(crate) use layout::split_bands; pub use layout::{PrintOptions, SheetLayout, layout_sheet, layouts, paper_size, print_range}; use layout::{known_paper, seg_size, segments};-use objects::draw_objects;+use objects::{PicCache, draw_objects}; #[cfg(test)] use text::{center_across_width, wrap_lines}; @@ -84,7 +84,14 @@ pub const MAX_PAGES: usize = 2000; /// Renders laid-out sheets to a PDF. pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str) -> (Vec<u8>, usize) {+ render_pdf_with_image_budget(wb, layouts, file, path, gridcraft_pdf::MAX_DOC_IMAGE_BYTES)+}++/// [`render_pdf`] keeping at most `image_bytes` of picture data in the document (tests lower it+/// to exercise the cap without building gigabytes of pictures).+fn render_pdf_with_image_budget(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str, image_bytes: u64) -> (Vec<u8>, usize) { let mut doc = PdfDoc::new();+ doc.set_image_budget(image_bytes); doc.info.title = if wb.props.title.is_empty() { file.to_string() } else { wb.props.title.clone() }; doc.info.author = wb.props.author.clone(); let total: usize = layouts.iter().map(|l| l.pages.len()).sum();@@ -93,8 +100,9 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str let mut pending_links = Vec::new(); let mut printed_blocks = Vec::new(); // Decoded pictures are reused across the pages their anchor covers: a floating picture- // spanning 40 pages was decoded (and pushed into the PDF) 40 times (#2389).- let mut pics: std::collections::HashMap<(usize, usize), Option<(Vec<u8>, u32, u32)>> = std::collections::HashMap::new();+ // spanning 40 pages was decoded 40 times (#2389). Each page still keeps its own copy in the+ // PDF, within the document's image budget.+ let mut pics = PicCache::default(); for lay in layouts { let Some(sh) = wb.sheet(lay.sheet) else { continue }; let mut cf = if sh.cond_formats.is_empty() { None } else { Some(crate::cf::CfCache::new()) };@@ -117,7 +125,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str let mut x = ox + lay.heading_w * k; for (csi, cs) in csegs.iter().enumerate() { let b = make_block(wb, lay.sheet, sh, *rs, *cs, x, y, lay);- draw_block(pg, &b, lay.settings.gridlines, &mut cf);+ draw_block(pg, &b, lay.settings.gridlines, &mut cf, &mut pics); draw_block_links(pg, &b, page_index, &mut pending_links); printed_blocks.push(PrintedBlock { page: page_index, sheet: lay.sheet, rows: *rs, cols: *cs, x, y, k }); if lay.settings.headings {@@ -1,5 +1,5 @@ use super::layout::{DENSE_BLOCK, SheetLayout};-use super::objects::draw_cell_picture;+use super::objects::{PicCache, draw_cell_picture}; use super::text::draw_text; use super::*; @@ -225,7 +225,7 @@ fn draw_border(pg: &mut Page, b: &BorderLine, wb: &Workbook, x0: f32, y0: f32, x } /// Draws the cells of a block: fills, gridlines, data bars, borders and text.-pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Option<crate::cf::CfCache>) {+pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Option<crate::cf::CfCache>, pics: &mut PicCache) { let (bx, by) = (b.xs.first().copied().unwrap_or(0.0), b.ys.first().copied().unwrap_or(0.0)); let (bw, bh) = (b.xs.last().copied().unwrap_or(bx) - bx, b.ys.last().copied().unwrap_or(by) - by); if bw <= 0.0 || bh <= 0.0 {@@ -338,7 +338,7 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt continue; } if let Some(picture) = sh.cell_pictures.get(c) {- draw_cell_picture(pg, &picture.data, (x, y, w, h), b.k);+ draw_cell_picture(pg, pics, (b.si, *c), &picture.data, (x, y, w, h), b.k); } else { draw_text(pg, b, *c, l, (x, y, w, h), merged, &merges, (bx, bx + bw)); }@@ -1,9 +1,63 @@ use super::draw::{Block, PdfMeasure, Xf, draw_prims, rgb_of}; use super::text::wrap_lines; use super::*;+use std::borrow::Cow;+use std::collections::hash_map::Entry;++/// A decoded picture: RGBA pixels, width, height.+type Decoded = (Vec<u8>, u32, u32);++/// Most bytes of decoded pictures one render memoizes; a picture decoded past it is used once+/// and dropped (and decoded again on the next page that prints it).+const MAX_PIC_CACHE_BYTES: u64 = 256 * 1024 * 1024;++#[derive(Clone, Copy, PartialEq, Eq, Hash)]+enum PicKey {+ /// A floating picture: sheet index and position in `Sheet::images`.+ Floating(usize, usize),+ /// A cell picture: sheet index, cell and the thumbnail size it is decoded at.+ Cell(usize, CellRef, u32, u32),+}++/// Decoded pictures reused across the pages (and repeated title rows) that print them, so a+/// picture is decoded once per render instead of once per page (#2389).+#[derive(Default)]+pub(super) struct PicCache {+ map: std::collections::HashMap<PicKey, Option<Decoded>>,+ /// Bytes of pixels in `map`.+ bytes: u64,+}++impl PicCache {+ /// The decoded picture for `key`, running `decode` the first time. A failed decode is+ /// remembered too; a decode that does not fit in the cache is returned without being kept.+ fn get(&mut self, key: PicKey, decode: impl FnOnce() -> Option<Decoded>) -> Option<Cow<'_, Decoded>> {+ let slot = match self.map.entry(key) {+ Entry::Occupied(e) => e.into_mut(),+ Entry::Vacant(e) => {+ let decoded = decode();+ let size = decoded.as_ref().map_or(0, |d| d.0.len() as u64);+ if self.bytes.saturating_add(size) > MAX_PIC_CACHE_BYTES {+ return decoded.map(Cow::Owned);+ }+ self.bytes = self.bytes.saturating_add(size);+ e.insert(decoded)+ }+ };+ slot.as_ref().map(Cow::Borrowed)+ }+}++/// Whether a picture decoded into at most `max_w`×`max_h` pixels can still be kept by the PDF.+/// The document's image room only shrinks while it is built, so a picture refused here would be+/// refused on every later page too and is not decoded at all.+fn fits(room: u64, max_w: u32, max_h: u32) -> bool {+ u64::from(max_w).saturating_mul(u64::from(max_h)).saturating_mul(4) <= room+} /// Cell pictures fit the current cell (or merged-cell) rectangle without changing aspect ratio.-pub(super) fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32, f32), k: f32) {+/// `at` (sheet index and cell) keys the decode in `pics`.+pub(super) fn draw_cell_picture(pg: &mut Page, pics: &mut PicCache, at: (usize, CellRef), data: &[u8], rect: (f32, f32, f32, f32), k: f32) { let (x, y, w, h) = rect; let pad = 2.0 * k; let (w, h) = ((w - 2.0 * pad).max(0.0), (h - 2.0 * pad).max(0.0));@@ -14,18 +68,30 @@ pub(super) fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32 if iw == 0 || ih == 0 || iw > 8192 || ih > 8192 || u64::from(iw) * u64::from(ih) > 16_000_000 { return; }- let Ok(mut reader) = image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format() else { return };+ let (tw, th) = (((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32);+ let room = pg.image_room();+ let Some(image) = pics.get(PicKey::Cell(at.0, at.1, tw, th), || decode_cell_picture(data, tw, th, room)) else { return };+ let (px, px_w, px_h) = &*image;+ let fit = (w / iw as f32).min(h / ih as f32);+ let (pw, ph) = (iw as f32 * fit, ih as f32 * fit);+ pg.image_rgba(x + pad + (w - pw) / 2.0, y + pad + (h - ph) / 2.0, pw, ph, px, *px_w, *px_h);+}++/// Decodes a cell picture to RGBA scaled to fit `tw`×`th` pixels, unless the PDF has no `room`+/// left for it.+fn decode_cell_picture(data: &[u8], tw: u32, th: u32, room: u64) -> Option<Decoded> {+ if !fits(room, tw, th) {+ return None;+ }+ let mut reader = image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format().ok()?; let mut limits = image::Limits::default(); limits.max_image_width = Some(8192); limits.max_image_height = Some(8192); limits.max_alloc = Some(64 * 1024 * 1024); reader.limits(limits);- let Ok(image) = reader.decode() else { return };- let image =- image.thumbnail(((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32).to_rgba8();- let fit = (w / iw as f32).min(h / ih as f32);- let (pw, ph) = (iw as f32 * fit, ih as f32 * fit);- pg.image_rgba(x + pad + (w - pw) / 2.0, y + pad + (h - ph) / 2.0, pw, ph, image.as_raw(), image.width(), image.height());+ let image = reader.decode().ok()?.thumbnail(tw, th).to_rgba8();+ let (pw, ph) = (image.width(), image.height());+ Some((image.into_raw(), pw, ph)) } /// Sheet pixel rectangle of an anchor.@@ -33,10 +99,11 @@ fn anchor_rect(sh: &Sheet, a: &Anchor) -> (f64, f64, f64, f64) { (sh.col_left(a.cell.col) + a.dx as f64, sh.row_top(a.cell.row) + a.dy as f64, a.width.max(0.0) as f64, a.height.max(0.0) as f64) } -/// Decodes an embedded picture to RGBA, downscaled to at most `max_w`×`max_h` pixels.-fn decode_image(data: &[u8], max_w: u32, max_h: u32) -> Option<(Vec<u8>, u32, u32)> {+/// Decodes an embedded picture to RGBA, downscaled to at most `max_w`×`max_h` pixels, unless the+/// PDF has no `room` left for it.+fn decode_image(data: &[u8], max_w: u32, max_h: u32, room: u64) -> Option<Decoded> { let (w, h) = crate::io::image_size(data)?;- if w == 0 || h == 0 || w as u64 * h as u64 > 60_000_000 {+ if w == 0 || h == 0 || w as u64 * h as u64 > 60_000_000 || !fits(room, w.min(max_w.max(1)), h.min(max_h.max(1))) { return None; } let img = image::load_from_memory(data).ok()?;@@ -49,14 +116,7 @@ fn decode_image(data: &[u8], max_w: u32, max_h: u32) -> Option<(Vec<u8>, u32, u3 /// Draws charts, pictures, shapes and sparklines overlapping the body block of a page. /// `pics` memoizes decoded pictures for the whole render (one page at a time draws each /// picture again, but the decode happens once).-pub(super) fn draw_objects(- pg: &mut Page,- wb: &Workbook,- si: usize,- sh: &Sheet,- b: &Block,- pics: &mut std::collections::HashMap<(usize, usize), Option<(Vec<u8>, u32, u32)>>,-) {+pub(super) fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block, pics: &mut PicCache) { let block = b.range(); let (sx0, sy0) = (sh.col_left(block.start.col), sh.row_top(block.start.row)); let (sx1, sy1) = (sh.col_left(block.end.col.saturating_add(1)), sh.row_top(block.end.row.saturating_add(1)));@@ -75,8 +135,9 @@ pub(super) fn draw_objects( let (x, y, w, h) = place(r); // About 150 dpi is plenty for print. let (mw, mh) = (((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32);- let decoded = pics.entry((si, imi)).or_insert_with(|| decode_image(&im.data, mw, mh));- if let Some((px, pw, ph)) = decoded.as_ref() {+ let room = pg.image_room();+ if let Some(decoded) = pics.get(PicKey::Floating(si, imi), || decode_image(&im.data, mw, mh, room)) {+ let (px, pw, ph) = &*decoded; pg.image_rgba(x, y, w, h, px, *pw, *ph); } }@@ -359,6 +359,62 @@ fn charts_and_images_are_drawn() { } } +/// Bytes of pixel data in a PDF's image XObjects: 3 per RGB pixel, 1 per soft-mask pixel.+fn image_bytes(pdf: &[u8]) -> u64 {+ let text = String::from_utf8_lossy(pdf);+ text.split("/Subtype /Image")+ .skip(1)+ .map(|obj| {+ let dict = &obj[..obj.find(">>").unwrap()];+ let num = |key: &str| -> u64 {+ let at = dict.find(key).unwrap() + key.len();+ dict[at..].split_whitespace().next().unwrap().parse().unwrap()+ };+ num("/Width ") * num("/Height ") * if dict.contains("/DeviceRGB") { 3 } else { 1 }+ })+ .sum()+}++#[test]+fn picture_copies_across_pages_stay_within_the_image_budget() {+ // Each page a picture overlaps kept its own decoded copy in the PDF with no document-wide+ // bound: a 3 KB workbook with one tall picture reached 2 GiB before running out of pages.+ let mut s = s();+ let mut png = Vec::new();+ let img = image::RgbaImage::from_pixel(64, 64, image::Rgba([10, 120, 200, 255]));+ image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();+ s.execute("cell.set", json!({"cell": "A1", "input": "Title"})).unwrap();+ s.execute("pageLayout.pageSetup", json!({"titleRows": "1:1"})).unwrap();+ let d = s.doc_mut().unwrap();+ let wb = std::sync::Arc::make_mut(&mut d.wb);+ let id = wb.next_object_id();+ let sh = wb.sheet_mut(0).unwrap();+ // A floating picture over some 20 pages, and a cell picture in the title row on every page.+ sh.images.push(Image {+ id,+ anchor: Anchor { cell: CellRef::new(1, 1), dx: 0.0, dy: 0.0, width: 200.0, height: 19_990.0, mode: gridcraft_model::AnchorMode::MoveAndSize },+ data: std::sync::Arc::new(png.clone()),+ mime: "image/png".into(),+ alt: String::new(),+ });+ sh.cell_pictures.insert(CellRef::new(0, 2), std::sync::Arc::new(CellPicture { data: png, mime: "image/png".into(), alt: String::new() }));+ let d = s.doc().unwrap();+ let lays = layouts(&d.wb, &PrintOptions::from_params(&json!({}), "file.exportPdf").unwrap());++ let (full, pages) = render_pdf(&d.wb, &lays, "t", "");+ assert!(pages >= 15, "{pages} pages");+ // Within the default budget every page keeps its own copy (64×64 RGB is 12,288 bytes).+ let full_bytes = image_bytes(&full);+ assert!(full_bytes >= (pages as u64 - 1) * 12_288, "{full_bytes} bytes of images on {pages} pages");++ let budget = 64 * 1024;+ let (capped, capped_pages) = render_pdf_with_image_budget(&d.wb, &lays, "t", "", budget);+ assert_eq!(capped_pages, pages, "pages past the budget still print, without the pictures");+ let kept = image_bytes(&capped);+ assert!((12_288..=budget).contains(&kept), "{kept} bytes of images kept with a {budget}-byte budget");+ assert!(contains(&capped, "%%EOF"));+}+ #[test] fn page_setup_edits_settings() { let mut s = s();@@ -5,7 +5,9 @@ //! WinAnsiEncoding (CJK and other scripts) are set in a predefined, non-embedded Adobe-GB1 CID //! font with the `UniGB-UTF16-H` CMap instead of being replaced by `?`. //! - Vector drawing: rectangles, lines (solid or dashed), polygons, circles (Bézier), clipping.-//! - RGBA images as RGB image XObjects with a soft mask for alpha (run-length encoded).+//! - RGBA images as RGB image XObjects with a soft mask for alpha (run-length encoded). The+//! decoded image bytes one document keeps across all its pages are capped+//! ([`MAX_DOC_IMAGE_BYTES`]); images past the cap are left out. //! - Coordinates are points with a **top-left origin** (y grows down); text `y` is the baseline. //! //! Streams are written uncompressed (images use the trivial RunLengthDecode filter) so the crate@@ -321,6 +323,9 @@ pub struct Page { content: String, fonts: [bool; FONT_SLOTS], images: Vec<ImageData>,+ /// Image bytes this page may still keep before the document's image budget is spent. Set by+ /// [`PdfDoc::add_page`] and [`PdfDoc::page_mut`], which hand out one page at a time.+ image_room: u64, links: Vec<LinkAnnotation>, depth: u32, }@@ -330,6 +335,10 @@ pub struct Page { const MAX_PAGE_CONTENT: usize = 64 * 1024 * 1024; /// Largest image (pixels) embedded. const MAX_IMAGE_PIXELS: u64 = 40_000_000;+/// Most bytes of image data (RGB plus alpha planes) one document keeps across all its pages.+/// Each [`Page::image_rgba`] call keeps its own copy, so a picture repeated on every page of a+/// long export would otherwise grow without bound; images past the budget are left out.+pub const MAX_DOC_IMAGE_BYTES: u64 = 512 * 1024 * 1024; const MAX_PAGE_LINKS: usize = 4096; const MAX_LINK_URI_BYTES: usize = 4096; @@ -346,6 +355,20 @@ impl Page { fn full(&self) -> bool { self.content.len() > MAX_PAGE_CONTENT }++ /// Bytes of image data this page may still add before the document's image budget+ /// ([`MAX_DOC_IMAGE_BYTES`]) is spent. An image of `pw`×`ph` pixels needs up to `pw * ph * 4`;+ /// callers can skip decoding pictures that cannot fit (the room never grows back while a+ /// document is built).+ pub fn image_room(&self) -> u64 {+ self.image_room+ }++ /// Bytes of image data kept by this page.+ fn image_bytes(&self) -> u64 {+ self.images.iter().fold(0u64, |acc, i| acc.saturating_add(i.rgb.len() as u64).saturating_add(i.alpha.as_ref().map_or(0, |a| a.len() as u64)))+ }+ fn op(&mut self, s: &str) { if !self.full() { self.content.push_str(s);@@ -576,12 +599,18 @@ impl Page { } /// Draws an RGBA image (`pw`×`ph` pixels, row-major, straight alpha) into the box- /// `(x, y, w, h)`. Malformed or oversized buffers are ignored.+ /// `(x, y, w, h)`. Malformed or oversized buffers are ignored, and so are images that do not+ /// fit in what is left of the document's image budget ([`Page::image_room`]). pub fn image_rgba(&mut self, x: f32, y: f32, w: f32, h: f32, rgba: &[u8], pw: u32, ph: u32) { let px = pw as u64 * ph as u64; if pw == 0 || ph == 0 || px > MAX_IMAGE_PIXELS || (rgba.len() as u64) < px * 4 || !(extent(w) && extent(h) && finite(&[x, y])) { return; }+ // Room for the worst case (RGB plus an alpha plane) is checked before anything is+ // allocated, so the document's image bytes stay bounded however many pages it has.+ if px * 4 > self.image_room {+ return;+ } let px = px as usize; let mut rgb = Vec::with_capacity(px * 3); let mut alpha = Vec::with_capacity(px);@@ -590,6 +619,8 @@ impl Page { alpha.push(p[3]); } let alpha = if alpha.iter().all(|a| *a == 255) { None } else { Some(alpha) };+ let kept = (rgb.len() as u64).saturating_add(alpha.as_ref().map_or(0, |a| a.len() as u64));+ self.image_room = self.image_room.saturating_sub(kept); let idx = self.images.len(); self.images.push(ImageData { width: pw, height: ph, rgb, alpha }); let s = format!("q {} 0 0 {} {} {} cm /Im{} Do Q", n(w), n(h), n(x), n(self.y(y + h)), idx + 1);@@ -610,6 +641,8 @@ pub struct Info { pub struct PdfDoc { pages: Vec<Page>, pub info: Info,+ /// Image budget lowered by [`PdfDoc::set_image_budget`]; `None` = [`MAX_DOC_IMAGE_BYTES`].+ image_budget: Option<u64>, } /// PackBits-style run-length encoding (PDF `RunLengthDecode`).@@ -677,18 +710,43 @@ impl PdfDoc { pub fn add_page(&mut self, width_pt: f32, height_pt: f32) -> &mut Page { let width = if width_pt.is_finite() { width_pt.clamp(3.0, 14_400.0) } else { 612.0 }; let height = if height_pt.is_finite() { height_pt.clamp(3.0, 14_400.0) } else { 792.0 };- self.pages.push(Page { width, height, content: String::new(), fonts: [false; FONT_SLOTS], images: Vec::new(), links: Vec::new(), depth: 0 });+ let image_room = self.image_room();+ self.pages.push(Page {+ width,+ height,+ content: String::new(),+ fonts: [false; FONT_SLOTS],+ images: Vec::new(),+ image_room,+ links: Vec::new(),+ depth: 0,+ }); let i = self.pages.len() - 1; &mut self.pages[i] } + /// Lowers the bytes of image data the whole document keeps (never above+ /// [`MAX_DOC_IMAGE_BYTES`]); images drawn past it are left out.+ pub fn set_image_budget(&mut self, bytes: u64) {+ self.image_budget = Some(bytes.min(MAX_DOC_IMAGE_BYTES));+ }++ /// Image bytes the document may still keep: its budget minus what every page holds.+ fn image_room(&self) -> u64 {+ let used = self.pages.iter().fold(0u64, |acc, p| acc.saturating_add(p.image_bytes()));+ self.image_budget.unwrap_or(MAX_DOC_IMAGE_BYTES).saturating_sub(used)+ }+ pub fn page_count(&self) -> usize { self.pages.len() } /// The page at `i`, to keep drawing on it. pub fn page_mut(&mut self, i: usize) -> Option<&mut Page> {- self.pages.get_mut(i)+ let room = self.image_room();+ let page = self.pages.get_mut(i)?;+ page.image_room = room;+ Some(page) } /// Serializes the document. A document without pages gets one blank Letter page.@@ -318,3 +318,36 @@ fn cjk_text_is_not_replaced_by_question_marks() { // Measured as full-width glyphs, matching the CID font's default width. assert_eq!(text_width(Font::Helvetica, 10.0, "签字"), 20.0); }++#[test]+fn image_bytes_are_bounded_across_pages() {+ // Each page keeps its own copy of a picture it draws, so a picture repeated on every page of+ // a long export grew without bound (a 3 KB workbook reached 2 GiB). The document-wide budget+ // stops adding copies once it is spent.+ let mut d = PdfDoc::new();+ assert_eq!(d.add_page(100.0, 100.0).image_room(), MAX_DOC_IMAGE_BYTES);+ d.set_image_budget(u64::MAX);+ assert_eq!(d.add_page(100.0, 100.0).image_room(), MAX_DOC_IMAGE_BYTES, "the budget is never raised");++ let mut d = PdfDoc::new();+ d.set_image_budget(10_000);+ // Opaque 32×32: 4096 bytes of room needed, 3072 bytes (RGB, no mask) kept.+ let px = [200u8, 200, 200, 255].repeat(32 * 32);+ for _ in 0..50 {+ let p = d.add_page(100.0, 100.0);+ p.image_rgba(0.0, 0.0, 50.0, 50.0, &px, 32, 32);+ }+ let kept: u64 = d.pages.iter().map(Page::image_bytes).sum();+ assert_eq!(kept, 2 * 3072);+ assert_eq!(d.pages.iter().filter(|p| !p.images.is_empty()).count(), 2);+ assert!(d.pages.iter().skip(2).all(|p| p.images.is_empty() && p.content.is_empty()));+ // A page reopened later sees what the whole document already holds.+ let p = d.page_mut(0).unwrap();+ assert_eq!(p.image_room(), 10_000 - 2 * 3072);+ p.image_rgba(0.0, 0.0, 50.0, 50.0, &px, 32, 32);+ p.image_rgba(0.0, 0.0, 10.0, 10.0, &[255; 4 * 4 * 4], 4, 4);+ assert_eq!(d.pages[0].images.len(), 2, "the big copy is refused, the small one still fits");+ let pdf = d.finish();+ check_xref(&pdf);+ assert_eq!(String::from_utf8_lossy(&pdf).matches("/Subtype /Image").count(), 3);+}