mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 20:58:00 -06:00
AF.4: validate and harden Affinity 3 artboards, text and images
This commit is contained in:
@@ -6,15 +6,77 @@ use crate::stream::{ObjId, Tag, Value};
|
||||
|
||||
/// Nodes per curve object.
|
||||
const MAX_NODES: usize = 4_000_000;
|
||||
/// A shared-object stream is bounded, but a compound graph can expand exponentially. Count
|
||||
/// each visit and each curve record / generated contour / transformed segment across the whole
|
||||
/// outline, including repeated references. Match the document reader's nesting limit.
|
||||
const MAX_OUTLINE_DEPTH: usize = 128;
|
||||
const MAX_OUTLINE_VISITS: usize = 100_000;
|
||||
|
||||
struct OutlineBudget {
|
||||
active: Vec<ObjId>,
|
||||
visits: usize,
|
||||
work: usize,
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
impl OutlineBudget {
|
||||
fn fail(&mut self, r: &mut Reader, warning: &'static str) -> Option<()> {
|
||||
if !self.failed {
|
||||
r.warn(warning);
|
||||
}
|
||||
self.failed = true;
|
||||
None
|
||||
}
|
||||
|
||||
fn spend(&mut self, r: &mut Reader, work: usize) -> Option<()> {
|
||||
if work > self.work {
|
||||
return self.fail(r, "compound outlines exceeding the geometry work limit");
|
||||
}
|
||||
self.work -= work;
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn path(&mut self, r: &mut Reader, path: &Path) -> Option<()> {
|
||||
for subpath in &path.subpaths {
|
||||
self.spend(r, 1)?;
|
||||
self.spend(r, subpath.segments.len())?;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The node-space outline of a curve, shape or compound node, and whether it fills even-odd.
|
||||
/// Instances (master-page or symbol copies) take the geometry of their master.
|
||||
pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> Option<(Path, bool)> {
|
||||
let mut budget = OutlineBudget { active: Vec::new(), visits: MAX_OUTLINE_VISITS, work: MAX_NODES, failed: false };
|
||||
bounded_outline(r, id, class, world, &mut budget)
|
||||
}
|
||||
|
||||
fn bounded_outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine, budget: &mut OutlineBudget) -> Option<(Path, bool)> {
|
||||
if budget.active.contains(&id) {
|
||||
budget.fail(r, "a compound outline that contains itself")?;
|
||||
}
|
||||
if budget.active.len() >= MAX_OUTLINE_DEPTH {
|
||||
budget.fail(r, "compound outlines nested deeper than 128 levels")?;
|
||||
}
|
||||
if budget.visits == 0 {
|
||||
budget.fail(r, "compound outlines exceeding the operand visit limit")?;
|
||||
}
|
||||
budget.visits -= 1;
|
||||
budget.active.push(id);
|
||||
let result = outline_inner(r, id, class, world, budget);
|
||||
// The popped value is our own stack marker, and is no longer needed.
|
||||
let _ = budget.active.pop();
|
||||
result
|
||||
}
|
||||
|
||||
fn outline_inner(r: &mut Reader, id: ObjId, class: Tag, world: Affine, budget: &mut OutlineBudget) -> Option<(Path, bool)> {
|
||||
let s = r.s;
|
||||
match &class.0.to_be_bytes() {
|
||||
b"PCrv" | b"TxtC" => {
|
||||
let crvs = s.obj(id, b"Crvs").or_else(|| s.obj(id, b"MCrM").and_then(|m| s.obj(m, b"Crvs")))?;
|
||||
let path = curves(r, crvs)?;
|
||||
let path = curves(r, crvs, budget)?;
|
||||
budget.path(r, &path)?;
|
||||
let multi = path.subpaths.len() > 1;
|
||||
Some((path, multi))
|
||||
}
|
||||
@@ -29,8 +91,24 @@ pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> O
|
||||
if s.enumeration(c, b"ComO").is_some_and(|(op, _)| op != 0 && op != 2) {
|
||||
r.warn("compound shapes that intersect or exclude (imported as add)");
|
||||
}
|
||||
if let Some((p, _)) = outline(r, c, class, local.then(world)) {
|
||||
path.subpaths.extend(p.transformed(local).subpaths);
|
||||
let child_world = local.then(world);
|
||||
if !child_world.is_finite() {
|
||||
budget.fail(r, "a compound operand with an invalid transform")?;
|
||||
}
|
||||
if let Some((mut p, _)) = bounded_outline(r, c, class, child_world, budget) {
|
||||
// Charge before transforming or extending. Transform the owned path in place
|
||||
// so nested compounds do not make an additional copy of every contour.
|
||||
budget.path(r, &p)?;
|
||||
for subpath in &mut p.subpaths {
|
||||
subpath.start = local.apply(subpath.start);
|
||||
for segment in &mut subpath.segments {
|
||||
*segment = segment.map(|point| local.apply(point));
|
||||
}
|
||||
}
|
||||
path.subpaths.extend(p.subpaths);
|
||||
} else if budget.failed {
|
||||
// Never present a partial compound as complete after a cycle or budget limit.
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some((path, true))
|
||||
@@ -44,7 +122,9 @@ pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> O
|
||||
}
|
||||
};
|
||||
let b = s.floats::<4>(box_owner, b"ShpB")?;
|
||||
crate::shapes::shape(r, shape, b, world).map(|p| (p, false))
|
||||
let path = crate::shapes::shape(r, shape, b, world)?;
|
||||
budget.path(r, &path)?;
|
||||
Some((path, false))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,7 +133,7 @@ pub(crate) fn outline(r: &mut Reader, id: ObjId, class: Tag, world: Affine) -> O
|
||||
/// A record is (f64 x, f64 y, u8 kind, u8 role): role 0 is an anchor, 1 the out-handle of the
|
||||
/// anchor before it, 2 the in-handle of the anchor after it. A closed subpath repeats its first
|
||||
/// anchor at the end. `CnrD` live corners are rounded on the stored polyline.
|
||||
pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
|
||||
fn curves(r: &mut Reader, crvs: ObjId, budget: &mut OutlineBudget) -> Option<Path> {
|
||||
let s = r.s;
|
||||
let data = s.obj(crvs, b"Data")?;
|
||||
let mut fields = s.positional(data);
|
||||
@@ -62,6 +142,7 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
|
||||
Value::UInt(n) => usize::try_from(*n).ok()?,
|
||||
_ => return None,
|
||||
};
|
||||
budget.spend(r, count)?;
|
||||
let corners = s.obj(crvs, b"CnrD").and_then(|c| corner_radii(s.positional(c).collect()));
|
||||
let mut path = Path::default();
|
||||
let mut seen = 0usize;
|
||||
@@ -73,9 +154,9 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
|
||||
let Value::Array(records) = fields.next()? else { return None };
|
||||
seen = seen.checked_add(records.len())?;
|
||||
if seen > MAX_NODES {
|
||||
r.warn("curves with more than four million nodes");
|
||||
return None;
|
||||
budget.fail(r, "curves with more than four million nodes")?;
|
||||
}
|
||||
budget.spend(r, records.len())?;
|
||||
let anchors = anchors(records)?;
|
||||
let radii = if index == 0 { corners.as_ref() } else { None };
|
||||
if let Some(sp) = subpath(&anchors, closed, radii) {
|
||||
|
||||
@@ -172,6 +172,9 @@ pub struct TextRun {
|
||||
/// CSS weight (400 regular, 700 bold).
|
||||
pub weight: i64,
|
||||
pub italic: bool,
|
||||
/// Boolean OpenType feature overrides, as tags or `-tag` for disabled features.
|
||||
pub features: Vec<String>,
|
||||
pub all_caps: bool,
|
||||
/// Font size in document pixels, before the node's transform.
|
||||
pub size: f64,
|
||||
/// Tracking in em.
|
||||
@@ -223,10 +226,16 @@ pub enum Kind {
|
||||
/// Layer container (`Scop`).
|
||||
Layer,
|
||||
Group,
|
||||
/// Artboard: a rectangle that clips its children; `background` paints behind them.
|
||||
/// Artboard: an export rectangle and vector outline that clips its children.
|
||||
/// `background` paints behind them, and `strokes` over them.
|
||||
Artboard {
|
||||
rect: Rect,
|
||||
/// Actual clipping outline in document pixels. The artboard's export rectangle is its
|
||||
/// bounding box, but a rotated or non-rectangular board still clips to this outline.
|
||||
path: Path,
|
||||
even_odd: bool,
|
||||
background: Vec<Paint>,
|
||||
strokes: Vec<Stroke>,
|
||||
},
|
||||
/// Curve, parametric shape or compound shape: an outline with its fills and strokes.
|
||||
/// Children of a shape are clipped by its outline (painted over its fills, under its strokes).
|
||||
@@ -436,7 +445,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
b"Scop" => Kind::Layer,
|
||||
b"Grup" => Kind::Group,
|
||||
b"PCrv" | b"ShpN" | b"Comp" | b"SNEN" | b"SNRR" | b"ShRN" => {
|
||||
if class == Tag::of(b"ShpN") && s.bool(id, b"ABEn") == Some(true) {
|
||||
// Affinity 3.2.3's two-board public fixture marks board shapes with an `aprp`
|
||||
// property object in `phrp`, while older documents use `ABEn`. Ordinary shapes
|
||||
// in that fixture have neither. See the pinned corpus and validation audit.
|
||||
if s.bool(id, b"ABEn") == Some(true) || s.obj(id, b"phrp").is_some_and(|p| s.is(p, b"aprp")) {
|
||||
return Ok(self.artboard(id, world));
|
||||
}
|
||||
let Some((local, even_odd)) = crate::geometry::outline(self, id, class, world) else {
|
||||
@@ -523,12 +535,15 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
|
||||
fn artboard(&mut self, id: ObjId, world: Affine) -> Kind {
|
||||
let s = self.s;
|
||||
let b = s.floats::<4>(id, b"ShpB").map(rect).unwrap_or(Rect { x0: 0.0, y0: 0.0, x1: 0.0, y1: 0.0 });
|
||||
let Some(b) = s.floats::<4>(id, b"ShpB").map(rect).filter(|b| b.x1 > b.x0 && b.y1 > b.y0) else {
|
||||
self.warn("an artboard without valid bounds (its children are kept without clipping)");
|
||||
return Kind::Unsupported;
|
||||
};
|
||||
let corners =
|
||||
[Point { x: b.x0, y: b.y0 }, Point { x: b.x1, y: b.y0 }, Point { x: b.x1, y: b.y1 }, Point { x: b.x0, y: b.y1 }].map(|p| world.apply(p));
|
||||
let [a, b2, c, d, ..] = world.0;
|
||||
if b2.abs() > 1e-9 || c.abs() > 1e-9 || a <= 0.0 || d <= 0.0 {
|
||||
self.warn("a rotated or flipped artboard (imported at its bounding box)");
|
||||
self.warn("a rotated or flipped artboard (export bounds are its bounding box; artwork keeps its outline)");
|
||||
}
|
||||
let xs = corners.map(|p| p.x);
|
||||
let ys = corners.map(|p| p.y);
|
||||
@@ -538,8 +553,17 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
x1: xs.iter().copied().fold(f64::NEG_INFINITY, f64::max),
|
||||
y1: ys.iter().copied().fold(f64::NEG_INFINITY, f64::max),
|
||||
};
|
||||
let background = paint::fills(self, id, world, b"BFFl");
|
||||
Kind::Artboard { rect, background }
|
||||
if ![rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) || rect.x1 <= rect.x0 || rect.y1 <= rect.y0 {
|
||||
self.warn("an artboard with an invalid transformed size (its children are kept without clipping)");
|
||||
return Kind::Unsupported;
|
||||
}
|
||||
let class = s.class(id).unwrap_or(Tag::of(b"ShpN"));
|
||||
let (path, even_odd) = crate::geometry::outline(self, id, class, world).unwrap_or_else(|| {
|
||||
self.warn("an artboard whose outline could not be read (clipped to its rectangle)");
|
||||
(Path { subpaths: vec![crate::shapes::rectangle(b.x0, b.y0, b.x1, b.y1)] }, false)
|
||||
});
|
||||
let (background, strokes) = paint::node_paint(self, id, world);
|
||||
Kind::Artboard { rect, path: path.transformed(world), even_odd, background, strokes }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,11 +94,8 @@ pub fn preview(bytes: &[u8]) -> Result<Preview<'_>, Error> {
|
||||
}
|
||||
image_data = true;
|
||||
}
|
||||
b"IEND" => {
|
||||
if !image_data {
|
||||
return Err(Error::Malformed("PNG has no image data"));
|
||||
}
|
||||
}
|
||||
b"IEND" if !image_data => return Err(Error::Malformed("PNG has no image data")),
|
||||
b"IEND" => {}
|
||||
_ if kind.first().is_some_and(u8::is_ascii_uppercase) => {
|
||||
return Err(Error::Unsupported("unknown critical PNG chunk"));
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ const MAX_PIXELS: u64 = 64 << 20;
|
||||
const TILE: usize = 256;
|
||||
const TILE_BYTES: usize = TILE * TILE;
|
||||
|
||||
struct CachedPixels {
|
||||
tiles: HashMap<String, Vec<u8>>,
|
||||
source: Option<(usize, Vec<u8>)>,
|
||||
}
|
||||
|
||||
pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Image>, Error> {
|
||||
let s = r.s;
|
||||
let Some(bitmap) = s.obj(id, b"Bitm") else {
|
||||
@@ -53,7 +58,9 @@ pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result<Opt
|
||||
)
|
||||
})
|
||||
.unwrap_or((0, 0, width, height));
|
||||
if s.field(bitmap, b"Bckg").is_some() && has_tiles && !s.is(id, b"ImgN") {
|
||||
let source_tiles_only =
|
||||
(1..=4).all(|c| matches!(s.field(bitmap, &tag(b"Sta", c)), Some(Value::Array(a)) if !a.is_empty() && a.iter().all(|v| *v == Value::UInt(5))));
|
||||
if s.field(bitmap, b"Bckg").is_some() && has_tiles && !source_tiles_only && !s.is(id, b"ImgN") {
|
||||
r.warn("edited placed images use their stored pixels");
|
||||
}
|
||||
decode(r, bitmap, crop, world)
|
||||
@@ -118,7 +125,27 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
|
||||
}
|
||||
};
|
||||
let mut planes = Vec::with_capacity(channels);
|
||||
let mut cache = HashMap::new();
|
||||
// Current .af documents can keep level-zero tiles in their embedded JPEG/PNG instead of
|
||||
// duplicating the pixels. State 5 is checked in the public Patchy embedded-jpeg fixture.
|
||||
// Support only the observed, same-size RGBA8 image with a zero bitmap origin; other
|
||||
// representations still receive the existing loss warning rather than guessing a mapping.
|
||||
let needs_source = (1..=channels).any(|c| matches!(s.field(bitmap, &tag(b"Sta", c)), Some(Value::Array(a)) if a.contains(&Value::UInt(5))));
|
||||
let source = if needs_source && format == Some(0) && s.int(bitmap, b"LInf") == Some(0) && s.int(bitmap, b"TInf") == Some(0) {
|
||||
if let Some(name) = s.entry(bitmap, b"Bckg") {
|
||||
let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
||||
let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
||||
match original(r, name).and_then(|bytes| source_pixels(&bytes, width, height)) {
|
||||
Ok(pixels) => Some((width as usize, pixels)),
|
||||
Err(Error::Limit(e)) => return Err(Error::Limit(e)),
|
||||
Err(_) => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut cache = CachedPixels { tiles: HashMap::new(), source };
|
||||
for c in 1..=channels {
|
||||
planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), &mut cache)?);
|
||||
}
|
||||
@@ -158,6 +185,39 @@ fn original(r: &mut Reader, name: &str) -> Result<Vec<u8>, Error> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode the source under the same pixel budget as tiled layers, checking its actual
|
||||
/// dimensions before allocating decoded pixels. Only JPEG and PNG sources are supported.
|
||||
fn source_pixels(bytes: &[u8], width: u32, height: u32) -> Result<Vec<u8>, Error> {
|
||||
use image::{ImageFormat, ImageReader};
|
||||
use std::io::Cursor;
|
||||
|
||||
if u64::from(width) * u64::from(height) > MAX_PIXELS {
|
||||
return Err(Error::Limit("embedded image pixels"));
|
||||
}
|
||||
let mut reader = ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(|_| Error::Malformed("embedded image format"))?;
|
||||
if !matches!(reader.format(), Some(ImageFormat::Jpeg | ImageFormat::Png)) {
|
||||
return Err(Error::Unsupported("embedded source pixel format"));
|
||||
}
|
||||
let dimensions = ImageReader::new(Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.map_err(|_| Error::Malformed("embedded image format"))?
|
||||
.into_dimensions()
|
||||
.map_err(|_| Error::Malformed("embedded image dimensions"))?;
|
||||
if dimensions != (width, height) || width == 0 || height == 0 {
|
||||
return Err(Error::Malformed("embedded source and bitmap dimensions differ"));
|
||||
}
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(width);
|
||||
limits.max_image_height = Some(height);
|
||||
limits.max_alloc = Some(MAX_PIXELS * 8);
|
||||
reader.limits(limits);
|
||||
let decoded = reader.decode().map_err(|e| match e {
|
||||
image::ImageError::Limits(_) => Error::Limit("embedded image decoding"),
|
||||
_ => Error::Malformed("embedded image decoding"),
|
||||
})?;
|
||||
Ok(decoded.into_rgba8().into_raw())
|
||||
}
|
||||
|
||||
fn tag(prefix: &[u8; 3], c: usize) -> [u8; 4] {
|
||||
[prefix[0], prefix[1], prefix[2], b'0' + c as u8]
|
||||
}
|
||||
@@ -169,8 +229,9 @@ fn plane(
|
||||
c: usize,
|
||||
bps: usize,
|
||||
(x0, y0, w, h): (u32, u32, u32, u32),
|
||||
cache: &mut HashMap<String, Vec<u8>>,
|
||||
cache: &mut CachedPixels,
|
||||
) -> Result<Vec<u8>, Error> {
|
||||
let CachedPixels { tiles: cache, source } = cache;
|
||||
let s = r.s;
|
||||
let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
|
||||
let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
|
||||
@@ -219,6 +280,13 @@ fn plane(
|
||||
None
|
||||
}
|
||||
5 => {
|
||||
if let Some((source_width, pixels)) = source.as_ref() {
|
||||
let (ox, oy) = (tx * TILE, ty * TILE);
|
||||
copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| {
|
||||
pixels.get(((oy + y) * source_width + ox + x) * 4 + c - 1).copied().unwrap_or(0)
|
||||
});
|
||||
continue;
|
||||
}
|
||||
r.warn("pixel layers drawn from an embedded image (left empty)");
|
||||
None
|
||||
}
|
||||
@@ -241,3 +309,106 @@ fn copy(out: &mut [u8], row: usize, (bx0, py0, bx1, py1): (usize, usize, usize,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::Kind;
|
||||
use crate::synth::{self, F, Method, tag};
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let pixels = image::RgbaImage::from_fn(300, 2, |x, y| image::Rgba([(x % 251) as u8, (y * 31) as u8, 77, 255]));
|
||||
let mut bytes = std::io::Cursor::new(Vec::new());
|
||||
pixels.write_to(&mut bytes, image::ImageFormat::Png).unwrap();
|
||||
bytes.into_inner()
|
||||
}
|
||||
|
||||
/// An original synthetic mixed source/cache bitmap. It exercises reader recovery,
|
||||
/// not a claim that Affinity itself accepts the synthetic container.
|
||||
fn fixture(source: &[u8], origin: i32) -> Vec<u8> {
|
||||
let mut bitmap = vec![
|
||||
(tag(b"Frmt"), F::Enum(0, 0)),
|
||||
(tag(b"BmpW"), F::I32(300)),
|
||||
(tag(b"BmpH"), F::I32(2)),
|
||||
(tag(b"LInf"), F::I32(origin)),
|
||||
(tag(b"TInf"), F::I32(0)),
|
||||
(tag(b"Bckg"), F::Entry("c/1".into())),
|
||||
];
|
||||
for c in 1..=4 {
|
||||
bitmap.push((tag(&super::tag(b"TWi", c)), F::I32(2)));
|
||||
bitmap.push((tag(&super::tag(b"THi", c)), F::I32(1)));
|
||||
bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(vec![5, 4])));
|
||||
bitmap.push((
|
||||
tag(&super::tag(b"Idx", c)),
|
||||
F::Shared(vec![F::Def(c as u32, vec![tag(b"Blck")], vec![(tag(b"Data"), F::Entry(format!("d/{c}")))])]),
|
||||
));
|
||||
}
|
||||
let mut crop = vec![0x17];
|
||||
crop.extend(tag(b"BitR").0.to_le_bytes());
|
||||
crop.extend([250i32, 0, 270, 2].into_iter().flat_map(i32::to_le_bytes));
|
||||
let raster = F::Def(
|
||||
10,
|
||||
vec![tag(b"Rstr")],
|
||||
vec![
|
||||
(tag(b"Bitm"), F::Obj(tag(b"DyBm"), bitmap)),
|
||||
(tag(b"BitR"), F::Raw(crop)),
|
||||
(tag(b"Xfrm"), F::F64s(vec![0.0, -2.0, 10.0, 2.0, 0.0, 20.0])),
|
||||
],
|
||||
);
|
||||
let spread = F::Def(11, vec![tag(b"Sprd")], vec![(tag(b"Chld"), F::Shared(vec![raster]))]);
|
||||
let doc = synth::stream(&[(tag(b"DocR"), F::Obj(tag(b"DocN"), vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
|
||||
let mut blob = vec![0x2d];
|
||||
blob.extend(tag(b"Data").0.to_le_bytes());
|
||||
blob.extend((source.len() as u32).to_le_bytes());
|
||||
blob.extend(source);
|
||||
let block = synth::stream(&[(tag(b"Data"), F::Raw(blob))]);
|
||||
let tiles: Vec<_> = [200u8, 10, 30, 255].into_iter().map(|v| vec![v; TILE_BYTES]).collect();
|
||||
synth::container(
|
||||
&[
|
||||
("doc.dat", &doc, Method::Zlib),
|
||||
("c/1", &block, Method::Zlib),
|
||||
("d/1", &tiles[0], Method::Zlib),
|
||||
("d/2", &tiles[1], Method::Zlib),
|
||||
("d/3", &tiles[2], Method::Zlib),
|
||||
("d/4", &tiles[3], Method::Zlib),
|
||||
],
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_tiles_and_cached_edits_survive_crop_rotation_and_tile_boundaries() {
|
||||
let doc = crate::read(&fixture(&png(), 0), crate::Limits::default()).unwrap();
|
||||
assert_eq!(doc.warnings, ["edited placed images use their stored pixels"]);
|
||||
let Kind::Image(image) = &doc.spreads[0].nodes[0].kind else { panic!() };
|
||||
assert_eq!((image.width, image.height), (20, 2));
|
||||
assert_eq!(image.transform, Affine([0.0, 2.0, -2.0, 0.0, 10.0, 520.0]));
|
||||
let Pixels::Rgba8(pixels) = &image.pixels else { panic!() };
|
||||
for y in 0..2 {
|
||||
for x in 0..20 {
|
||||
let expected = if x + 250 < 256 { [((x + 250) % 251) as u8, (y * 31) as u8, 77, 255] } else { [200, 10, 30, 255] };
|
||||
assert_eq!(&pixels[(y * 20 + x) * 4..(y * 20 + x + 1) * 4], &expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_sources_and_unknown_origins_preserve_cached_edits_and_warn() {
|
||||
for bytes in [fixture(b"invalid image", 0), fixture(&png(), 1)] {
|
||||
let doc = crate::read(&bytes, crate::Limits::default()).unwrap();
|
||||
assert!(doc.warnings.iter().any(|w| w == "pixel layers drawn from an embedded image (left empty) (4×)"));
|
||||
let Kind::Image(image) = &doc.spreads[0].nodes[0].kind else { panic!() };
|
||||
let Pixels::Rgba8(pixels) = &image.pixels else { panic!() };
|
||||
assert_eq!(&pixels[..4], &[0, 0, 0, 0]);
|
||||
assert_eq!(&pixels[6 * 4..7 * 4], &[200, 10, 30, 255]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_source_dimensions_and_pixel_budget_are_checked_before_decode() {
|
||||
let bytes = png();
|
||||
assert!(matches!(source_pixels(&bytes, 299, 2), Err(Error::Malformed(_))));
|
||||
assert!(matches!(source_pixels(&bytes, u32::MAX, u32::MAX), Err(Error::Limit(_))));
|
||||
assert_eq!(source_pixels(&bytes, 300, 2).unwrap().len(), 300 * 2 * 4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,13 +115,21 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) ->
|
||||
}))?
|
||||
}
|
||||
b"ShPi" => {
|
||||
let start = num(r, shape, b"AngS").unwrap_or(0.0);
|
||||
let mut end = num(r, shape, b"AngE").unwrap_or(TAU);
|
||||
while end < start {
|
||||
end += TAU;
|
||||
}
|
||||
let raw_start = num(r, shape, b"AngS").unwrap_or(0.0);
|
||||
let raw_end = num(r, shape, b"AngE").unwrap_or(TAU);
|
||||
// Normalize in constant time. Repeatedly adding TAU can hang on finite values
|
||||
// large enough that floating-point addition no longer changes the endpoint.
|
||||
let start = raw_start.rem_euclid(TAU);
|
||||
let delta = raw_end - raw_start;
|
||||
let sweep = if raw_end >= raw_start {
|
||||
delta.min(TAU)
|
||||
} else if delta.is_finite() {
|
||||
delta.rem_euclid(TAU)
|
||||
} else {
|
||||
(raw_end.rem_euclid(TAU) - start).rem_euclid(TAU)
|
||||
};
|
||||
let inner = num(r, shape, b"IRad").unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
pie(cx, cy, rx, ry, start, end.min(start + TAU), inner)
|
||||
pie(cx, cy, rx, ry, start, start + sweep, inner)
|
||||
}
|
||||
b"ShpT" => {
|
||||
let p = num(r, shape, b"Pos ").unwrap_or(0.5);
|
||||
|
||||
+201
-30
@@ -3,11 +3,13 @@
|
||||
//! OpenType features) is left to the importing application.
|
||||
|
||||
use crate::model::{Affine, Align, Point, Reader, Text, TextRun, rect};
|
||||
use crate::paint::{self, Paint};
|
||||
use crate::paint::{self, Color, Paint};
|
||||
use crate::stream::{ObjId, Value};
|
||||
|
||||
/// Characters read per text node (stories are split into blocks; this caps hostile ones).
|
||||
const MAX_CHARS: usize = 1 << 20;
|
||||
/// Shared character attributes must not amplify into unbounded font/feature/gradient data.
|
||||
const MAX_ATTRIBUTE_BYTES: usize = 16 << 20;
|
||||
|
||||
pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
|
||||
let s = r.s;
|
||||
@@ -19,46 +21,76 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
|
||||
let mut align = None;
|
||||
let mut total = 0usize;
|
||||
let mut placeholders = false;
|
||||
let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES);
|
||||
for block in s.objs(story, b"Blok") {
|
||||
let glyphs = s.obj(block, b"Glyp")?;
|
||||
let chars: Vec<char> = match s.str(glyphs, b"Utf8") {
|
||||
Some(t) => t.chars().collect(),
|
||||
let Some(glyphs) = s.obj(block, b"Glyp") else {
|
||||
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;
|
||||
|
||||
let mut chars: Vec<char> = match s.str(glyphs, b"Utf8") {
|
||||
Some(t) => t.chars().take(budget + 1).collect(),
|
||||
None => {
|
||||
// Inline objects (page numbers, anchors) each take one index before the segment text.
|
||||
let segments = s.objs(glyphs, b"Mixd");
|
||||
if segments.is_empty() {
|
||||
r.warn("a text block without readable characters (left out)");
|
||||
continue;
|
||||
}
|
||||
let mut v = Vec::new();
|
||||
for seg in s.objs(glyphs, b"Mixd") {
|
||||
for seg in segments {
|
||||
let inline = s.objs(seg, b"Glys").len();
|
||||
v.extend(std::iter::repeat_n('\u{FFFC}', inline));
|
||||
v.extend(std::iter::repeat_n('\u{FFFC}', inline.min(budget + 1 - v.len())));
|
||||
placeholders |= inline > 0;
|
||||
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars());
|
||||
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take(budget + 1 - v.len()));
|
||||
if v.len() > budget {
|
||||
break;
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
};
|
||||
total = total.checked_add(chars.len())?;
|
||||
if total > MAX_CHARS {
|
||||
let truncated = chars.len() > budget;
|
||||
if truncated {
|
||||
chars.truncate(budget);
|
||||
r.warn("text longer than a million characters (truncated)");
|
||||
break;
|
||||
}
|
||||
total += chars.len();
|
||||
let mut start = 0usize;
|
||||
for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() {
|
||||
let end = usize::try_from(s.int(run, b"Indx")?).ok()?.min(chars.len());
|
||||
let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else {
|
||||
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
|
||||
continue;
|
||||
};
|
||||
let end = index.min(chars.len());
|
||||
if index > chars.len() || end < start {
|
||||
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
|
||||
}
|
||||
if end <= start {
|
||||
continue;
|
||||
}
|
||||
let text: String = chars.get(start..end)?.iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect();
|
||||
let text = characters(&chars, start, end);
|
||||
start = end;
|
||||
let Some(attrs) = s.obj(run, b"Item") else { continue };
|
||||
if !text.is_empty() {
|
||||
runs.push(run_attrs(r, attrs, text, world));
|
||||
push_run(r, &mut runs, s.obj(run, b"Item"), text, world, &mut attribute_budget);
|
||||
}
|
||||
if start < chars.len() {
|
||||
let text = characters(&chars, start, chars.len());
|
||||
push_run(r, &mut runs, None, text, world, &mut attribute_budget);
|
||||
}
|
||||
for paragraph in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() {
|
||||
if let Some(attrs) = s.obj(paragraph, b"Item") {
|
||||
let paragraph = paragraph_align(r, attrs);
|
||||
match align {
|
||||
None => align = Some(paragraph),
|
||||
Some(first) if first != paragraph => r.warn("mixed paragraph alignments (the first alignment is used)"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if align.is_none() {
|
||||
align = s
|
||||
.obj(block, b"PAtt")
|
||||
.and_then(|p| s.objs(p, b"Runs").first().copied())
|
||||
.and_then(|run| s.obj(run, b"Item"))
|
||||
.map(|p| paragraph_align(r, p));
|
||||
if truncated {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if placeholders {
|
||||
@@ -82,6 +114,39 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
|
||||
Some(Text { runs, align, anchor: Point { x: anchor_x, y: first_baseline }, frame: if artistic { None } else { Some(bounds) }, transform: world })
|
||||
}
|
||||
|
||||
fn characters(chars: &[char], start: usize, end: usize) -> String {
|
||||
chars.get(start..end).unwrap_or_default().iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect()
|
||||
}
|
||||
|
||||
fn push_run(r: &mut Reader, runs: &mut Vec<TextRun>, attrs: Option<ObjId>, text: String, world: Affine, attribute_budget: &mut Option<usize>) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let run = match attrs {
|
||||
Some(attrs) if attribute_budget.is_some() => run_attrs(r, attrs, text, world, attribute_budget),
|
||||
_ => {
|
||||
// This is a recovery policy, not an assertion about unspecified Affinity attributes.
|
||||
if attrs.is_none() {
|
||||
r.warn("text without character attributes (characters preserved with default attributes)");
|
||||
}
|
||||
TextRun {
|
||||
text,
|
||||
postscript: String::new(),
|
||||
family: String::new(),
|
||||
weight: 400,
|
||||
italic: false,
|
||||
features: Vec::new(),
|
||||
all_caps: false,
|
||||
size: 12.0 * r.dpi / 72.0,
|
||||
tracking: 0.0,
|
||||
leading: None,
|
||||
fill: Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }),
|
||||
}
|
||||
}
|
||||
};
|
||||
runs.push(run);
|
||||
}
|
||||
|
||||
fn slot<T: Copy>(v: Option<&Value>, i: usize, f: impl Fn(&Value) -> Option<T>) -> Option<T> {
|
||||
match v? {
|
||||
Value::Array(a) => a.get(i).and_then(f),
|
||||
@@ -104,7 +169,7 @@ fn int(v: &Value) -> Option<i64> {
|
||||
}
|
||||
}
|
||||
|
||||
fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine) -> TextRun {
|
||||
fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribute_budget: &mut Option<usize>) -> TextRun {
|
||||
let s = r.s;
|
||||
let doubles = s.field(attrs, b"Doub");
|
||||
let ints = s.field(attrs, b"Ints");
|
||||
@@ -112,24 +177,67 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine) -> TextR
|
||||
let tracking = slot(doubles, 4, float).unwrap_or(0.0);
|
||||
let leading = (slot(ints, 4, int) == Some(1)).then(|| slot(doubles, 13, float)).flatten().filter(|v| *v > 0.0);
|
||||
let objects = match s.field(attrs, b"Objs") {
|
||||
Some(Value::Array(a)) => a.iter().map(|v| if let Value::Obj(o) = v { *o } else { None }).collect(),
|
||||
Some(Value::Array(a)) => a.iter().take(8).map(|v| if let Value::Obj(o) = v { *o } else { None }).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let fill = objects.first().copied().flatten().and_then(|f| paint::descriptor(r, f, world)).unwrap_or(Paint::None);
|
||||
if objects.get(1).copied().flatten().and_then(|f| paint::descriptor(r, f, world)).is_some_and(|p| p != Paint::None) {
|
||||
let mut fill = match objects.first().copied().flatten().and_then(|f| paint::descriptor(r, f, world)) {
|
||||
Some(fill) => fill,
|
||||
None => {
|
||||
r.warn("text without readable fill attributes (default black used)");
|
||||
Paint::Solid(Color::Gray { v: 0.0, a: 1.0 })
|
||||
}
|
||||
};
|
||||
// A single descriptor decode is bounded by paint::MAX_STOPS. Charge its owned stop data
|
||||
// before keeping it; subsequent runs skip attribute decoding once the budget is exhausted.
|
||||
if let Paint::Gradient(gradient) = &fill {
|
||||
let bytes = gradient.stops.len().saturating_mul(std::mem::size_of::<paint::Stop>());
|
||||
if !charge_attributes(r, attribute_budget, bytes) {
|
||||
fill = Paint::Solid(Color::Gray { v: 0.0, a: 1.0 });
|
||||
}
|
||||
}
|
||||
if attribute_budget.is_some() && objects.get(1).copied().flatten().and_then(|f| paint::descriptor(r, f, world)).is_some_and(|p| p != Paint::None)
|
||||
{
|
||||
r.warn("outlined (stroked) text");
|
||||
}
|
||||
if slot(doubles, 10, float).is_some_and(|h| (h - 1.0).abs() > 1e-3) {
|
||||
r.warn("horizontally scaled text");
|
||||
}
|
||||
let font = s.obj(attrs, b"DFnt");
|
||||
let get = |tag: &[u8; 4]| font.and_then(|f| s.str(f, tag)).unwrap_or_default().to_string();
|
||||
// A resolved font is populated on the emoji run of the public Affinity 3 text-runs file
|
||||
// (Courier New requested, Segoe UI Emoji stored in RFnt). Empty RFnt records are common.
|
||||
// Source: https://github.com/SethRobinson/Patchy/tree/de84eab550758b30fa062e479f5778cce7693b73/test-fixtures/af
|
||||
let requested = s.obj(attrs, b"DFnt");
|
||||
let resolved =
|
||||
s.obj(attrs, b"RFnt").filter(|f| !s.str(*f, b"Famy").unwrap_or_default().is_empty() || !s.str(*f, b"Post").unwrap_or_default().is_empty());
|
||||
let font = resolved.or(requested);
|
||||
if let Some(resolved) = resolved {
|
||||
let differs = [b"Famy", b"Post"]
|
||||
.iter()
|
||||
.any(|tag| s.str(resolved, tag).unwrap_or_default() != requested.and_then(|f| s.str(f, tag)).unwrap_or_default());
|
||||
if differs {
|
||||
r.warn("text uses a stored fallback font (imported with that font)");
|
||||
}
|
||||
}
|
||||
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 (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) {
|
||||
weight
|
||||
} else {
|
||||
r.warn("invalid font weight (imported as regular)");
|
||||
400
|
||||
};
|
||||
TextRun {
|
||||
text,
|
||||
postscript: get(b"Post"),
|
||||
family: get(b"Famy"),
|
||||
weight: font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400),
|
||||
postscript,
|
||||
family,
|
||||
weight,
|
||||
italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false),
|
||||
features,
|
||||
all_caps,
|
||||
size,
|
||||
tracking,
|
||||
leading,
|
||||
@@ -137,7 +245,70 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine) -> TextR
|
||||
}
|
||||
}
|
||||
|
||||
fn charge_attributes(r: &mut Reader, budget: &mut Option<usize>, bytes: usize) -> bool {
|
||||
let Some(remaining) = *budget else { return false };
|
||||
match remaining.checked_sub(bytes) {
|
||||
Some(remaining) => {
|
||||
*budget = Some(remaining);
|
||||
true
|
||||
}
|
||||
None => {
|
||||
*budget = None;
|
||||
r.warn("text attribute budget exceeded (excess font names, features and gradients left out)");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() }
|
||||
}
|
||||
|
||||
/// The public Affinity 3 caps fixture stores `smcp` and the private `CAP\x01` selector in
|
||||
/// OtAt/Setn. Only the observed all-caps selector and boolean OpenType tags are mapped; other
|
||||
/// values are reported, never guessed. Standard tag syntax: https://learn.microsoft.com/en-us/typography/opentype/spec/featuretags
|
||||
fn font_features(r: &mut Reader, attrs: Option<ObjId>, attribute_budget: &mut Option<usize>) -> (Vec<String>, bool) {
|
||||
let mut features = Vec::new();
|
||||
let mut all_caps = false;
|
||||
if attribute_budget.is_none() {
|
||||
return (features, all_caps);
|
||||
}
|
||||
let s = r.s;
|
||||
let Some(Value::Array(settings)) = attrs.and_then(|a| s.field(a, b"Setn")) else { return (features, all_caps) };
|
||||
for (index, setting) in settings.iter().take(257).enumerate() {
|
||||
if index == 256 {
|
||||
r.warn("more than 256 text feature settings (truncated)");
|
||||
break;
|
||||
}
|
||||
let Value::Obj(Some(setting)) = setting else {
|
||||
r.warn("unreadable text feature settings (imported with defaults)");
|
||||
continue;
|
||||
};
|
||||
let setting = *setting;
|
||||
let tag = s.int(setting, b"Feat").and_then(|v| u32::try_from(v).ok()).map(u32::to_be_bytes);
|
||||
let value = r.s.int(setting, b"Valu");
|
||||
match (tag, value) {
|
||||
(Some([b'C', b'A', b'P', 1]), Some(0 | 1)) => all_caps = value == Some(1),
|
||||
(Some(tag), Some(value @ (0 | 1))) if tag.iter().all(u8::is_ascii_alphanumeric) => {
|
||||
if !charge_attributes(r, attribute_budget, std::mem::size_of::<String>() + if value == 0 { 5 } else { 4 }) {
|
||||
break;
|
||||
}
|
||||
let name = String::from_utf8_lossy(&tag);
|
||||
features.push(if value == 0 { format!("-{name}") } else { name.into_owned() });
|
||||
}
|
||||
_ => r.warn("text feature selectors or non-boolean OpenType settings (imported with defaults)"),
|
||||
}
|
||||
}
|
||||
(features, all_caps)
|
||||
}
|
||||
|
||||
fn paragraph_align(r: &mut Reader, p: ObjId) -> Align {
|
||||
// Nonzero offsets in these slots are present in the public text-indent and text-para-spacing
|
||||
// fixtures. Their layout is not represented by this importer; retain text and report the gap.
|
||||
if (1..=6).any(|i| slot(r.s.field(p, b"Doub"), i, float).is_some_and(|v| v.abs() > 1e-9)) {
|
||||
r.warn("paragraph indents or spacing (imported with defaults)");
|
||||
}
|
||||
match slot(r.s.field(p, b"Ints"), 0, int) {
|
||||
Some(1) => Align::Center,
|
||||
Some(2) => Align::Right,
|
||||
|
||||
Reference in New Issue
Block a user