mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-31 05:47:00 -06:00
* 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:
co-authored by
Claude Opus 5.5
Dmitrii Khlapov
Brandon Thomas
parent
c9f3da2fc5
commit
3787af0a67
@@ -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 |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user