AF.4: validate and harden Affinity 3 artboards, text and images

This commit is contained in:
Riccardo Balloch
2026-10-09 13:45:55 +00:00
parent c2711df30b
commit 79c5d673ff
28 changed files with 2343 additions and 116 deletions
+88 -7
View File
@@ -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) {
+30 -6
View File
@@ -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 }
}
}
+2 -5
View File
@@ -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"));
}
+174 -3
View File
@@ -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);
}
}
+14 -6
View File
@@ -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
View File
@@ -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,