AF.5: bound hostile Affinity files; fuzz the new import paths

- One budget for the whole read (layers, pixels, characters, table
  cells), shared with embedded documents and charged also when their
  reading fails; the first reading is dropped before the second.
- Tables: at most 65 536 cells; lines and cell formats that don't
  match the grid are left out instead of shifted.
- Paragraph formats matched to runs by binary search; font names
  longer than 256 bytes are dropped.
- Overlays: one work budget per import; encoded originals decoded
  within their layer's size; table cell outlines recoloured too.
- Engine: master page copies capped, each page's master found in
  linear time, line gaps looked up once per font, the working CMYK
  table built once, objects past MAX_COORD left out.
- Embedded content stays with its node; pixel masks under artistic
  text are read whole and include stroke reach; gradient overlays on
  hidden layers are kept.
- Render: contexts and raster effect images stay within
  MAX_RASTER_SIDE per side (vello overflowed near u16::MAX; found by
  the new fuzz target).
- Tests: affinity tests/import_limits.rs, feature samples in
  import_fuzz.rs, shared builders in engine/tests/support/.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
dwetscher
2026-10-22 18:24:57 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent fd50bd6f84
commit f73a3df974
16 changed files with 1330 additions and 524 deletions
+125 -58
View File
@@ -271,9 +271,11 @@ pub struct Table {
pub rows: Vec<f64>,
/// Row by row.
pub cells: Vec<TableCell>,
/// Vertical edges row by row, one more per row than the columns; `None` draws no line.
/// Vertical edges row by row, one more per row than the columns; `None` draws no line, and
/// the list is empty when the file's didn't match the grid.
pub vertical: Vec<Option<Stroke>>,
/// Horizontal edges boundary by boundary (one more than the rows), one per column.
/// Horizontal edges boundary by boundary (one more than the rows), one per column (empty
/// as `vertical` can be).
pub horizontal: Vec<Option<Stroke>>,
/// Node space to document pixels.
pub transform: Affine,
@@ -472,6 +474,29 @@ const MAX_LAYER_PIXELS: u64 = 512 << 20;
/// Embedded documents inside embedded documents are followed this deep.
const MAX_EMBED_DEPTH: usize = 4;
/// Characters of all stories together (a story may be shared by many text layers).
const MAX_DOCUMENT_CHARS: usize = 16 << 20;
/// Table cells of all tables together (each becomes a text object with its edge lines).
const MAX_DOCUMENT_CELLS: usize = 1 << 18;
/// What a whole read may still use. Shared objects can be linked from many layers, and embedded
/// documents are read with what their embedding document left, so linking or embedding the
/// same content many times can't multiply the work.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Budget {
/// Layer nodes ([`MAX_NODES`] in all).
pub(crate) nodes: usize,
/// Pixels the pixel layers may decode ([`MAX_LAYER_PIXELS`] in all).
pub(crate) pixels: u64,
/// Characters of stories ([`MAX_DOCUMENT_CHARS`] in all).
pub(crate) chars: usize,
/// Table cells ([`MAX_DOCUMENT_CELLS`] in all).
pub(crate) cells: usize,
}
impl Budget {
pub(crate) const FULL: Budget = Budget { nodes: MAX_NODES, pixels: MAX_LAYER_PIXELS, chars: MAX_DOCUMENT_CHARS, cells: MAX_DOCUMENT_CELLS };
}
/// Read a document: archive, `doc.dat` and the model.
pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
@@ -480,28 +505,35 @@ pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
/// [`read`], its pixel layers holding at most `pixels` pixels together.
pub(crate) fn read_within(bytes: &[u8], limits: Limits, pixels: u64) -> Result<Document, Error> {
read_at(bytes, limits, pixels, Affine::IDENTITY, 0).map(|r| r.document)
read_at(bytes, limits, &mut Budget { pixels, ..Budget::FULL }, Affine::IDENTITY, 0).document
}
/// A document read by [`read_at`], with what it used of its budgets.
/// A document read by [`read_at`] (or why it couldn't be), with the bytes its archive extracted
/// either way.
struct ReadAt {
document: Document,
/// Bytes its archive extracted.
document: Result<Document, Error>,
extracted: usize,
/// Pixels its pixel layers left of the allowance.
pixels_left: u64,
}
/// Read a document whose spreads are placed by `base` (for embedded documents, nested `depth`
/// deep), its pixel layers holding at most `pixels` pixels together.
fn read_at(bytes: &[u8], limits: Limits, pixels: u64, base: Affine, depth: usize) -> Result<ReadAt, Error> {
let mut archive = Archive::open(bytes, limits)?;
/// deep). What it uses is taken from `budget`, also when it fails part of the way.
fn read_at(bytes: &[u8], limits: Limits, budget: &mut Budget, base: Affine, depth: usize) -> ReadAt {
let mut archive = match Archive::open(bytes, limits) {
Ok(a) => a,
Err(e) => return ReadAt { document: Err(e), extracted: 0 },
};
let document = read_archive(&mut archive, budget, base, depth);
ReadAt { document, extracted: archive.extracted() }
}
fn read_archive(archive: &mut Archive, budget: &mut Budget, base: Affine, depth: usize) -> Result<Document, Error> {
let doc = archive.read("doc.dat")?;
let s = stream::parse(&doc)?;
let mut reader = Reader::new(&s, &mut archive, base, depth);
reader.pixels_left = pixels;
let (document, pixels_left) = reader.document()?;
Ok(ReadAt { document, extracted: archive.extracted(), pixels_left })
let mut reader = Reader::new(&s, archive, base, depth);
reader.budget = *budget;
let document = reader.document();
*budget = reader.budget;
document
}
pub(crate) struct Reader<'s, 'a, 'b> {
@@ -509,7 +541,6 @@ pub(crate) struct Reader<'s, 'a, 'b> {
pub(crate) archive: &'b mut Archive<'a>,
pub(crate) dpi: f64,
pub(crate) warnings: BTreeMap<String, usize>,
nodes: usize,
active: HashSet<ObjId>,
/// Where the spreads go: the identity, or an embedding document's placement.
base: Affine,
@@ -521,8 +552,8 @@ pub(crate) struct Reader<'s, 'a, 'b> {
pending: Option<Vec<Node>>,
/// The master spreads, in the order of [`Document::masters`].
master_ids: Vec<ObjId>,
/// Pixels the pixel layers still to come may decode ([`MAX_LAYER_PIXELS`] in all).
pub(crate) pixels_left: u64,
/// What the rest of the read may use.
pub(crate) budget: Budget,
}
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
@@ -533,14 +564,13 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
archive,
dpi: 72.0,
warnings: BTreeMap::new(),
nodes: 0,
active: HashSet::new(),
base,
type_base: Affine([a, b, c, d, 0.0, 0.0]),
depth,
pending: None,
master_ids: Vec::new(),
pixels_left: MAX_LAYER_PIXELS,
budget: Budget::FULL,
}
}
@@ -548,8 +578,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
*self.warnings.entry(what.into()).or_default() += 1;
}
/// The document, and the pixels its pixel layers left of the allowance.
fn document(mut self) -> Result<(Document, u64), Error> {
/// The document.
fn document(&mut self) -> Result<Document, Error> {
let s = self.s;
let root = s.root;
self.dpi = s.obj(root, b"UVCn").and_then(|u| s.f64(u, b"UPPI")).filter(|d| *d > 0.0 && *d < 100_000.0).unwrap_or(72.0);
@@ -589,8 +619,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
.and_then(|p| s.bytes(p, b"Data"))
.filter(|b| b.len() <= MAX_PROFILE_BYTES && b.get(16..20) == Some(b"CMYK".as_slice()))
.map(<[u8]>::to_vec);
let warnings = self.warnings.into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect();
Ok((Document { dpi: self.dpi, spreads, masters, saved_by, warnings, cmyk_profile }, self.pixels_left))
let warnings = std::mem::take(&mut self.warnings).into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect();
Ok(Document { dpi: self.dpi, spreads, masters, saved_by, warnings, cmyk_profile })
}
/// A spread (`Sprd`) of the document `doc`, or of its master spreads.
@@ -650,10 +680,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
self.warn("layers nested deeper than 128 levels");
return Ok(None);
}
self.nodes += 1;
if self.nodes > MAX_NODES {
let Some(left) = self.budget.nodes.checked_sub(1) else {
return Err(Error::Limit("too many layers"));
}
};
self.budget.nodes = left;
// A node that contains itself (a shared object linked back into its own subtree).
if !self.active.insert(id) {
self.warn("a layer that contains itself");
@@ -680,17 +710,16 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
} else {
self.kind(id, class, world, parent)?
};
// This node's embedded content, before its children's `node` calls can take it.
let embedded = self.pending.take();
// A compound's children are its operands, already merged into its outline.
let (mut children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? };
if let Some(embedded) = self.pending.take() {
if let Some(embedded) = embedded {
children.extend(embedded);
}
// Pixel masks are read only over what they mask (they can span a whole spread at print
// resolution, a gigapixel).
let over = match (kind_bounds(&kind), nodes_bounds(&children)) {
(Some(a), Some(b)) => Some(union(a, b)),
(a, b) => a.or(b),
};
let over = mask_extent(&kind, &children);
let child_mask = match child_mask {
Some((c, at)) => crate::raster::mask(self, c, at, over)?,
None => None,
@@ -699,8 +728,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
let pixel_mask = attached_mask.or(child_mask);
self.active.remove(&id);
let mut node = Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, effects: Vec::new(), children };
// Gradient overlays are laid out over the node's bounds.
let bounds = nodes_bounds(std::slice::from_ref(&node));
// Gradient overlays are laid out over the node's bounds, also while it is hidden.
let bounds = tree_bounds(&node);
node.effects = self.effects(id, world, bounds);
Ok(Some(node))
}
@@ -925,20 +954,30 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
return None;
}
// A first reading finds its page and resolution, the second places it.
let probe = match read_at(inner, self.archive.remaining(), self.pixels_left, Affine::IDENTITY, self.depth + 1) {
Ok(r) => r,
// Both readings are charged to the budget and the archive's limits, also when they fail:
// each does the work.
let mut budget = self.budget;
let probe = read_at(inner, self.archive.remaining(), &mut budget, Affine::IDENTITY, self.depth + 1);
self.budget = budget;
if self.archive.charge(probe.extracted).is_err() {
self.warn("embedded documents beyond the size limit (left out)");
return None;
}
let probe = match probe.document {
Ok(d) => d,
Err(e) => {
self.warn(format!("embedded documents that could not be read ({e})"));
return None;
}
};
let used = probe.extracted;
let spread = probe.document.spreads.first()?;
let spread = probe.spreads.first()?;
// `PBBx` 2 places the content's own bounds (as with a cached picture); otherwise its page.
let content = s.obj(id, b"Bitm").and_then(|b| s.enumeration(b, b"PBBx")).is_some_and(|(k, _)| k == 2);
let first_board = spread.nodes.iter().find_map(|n| if let Kind::Artboard { rect, .. } = &n.kind { Some(*rect) } else { None });
let page = if content { None } else { spread.pages.first().copied().or(first_board) };
let page = page.or_else(|| nodes_bounds(&spread.nodes)).unwrap_or(spread.bounds);
// Its pixels aren't needed any more: never hold both readings' at once.
drop(probe);
// Its document pixels are the node's units, whatever either document's resolution.
let k = 1.0;
let (cx, cy) = ((page.x0 + page.x1) / 2.0, (page.y0 + page.y1) / 2.0);
@@ -947,18 +986,14 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
self.warn("embedded documents with an invalid placement");
return None;
}
if self.archive.charge(used).is_err() {
self.warn("embedded documents beyond the size limit (left out)");
return None;
}
let placed = read_at(inner, self.archive.remaining(), self.pixels_left, base, self.depth + 1).ok()?;
// The probe's pixels were only looked at; the placed content's stay.
self.pixels_left = placed.pixels_left;
let doc = placed.document;
let mut budget = self.budget;
let placed = read_at(inner, self.archive.remaining(), &mut budget, base, self.depth + 1);
self.budget = budget;
if self.archive.charge(placed.extracted).is_err() {
self.warn("embedded documents beyond the size limit (left out)");
return None;
}
let doc = placed.document.ok()?;
for w in doc.warnings {
self.warn(format!("{w} [in embedded documents]"));
}
@@ -1005,22 +1040,54 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
/// The bounds of what visible `nodes` draw (control points of outlines, corners of images, frames
/// and tables), in document pixels.
pub fn nodes_bounds(nodes: &[Node]) -> Option<Rect> {
fn walk(nodes: &[Node], depth: usize, out: &mut Option<Rect>) {
if depth > MAX_TREE_DEPTH {
return;
}
for n in nodes.iter().filter(|n| n.visible) {
kind_points(&n.kind, &mut |p| add_point(out, p));
walk(&n.children, depth + 1, out);
}
}
let mut out = None;
walk(nodes, 0, &mut out);
walk_bounds(nodes, true, 0, &mut out);
out
}
/// The bounds of what one node's own kind draws (not its children).
pub(crate) fn kind_bounds(kind: &Kind) -> Option<Rect> {
/// The bounds of `node` and everything in it, hidden or not.
fn tree_bounds(node: &Node) -> Option<Rect> {
let mut out = None;
walk_bounds(std::slice::from_ref(node), false, 0, &mut out);
out
}
fn walk_bounds(nodes: &[Node], visible_only: bool, depth: usize, out: &mut Option<Rect>) {
if depth > MAX_TREE_DEPTH {
return;
}
for n in nodes.iter().filter(|n| n.visible || !visible_only) {
kind_points(&n.kind, &mut |p| add_point(out, p));
walk_bounds(&n.children, visible_only, depth + 1, out);
}
}
/// What a pixel mask over a node of `kind` with `children` is read over: the bounds of what they
/// draw, strokes included, or `None` (all of the mask) when some of it has no bounds known here:
/// artistic text, whose glyphs only the layout places.
fn mask_extent(kind: &Kind, children: &[Node]) -> Option<Rect> {
fn visit(kind: &Kind, children: &[Node], depth: usize, out: &mut Option<Rect>) -> bool {
if depth > MAX_TREE_DEPTH || matches!(kind, Kind::Text(t) if t.frame.is_none()) {
return false;
}
let mut own = kind_bounds(kind);
if let (Some(r), Kind::Shape { strokes, .. } | Kind::Artboard { strokes, .. }) = (own, kind) {
// As far as a stroke can reach out of its path, miter joins included.
let reach = strokes.iter().map(|s| s.width * s.miter_limit.clamp(1.0, 10.0)).filter(|w| w.is_finite()).fold(0.0, f64::max);
own = Some(Rect { x0: r.x0 - reach, y0: r.y0 - reach, x1: r.x1 + reach, y1: r.y1 + reach });
}
if let Some(r) = own {
*out = Some(out.map_or(r, |o| union(o, r)));
}
children.iter().filter(|n| n.visible).all(|n| visit(&n.kind, &n.children, depth + 1, out))
}
let mut out = None;
if visit(kind, children, 0, &mut out) { out } else { None }
}
/// The bounds of what one node's own kind draws (not its children): its outline's points, its
/// image's or frame's corners, or artistic text's anchor.
pub fn kind_bounds(kind: &Kind) -> Option<Rect> {
let mut out = None;
kind_points(kind, &mut |p| add_point(&mut out, p));
out
+75 -10
View File
@@ -17,6 +17,11 @@ const MAX_DEPTH: usize = 256;
/// Most pixels an overlay is baked into per image.
const MAX_PIXELS: usize = 64 << 20;
/// Work the overlays of one import may take together: each overlay counts the nodes and pixels
/// of the subtree it is baked into (which is copied once more for them all). Nested overlays
/// and shared content would otherwise multiply it; past it, overlays are left out and said.
pub const MAX_WORK: u64 = 1 << 29;
#[derive(Clone, Copy)]
enum Source<'a> {
Solid(Color),
@@ -30,9 +35,10 @@ struct Overlay<'a> {
blend: Blend,
}
/// `node` with its colour and gradient overlays baked in (borrowed unchanged when it has none).
/// `warn` hears what could only be approximated.
pub fn apply<'a>(node: &'a Node, warn: &mut dyn FnMut(&str)) -> Cow<'a, Node> {
/// `node` with its colour and gradient overlays baked in (borrowed unchanged when it has none,
/// or when what is left of `work`, see [`MAX_WORK`], can't pay for them). `warn` hears what
/// could only be approximated or was left out.
pub fn apply<'a>(node: &'a Node, work: &mut u64, warn: &mut dyn FnMut(&str)) -> Cow<'a, Node> {
let overlays: Vec<Overlay> = node
.effects
.iter()
@@ -48,6 +54,13 @@ pub fn apply<'a>(node: &'a Node, warn: &mut dyn FnMut(&str)) -> Cow<'a, Node> {
if overlays.is_empty() {
return Cow::Borrowed(node);
}
let copies = u64::try_from(overlays.len()).unwrap_or(u64::MAX).saturating_add(1);
let cost = subtree_cost(node, *work / copies, 0).saturating_mul(copies);
let Some(left) = work.checked_sub(cost) else {
warn("overlay effects beyond the work one import may take (left out)");
return Cow::Borrowed(node);
};
*work = left;
let mut out = node.clone();
out.effects.retain(|e| !matches!(e, Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. }));
for o in &overlays {
@@ -59,6 +72,26 @@ pub fn apply<'a>(node: &'a Node, warn: &mut dyn FnMut(&str)) -> Cow<'a, Node> {
Cow::Owned(out)
}
/// Nodes and pixels in `node`'s subtree, counted only until they pass `limit`.
fn subtree_cost(node: &Node, limit: u64, depth: usize) -> u64 {
if depth > MAX_DEPTH {
return 0;
}
let mut cost: u64 = match &node.kind {
Kind::Image(img) => (u64::from(img.width) * u64::from(img.height)).saturating_add(1),
Kind::Text(t) => u64::try_from(t.runs.len()).unwrap_or(u64::MAX).saturating_add(1),
Kind::Table(t) => u64::try_from(t.cells.len()).unwrap_or(u64::MAX).saturating_add(1),
_ => 1,
};
for c in &node.children {
if cost > limit {
break;
}
cost = cost.saturating_add(subtree_cost(c, limit - cost, depth + 1));
}
cost
}
fn bake_node(node: &mut Node, o: &Overlay, depth: usize, warn: &mut dyn FnMut(&str)) {
if depth > MAX_DEPTH {
return;
@@ -88,6 +121,9 @@ fn bake_node(node: &mut Node, o: &Overlay, depth: usize, warn: &mut dyn FnMut(&s
for c in &mut t.cells {
for r in &mut c.runs {
r.fill = paint(&r.fill, o, warn);
if let Some(s) = &mut r.stroke {
stroke(s, o, warn);
}
}
}
for s in t.vertical.iter_mut().chain(t.horizontal.iter_mut()).flatten() {
@@ -236,9 +272,9 @@ fn pixels(img: &mut Image, o: &Overlay) -> bool {
Some(p) => p.to_vec(),
None => return false,
},
Pixels::Encoded(bytes) => match image::load_from_memory(bytes) {
Ok(d) if d.width() == img.width && d.height() == img.height => d.to_rgba8().into_raw(),
_ => return false,
Pixels::Encoded(bytes) => match decode(bytes, img.width, img.height) {
Some(p) => p,
None => return false,
},
// An overlay is blended in RGB: CMYK pixels take the profile-free formula here.
Pixels::Cmyka8(p) => match p.get(..n.saturating_mul(5)) {
@@ -292,6 +328,21 @@ fn pixels(img: &mut Image, o: &Overlay) -> bool {
true
}
/// An encoded original's RGBA pixels, decoded only if it is `width` × `height` as its layer says.
fn decode(bytes: &[u8], width: u32, height: u32) -> Option<Vec<u8>> {
let probe = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
if probe.into_dimensions().ok()? != (width, height) {
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(width);
limits.max_image_height = Some(height);
limits.max_alloc = u64::try_from(MAX_PIXELS).ok().map(|n| n * 8);
reader.limits(limits);
Some(reader.decode().ok()?.into_rgba8().into_raw())
}
/// The gradient's colour at unit-space point `q`.
fn sample(g: &Gradient, q: Point) -> Color {
let t = match g.kind {
@@ -495,11 +546,11 @@ mod tests {
#[test]
fn a_full_colour_overlay_replaces_the_fill_and_keeps_its_alpha() {
let white = Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 };
let n = apply(&solid(white, 1.0, Blend::Normal), &mut |_| {}).into_owned();
let n = apply(&solid(white, 1.0, Blend::Normal), &mut { MAX_WORK }, &mut |_| {}).into_owned();
assert_eq!(fill(&n), white);
assert!(n.effects.is_empty(), "the overlay is baked, not kept");
// Half way, mixed in RGB.
let n = apply(&solid(white, 0.5, Blend::Normal), &mut |_| {}).into_owned();
let n = apply(&solid(white, 0.5, Blend::Normal), &mut { MAX_WORK }, &mut |_| {}).into_owned();
assert_eq!(fill(&n), Color::Rgb { r: 1.0, g: 0.75, b: 0.5, a: 1.0 });
}
@@ -519,7 +570,7 @@ mod tests {
fn overlays_recolour_pixels_inside_their_alpha() {
let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, 1.0, Blend::Normal);
n.kind = Kind::Image(Image { width: 2, height: 1, pixels: Pixels::Rgba8(vec![255, 0, 0, 255, 255, 0, 0, 0]), transform: Affine::IDENTITY });
let out = apply(&n, &mut |_| {}).into_owned();
let out = apply(&n, &mut { MAX_WORK }, &mut |_| {}).into_owned();
let Kind::Image(img) = &out.kind else { panic!() };
assert_eq!(img.pixels, Pixels::Rgba8(vec![0, 0, 255, 255, 255, 0, 0, 0]));
}
@@ -536,8 +587,22 @@ mod tests {
};
let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, 1.0, Blend::Normal);
n.effects = vec![Effect::GradientOverlay { gradient: g.clone(), opacity: 1.0, blend: Blend::Normal }];
let out = apply(&n, &mut |_| {}).into_owned();
let out = apply(&n, &mut { MAX_WORK }, &mut |_| {}).into_owned();
let Kind::Shape { fills, .. } = &out.kind else { panic!() };
assert_eq!(fills.first(), Some(&Paint::Gradient(g)));
}
#[test]
fn overlays_stop_at_the_work_one_import_may_take() {
let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, 1.0, Blend::Normal);
n.kind = Kind::Image(Image { width: 4, height: 4, pixels: Pixels::Rgba8(vec![255; 64]), transform: Affine::IDENTITY });
// 16 pixels and the node, for the copy and for the overlay: 34.
let mut work = 40;
let mut warnings = Vec::new();
assert!(matches!(apply(&n, &mut work, &mut |w| warnings.push(w.to_string())), Cow::Owned(_)));
assert_eq!((work, warnings.len()), (6, 0));
assert!(matches!(apply(&n, &mut work, &mut |w| warnings.push(w.to_string())), Cow::Borrowed(_)));
assert_eq!(work, 6);
assert!(warnings.iter().any(|w| w.contains("beyond the work")), "{warnings:?}");
}
}
+2 -2
View File
@@ -168,11 +168,11 @@ fn decode_within(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), worl
}
// Before anything is allocated: a layer's tiles can be flat colour, with nothing to extract.
let pixels = u64::from(w.div_ceil(step)) * u64::from(h.div_ceil(step));
let Some(left) = r.pixels_left.checked_sub(pixels) else {
let Some(left) = r.budget.pixels.checked_sub(pixels) else {
r.warn("pixel layers beyond the pixels one document may hold");
return Ok(None);
};
r.pixels_left = left;
r.budget.pixels = left;
let format = s.enumeration(bitmap, b"Frmt").map(|(f, _)| f);
let (channels, bps) = match format {
Some(0) => (4, 1),
+48 -12
View File
@@ -48,6 +48,8 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
/// Rows and columns a table may have.
const MAX_TABLE_LINES: usize = 4096;
/// Cells a table may have (rows × columns): each becomes a text object with its edge lines.
const MAX_TABLE_CELLS: usize = 1 << 16;
/// A table node (`TxtT`): the grid, the cells' text and the lines on the cell edges.
pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option<Table> {
@@ -63,10 +65,24 @@ pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option<Table> {
let rows = bounds(s.obj(tabl, b"RPos"))?;
let (ncols, nrows) = (columns.len() - 1, rows.len() - 1);
let count = ncols.checked_mul(nrows)?;
let cell_objs = s.obj(tabl, b"Cell").map(|c| s.objs(c, b"Cell")).unwrap_or_default();
if cell_objs.len() != count {
r.warn("tables whose cells don't match their grid (cell formatting left out)");
if count > MAX_TABLE_CELLS {
r.warn("tables with more than 65536 cells (left out)");
return None;
}
let Some(left) = r.budget.cells.checked_sub(count) else {
r.warn("tables beyond the cells one document may hold (left out)");
return None;
};
r.budget.cells = left;
let cell_objs = s.obj(tabl, b"Cell").map(|c| s.objs(c, b"Cell")).unwrap_or_default();
// Formatting that doesn't match the grid would land on the wrong cells (and merging them,
// move text between cells): only text then.
let cell_objs = if cell_objs.len() == count {
cell_objs
} else {
r.warn("tables whose cells don't match their grid (cell formatting left out)");
Vec::new()
};
let blank = || TableCell { runs: Vec::new(), inset: [0.0; 4], valign: VAlign::Top, merged_left: false };
let mut cells: Vec<TableCell> = (0..count).map(|_| blank()).collect();
for (i, (cell, obj)) in cells.iter_mut().zip(&cell_objs).enumerate() {
@@ -109,7 +125,7 @@ pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option<Table> {
let objs = s.obj(tabl, tag).map(|e| s.objs(e, b"Edge")).unwrap_or_default();
if objs.len() != expected {
r.warn("tables whose lines don't match their grid (lines left out)");
return vec![None; expected];
return Vec::new();
}
objs.into_iter()
.map(|e| match (s.obj(e, b"Fill"), s.obj(e, b"Line")) {
@@ -145,8 +161,9 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec<Te
r.warn("a text block without readable characters (left out)");
continue;
};
// Bound collection itself, including placeholders, before allocating the character buffer.
let budget = MAX_CHARS - total;
// Bound collection itself, including placeholders, before allocating the character buffer:
// by the story's limit and by what the document's other stories left.
let budget = (MAX_CHARS - total).min(r.budget.chars.saturating_sub(total));
let mut chars: Vec<char> = match s.str(glyphs, b"Utf8") {
Some(t) => t.chars().take(budget + 1).collect(),
@@ -182,7 +199,11 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec<Te
let truncated = chars.len() > budget;
if truncated {
chars.truncate(budget);
r.warn("text longer than a million characters (truncated)");
if budget < MAX_CHARS - total {
r.warn("text beyond the characters one document may hold (truncated)");
} else {
r.warn("text longer than a million characters (truncated)");
}
}
total += chars.len();
// Paragraph formats by end index; a run without an index covers the rest of the block.
@@ -201,14 +222,17 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec<Te
},
}
}
let align_at = |i: usize| paragraphs.iter().find(|(end, _)| i < *end).map(|(_, a)| *a).or(open).unwrap_or_default();
// Sorted by end (they are in order in every file), so both lookups below are binary
// searches: a block can have as many paragraphs and runs as characters.
paragraphs.sort_by_key(|(end, _)| *end);
let align_at = |i: usize| paragraphs.get(paragraphs.partition_point(|(end, _)| *end <= i)).map(|(_, a)| *a).or(open).unwrap_or_default();
let mut cuts: Vec<usize> = paragraphs.iter().map(|(end, _)| *end).filter(|e| *e < chars.len()).collect();
cuts.sort_unstable();
cuts.dedup();
let push = |r: &mut Reader, runs: &mut Vec<TextRun>, attrs: Option<ObjId>, from: usize, to: usize, budget: &mut Option<usize>| {
// Split at paragraph ends so every run has one paragraph format.
let inside = cuts.get(cuts.partition_point(|c| *c <= from)..cuts.partition_point(|c| *c < to)).unwrap_or_default();
let mut a = from;
for &c in cuts.iter().filter(|c| **c > from && **c < to).chain(std::iter::once(&to)) {
for &c in inside.iter().chain(std::iter::once(&to)) {
push_run(r, runs, attrs, characters(&chars, a, c), world, budget, align_at(a));
a = c;
}
@@ -236,6 +260,7 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec<Te
break;
}
}
r.budget.chars = r.budget.chars.saturating_sub(total);
if placeholders {
r.warn("text fields such as page numbers (left out of the text)");
}
@@ -370,8 +395,8 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribut
if font.and_then(|f| s.int(f, b"Widh")).is_some_and(|w| w != 5) {
r.warn("font width variants (exact face depends on the installed PostScript font)");
}
let postscript = metadata_string(r, attribute_budget, font.and_then(|f| s.str(f, b"Post")).unwrap_or_default());
let family = metadata_string(r, attribute_budget, font.and_then(|f| s.str(f, b"Famy")).unwrap_or_default());
let postscript = font_name(r, attribute_budget, font.and_then(|f| s.str(f, b"Post")).unwrap_or_default());
let family = font_name(r, attribute_budget, font.and_then(|f| s.str(f, b"Famy")).unwrap_or_default());
let (features, all_caps) = font_features(r, objects.get(7).copied().flatten(), attribute_budget);
let weight = font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400);
let weight = if (1..=1000).contains(&weight) {
@@ -415,6 +440,17 @@ fn charge_attributes(r: &mut Reader, budget: &mut Option<usize>, bytes: usize) -
}
}
/// Longest font name kept (OpenType names are far shorter); every run and paragraph carries it.
const MAX_FONT_NAME: usize = 256;
fn font_name(r: &mut Reader, budget: &mut Option<usize>, value: &str) -> String {
if value.len() > MAX_FONT_NAME {
r.warn("font names longer than 256 bytes (the default font is used)");
return String::new();
}
metadata_string(r, budget, value)
}
fn metadata_string(r: &mut Reader, budget: &mut Option<usize>, value: &str) -> String {
let bytes = value.len().saturating_add(std::mem::size_of::<String>());
if value.is_empty() || !charge_attributes(r, budget, bytes) { String::new() } else { value.to_owned() }