mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
fd50bd6f84
commit
f73a3df974
+1
-1
File diff suppressed because one or more lines are too long
+23
-10
@@ -119,9 +119,14 @@ text-frame fixture.
|
||||
pass CI. Run locally with `AFFINITY_CORPUS_REQUIRED=1` for the same strict gate.
|
||||
* Synthetic engine regressions cover nested, rotated and curved artboards, negative origins,
|
||||
multiple spreads and resolution conversion, alongside text recovery and native save/export.
|
||||
* `engine/tests/import_fuzz.rs` mutates a synthetic native document's stream and archive and checks
|
||||
that whatever opens also renders and exports; `fuzz/` has `cargo-fuzz` targets for the whole
|
||||
reader (`container`) and the object stream (`stream`):
|
||||
* `engine/tests/import_fuzz.rs` mutates a synthetic native document's stream and archive, and
|
||||
documents with layer effects, colour overlays (also over tables), line styles, paragraph and
|
||||
character formats, tables, master pages, CMYK pixel layers and embedded documents (the embedding
|
||||
and the embedded stream), and checks that whatever opens also renders and exports.
|
||||
`tests/import_limits.rs` builds hostile documents against the limits below: grids of 16 million
|
||||
cells, grids and stories shared by many layers, 200 000 paragraphs, a document embedded hundreds
|
||||
of times. `fuzz/` has `cargo-fuzz` targets for the whole reader (`container`) and the object
|
||||
stream (`stream`):
|
||||
|
||||
```sh
|
||||
cd crates/affinity
|
||||
@@ -149,13 +154,21 @@ nodes per curve; compound outlines also cap recursion at 128 levels, operand vis
|
||||
and cumulative geometry work at four million, rejecting cycles and partial results on limits.
|
||||
Pie sweeps normalize finite angles in constant time. Pixel layers are decoded at no more than 64
|
||||
megapixels each (cropped to their content first; larger ones are read at a half, a quarter… of
|
||||
their resolution) and 512 megapixels for the whole document, embedded documents included; pixel
|
||||
masks are read only over what they mask. Embedded documents nest four deep at most and count
|
||||
against the importing document's limits. Tables have at most 4096 rows and columns, and 64 layer
|
||||
effects per node are read. Gradients are limited to 1024 stops. Text collection is bounded to a
|
||||
million characters per node before allocation, with a cumulative 16 MiB budget for owned font
|
||||
names, features and gradient stops. Exhaustion warns and uses default attributes for remaining
|
||||
text, preserving its characters.
|
||||
their resolution) and 512 megapixels for the whole document; pixel masks are read only over what
|
||||
they mask (all of it under artistic text, whose glyphs only the layout places). One budget covers
|
||||
the whole read: layers, pixels, 16 million characters of stories and 262 144 table cells, so
|
||||
content that many layers link, or a document embedded many times, can't multiply them. Embedded
|
||||
documents nest four deep at most; both of their readings are charged to that budget and to the
|
||||
importing document's archive limits, also when they fail part of the way. Tables have at most
|
||||
4096 rows and columns and 65 536 cells, and 64 layer effects per node are read. Gradients are
|
||||
limited to 1024 stops. Text collection is bounded to a million characters per node before
|
||||
allocation, with a cumulative 16 MiB budget for owned font names (256 bytes at most each),
|
||||
features and gradient stops; paragraph formats are matched to runs by binary search. Exhaustion
|
||||
warns and uses default attributes for remaining text, preserving its characters. Colour and
|
||||
gradient overlays share one work budget per import (`overlay::MAX_WORK`: the nodes and pixels
|
||||
each is baked into); past it they are left out, and said. In VectorCraft, master pages are
|
||||
copied onto at most as many objects, characters and pixels as a document may hold, a few times
|
||||
over, and objects beyond `MAX_COORD` of the origin are left out.
|
||||
Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the
|
||||
crate has no panics outside tests.
|
||||
|
||||
|
||||
+125
-58
@@ -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
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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() }
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Crafted documents can't make the reader allocate or work without bound: tables, shared
|
||||
//! stories, paragraphs and embedded documents stay within the whole read's budget, however often
|
||||
//! a file links or embeds the same content. Original synthetic documents in the reader's
|
||||
//! accepted schema; no Affinity software or files were used.
|
||||
#![cfg(feature = "synth")]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod support {
|
||||
pub mod text_fixture;
|
||||
}
|
||||
|
||||
use support::text_fixture::*;
|
||||
use vectorcraft_affinity::{
|
||||
Document, Error, Limits,
|
||||
model::{Kind, Node},
|
||||
read,
|
||||
synth::{self, F, Method, tag},
|
||||
};
|
||||
|
||||
type Field = (vectorcraft_affinity::stream::Tag, F);
|
||||
|
||||
/// A document of one spread holding `nodes`, plus these other archive entries.
|
||||
fn file(nodes: Vec<F>, entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let spread = F::Obj(tag(b"Sprd"), vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(nodes))]);
|
||||
let mut root = vec![(tag(b"DocR"), F::Obj(tag(b"DocN"), vec![(tag(b"Chld"), F::Shared(vec![spread]))]))];
|
||||
let mut next_id = 1000;
|
||||
for (_, value) in &mut root {
|
||||
shared_objects(value, &mut next_id);
|
||||
}
|
||||
let data = synth::stream(&root);
|
||||
let mut all = vec![("doc.dat", data.as_slice(), Method::Zlib)];
|
||||
all.extend(entries.iter().map(|(name, bytes)| (*name, *bytes, Method::Stored)));
|
||||
synth::container(&all, None)
|
||||
}
|
||||
|
||||
fn open(nodes: Vec<F>) -> Document {
|
||||
read(&file(nodes, &[]), Limits::default()).unwrap()
|
||||
}
|
||||
|
||||
fn all(nodes: &[Node]) -> Vec<&Node> {
|
||||
let mut out = Vec::new();
|
||||
for n in nodes {
|
||||
out.push(n);
|
||||
out.extend(all(&n.children));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn warned(d: &Document, needle: &str) -> bool {
|
||||
d.warnings.iter().any(|w| w.contains(needle))
|
||||
}
|
||||
|
||||
fn story(text: &str) -> F {
|
||||
F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![block(utf8(text), vec![run(text.chars().count() as i32, None)], &[])]))])
|
||||
}
|
||||
|
||||
fn artistic(story: F) -> F {
|
||||
F::Obj(
|
||||
tag(b"TxtA"),
|
||||
vec![
|
||||
(tag(b"StSt"), story),
|
||||
(tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 100.0, 20.0])), (tag(b"ArtV"), F::F64(16.0))])),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// A table frame (`TbFr`) with a grid of `cols` × `rows` cells and no cell objects or lines.
|
||||
fn grid(cols: usize, rows: usize) -> Vec<Field> {
|
||||
let pos = |n: usize| {
|
||||
let v: Vec<f64> = (0..=n).map(|i| i as f64).collect();
|
||||
F::Obj(tag(b"TPos"), vec![(tag(b"Posn"), array_f64(b"Posn", &v))])
|
||||
};
|
||||
let table = F::Obj(tag(b"Tabl"), vec![(tag(b"CPos"), pos(cols)), (tag(b"RPos"), pos(rows))]);
|
||||
vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, cols as f64, rows as f64])), (tag(b"Tabl"), table)]
|
||||
}
|
||||
|
||||
fn table(frame: F) -> F {
|
||||
F::Obj(tag(b"TxtT"), vec![(tag(b"StSt"), story("A")), (tag(b"TxtH"), frame)])
|
||||
}
|
||||
|
||||
fn tables(d: &Document) -> Vec<&vectorcraft_affinity::model::Table> {
|
||||
all(&d.spreads[0].nodes).into_iter().filter_map(|n| if let Kind::Table(t) = &n.kind { Some(t) } else { None }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_of_more_than_65536_cells_is_left_out_before_its_cells_are_made() {
|
||||
// 4096 × 4096 is within each dimension's limit but 16.7 million cells.
|
||||
let d = open(vec![table(F::Obj(tag(b"TbFr"), grid(4096, 4096))), table(F::Obj(tag(b"TbFr"), grid(256, 256)))]);
|
||||
let t = tables(&d);
|
||||
assert_eq!(t.len(), 1);
|
||||
assert_eq!((t[0].cells.len(), t[0].vertical.len(), t[0].horizontal.len()), (65536, 0, 0), "no lines when they don't match");
|
||||
assert!(warned(&d, "tables with more than 65536 cells"), "{:?}", d.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_sharing_one_grid_stay_within_the_cells_of_one_document() {
|
||||
// One 65536-cell grid linked from five tables: four fill the document's 262144 cells.
|
||||
let shared = F::Def(2_000_000, vec![tag(b"TbFr")], grid(256, 256));
|
||||
let d = open((0..5).map(|i| table(if i == 0 { shared.clone() } else { F::Ref(2_000_000) })).collect());
|
||||
assert_eq!(tables(&d).len(), 4);
|
||||
assert!(warned(&d, "tables beyond the cells one document may hold"), "{:?}", d.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_story_is_read_within_the_characters_of_one_document() {
|
||||
// A story of a million characters linked from twenty text layers: 16 million in all.
|
||||
let text = "x".repeat(1 << 20);
|
||||
let shared = F::Def(2_000_000, vec![tag(b"Stry")], vec![(tag(b"Blok"), F::Shared(vec![block(utf8(&text), vec![run(1 << 20, None)], &[])]))]);
|
||||
let d = open((0..20).map(|i| artistic(if i == 0 { shared.clone() } else { F::Ref(2_000_000) })).collect());
|
||||
let chars: usize = all(&d.spreads[0].nodes)
|
||||
.into_iter()
|
||||
.filter_map(|n| if let Kind::Text(t) = &n.kind { Some(t.runs.iter().map(|r| r.text.len()).sum::<usize>()) } else { None })
|
||||
.sum();
|
||||
assert_eq!(chars, 16 << 20);
|
||||
assert!(warned(&d, "text beyond the characters one document may hold"), "{:?}", d.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_paragraphs_and_runs_are_matched_in_about_linear_time() {
|
||||
// 200 000 paragraphs, each with its own run and format.
|
||||
let n = 200_000;
|
||||
let text = "a\u{2029}".repeat(n);
|
||||
let fmt =
|
||||
|end: usize| vec![(tag(b"Indx"), F::I32(end as i32)), (tag(b"Item"), F::Obj(tag(b"PAtt"), vec![(tag(b"Ints"), array_i32(b"Ints", &[1]))]))];
|
||||
let blk = F::Obj(
|
||||
tag(b"StBl"),
|
||||
vec![
|
||||
(tag(b"Glyp"), utf8(&text)),
|
||||
(tag(b"GAtt"), F::Obj(tag(b"GlAS"), vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), (1..=n).map(|i| run(2 * i as i32, None)).collect()))])),
|
||||
(tag(b"PAtt"), F::Obj(tag(b"PaAS"), vec![(tag(b"Runs"), F::Objs(tag(b"PaAR"), (1..=n).map(|i| fmt(2 * i)).collect()))])),
|
||||
],
|
||||
);
|
||||
let started = std::time::Instant::now();
|
||||
let d = open(vec![artistic(F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))]))]);
|
||||
let Kind::Text(t) = &d.spreads[0].nodes[0].kind else { panic!("text") };
|
||||
assert_eq!(t.runs.len(), n);
|
||||
// (About half a second; quadratic matching took over half a minute on the same machine.)
|
||||
assert!(started.elapsed().as_secs() < 10, "{:?}", started.elapsed());
|
||||
}
|
||||
|
||||
/// A shape node of `size` px.
|
||||
fn rect(size: f64) -> F {
|
||||
F::Obj(tag(b"ShpN"), vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, size, size])), (tag(b"Shpe"), F::Obj(tag(b"ShNR"), vec![]))])
|
||||
}
|
||||
|
||||
/// An instance of the embedded document in archive entry `edc/1`, with `children` of its own.
|
||||
fn embedded(children: Vec<F>) -> F {
|
||||
let content = F::Obj(tag(b"EmbC"), vec![(tag(b"EmbC"), F::Entry("edc/1".into()))]);
|
||||
let mut fields = vec![(tag(b"Bitm"), F::Obj(tag(b"EmbR"), vec![(tag(b"EmCn"), content)]))];
|
||||
if !children.is_empty() {
|
||||
fields.push((tag(b"Chld"), F::Shared(children)));
|
||||
}
|
||||
F::Obj(tag(b"EmbN"), fields)
|
||||
}
|
||||
|
||||
fn embedding(inner: &[u8], nodes: Vec<F>) -> Vec<u8> {
|
||||
let mut entry = b"EmDc\0\0\0\0".to_vec();
|
||||
entry.extend_from_slice(inner);
|
||||
file(nodes, &[("edc/1", &entry)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_embedded_documents_content_stays_with_its_node() {
|
||||
// Read before the node's own children, which must not take it.
|
||||
let inner = file(vec![rect(10.0), rect(20.0)], &[]);
|
||||
let d = read(&embedding(&inner, vec![embedded(vec![rect(5.0)])]), Limits::default()).unwrap();
|
||||
let node = &d.spreads[0].nodes[0];
|
||||
assert_eq!(node.children.len(), 3, "its own child and the embedded document's two shapes");
|
||||
assert!(node.children.iter().all(|c| c.children.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_one_document_many_times_shares_one_budget() {
|
||||
// 2000 shapes, embedded 300 times and read twice each: 1.2 million nodes, past the 500 000
|
||||
// a whole read may take (each embedding used to have an allowance of its own).
|
||||
let inner = file((0..2000).map(|_| rect(10.0)).collect(), &[]);
|
||||
let outer = embedding(&inner, (0..300).map(|_| embedded(Vec::new())).collect());
|
||||
assert!(matches!(read(&outer, Limits::default()), Err(Error::Limit(_))));
|
||||
// Embedded fewer times, it opens.
|
||||
let d = read(&embedding(&inner, (0..50).map(|_| embedded(Vec::new())).collect()), Limits::default()).unwrap();
|
||||
assert_eq!(all(&d.spreads[0].nodes).len(), 50 + 50 * 2000);
|
||||
}
|
||||
@@ -259,9 +259,19 @@ fn excessive_feature_settings_are_bounded_and_reported() {
|
||||
assert!(warning(&d, "more than 256 text feature settings"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_names_longer_than_256_bytes_are_dropped() {
|
||||
// Each paragraph and run carries its font's name: a huge one would be copied with each.
|
||||
let d = read(vec![block(utf8("x"), vec![run(1, Some(attrs(&"F".repeat(257), "Inter-Regular", 400, false, 12.0)))], &[])]);
|
||||
let r = &text(&d).runs[0];
|
||||
assert!(r.family.is_empty() && r.postscript == "Inter-Regular");
|
||||
assert!(warning(&d, "font names longer than 256 bytes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_font_metadata_amplification_is_bounded_without_losing_characters() {
|
||||
let mut a = attrs(&"F".repeat(16 * 1024), &"P".repeat(16 * 1024), 400, false, 12.0);
|
||||
// The longest font names kept (see `font_names_longer_than_256_bytes_are_dropped`).
|
||||
let mut a = attrs(&"F".repeat(256), &"P".repeat(256), 400, false, 12.0);
|
||||
if let F::Obj(_, fields) = &mut a {
|
||||
let settings = F::Obj(
|
||||
tag(b"OtAt"),
|
||||
@@ -278,11 +288,12 @@ fn shared_font_metadata_amplification_is_bounded_without_losing_characters() {
|
||||
}
|
||||
let F::Obj(class, fields) = a else { panic!("attributes must be an object") };
|
||||
let mut runs = vec![run(1, Some(F::Def(42, vec![class], fields)))];
|
||||
runs.extend((2..=1000).map(|end| run(end, Some(F::Ref(42)))));
|
||||
let d = read(vec![block(utf8(&"x".repeat(1000)), runs, &[])]);
|
||||
// About 7.7 kB of names and features per run: the budget runs out after about 2200 runs.
|
||||
runs.extend((2..=4000).map(|end| run(end, Some(F::Ref(42)))));
|
||||
let d = read(vec![block(utf8(&"x".repeat(4000)), runs, &[])]);
|
||||
let t = text(&d);
|
||||
assert_eq!(content(t), "x".repeat(1000));
|
||||
assert_eq!(t.runs.len(), 1000);
|
||||
assert_eq!(content(t), "x".repeat(4000));
|
||||
assert_eq!(t.runs.len(), 4000);
|
||||
let metadata: usize = t
|
||||
.runs
|
||||
.iter()
|
||||
@@ -291,7 +302,7 @@ fn shared_font_metadata_amplification_is_bounded_without_losing_characters() {
|
||||
.map(|s| s.len() + std::mem::size_of::<String>())
|
||||
.sum();
|
||||
assert!(metadata <= 16 << 20, "shared metadata was amplified to {metadata} bytes");
|
||||
assert_eq!(t.runs[0].family.len(), 16 * 1024);
|
||||
assert_eq!(t.runs[0].family.len(), 256);
|
||||
assert_eq!(t.runs[0].features.len(), 256);
|
||||
assert!(t.runs.last().unwrap().family.is_empty());
|
||||
assert!(t.runs.last().unwrap().features.is_empty());
|
||||
|
||||
@@ -83,6 +83,59 @@ struct Builder {
|
||||
cmyk_profile: Option<Vec<u8>>,
|
||||
/// That table, once made (`Some(None)`: there is none).
|
||||
cmyk_lut: Option<Option<Arc<vectorcraft_color::cms::CmykLut>>>,
|
||||
/// The working CMYK profile's table, once a CMYK pixel layer needs it.
|
||||
working_cmyk: Option<Arc<vectorcraft_color::cms::CmykLut>>,
|
||||
/// What baking colour and gradient overlays may still take ([`overlay::MAX_WORK`]).
|
||||
overlay_work: u64,
|
||||
/// What copying master pages onto the pages that show them may still take.
|
||||
copy_left: CopyCost,
|
||||
/// Each master spread's objects as its pages show them, made the first time one does.
|
||||
master_parts: std::collections::HashMap<usize, Arc<[MasterPart]>>,
|
||||
}
|
||||
|
||||
/// What copying a subtree takes: its nodes, characters and table cells (`work`), and its pixels.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct CopyCost {
|
||||
work: u64,
|
||||
pixels: u64,
|
||||
}
|
||||
|
||||
impl CopyCost {
|
||||
fn plus(self, o: CopyCost) -> CopyCost {
|
||||
CopyCost { work: self.work.saturating_add(o.work), pixels: self.pixels.saturating_add(o.pixels) }
|
||||
}
|
||||
fn minus(self, o: CopyCost) -> Option<CopyCost> {
|
||||
Some(CopyCost { work: self.work.checked_sub(o.work)?, pixels: self.pixels.checked_sub(o.pixels)? })
|
||||
}
|
||||
}
|
||||
|
||||
/// Master page copies of one import together: as many objects, characters and pixels as a
|
||||
/// document may hold, a few times over. A master shown on thousands of pages, or a huge one
|
||||
/// on many, would otherwise multiply them.
|
||||
const MAX_MASTER_COPIES: CopyCost = CopyCost { work: 1 << 23, pixels: 512 << 20 };
|
||||
|
||||
/// One object a master spread shows on the pages that show it: a top-level node of the spread
|
||||
/// (`child` `None`) or one object in one of its layers, with its bounds and what copying it takes.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct MasterPart {
|
||||
node: usize,
|
||||
child: Option<usize>,
|
||||
bounds: Option<af::Rect>,
|
||||
cost: CopyCost,
|
||||
}
|
||||
|
||||
fn copy_cost(n: &af::Node, depth: usize) -> CopyCost {
|
||||
if depth > MAX_DEPTH {
|
||||
return CopyCost::default();
|
||||
}
|
||||
let len = |runs: &[af::TextRun]| runs.iter().map(|r| r.text.len() as u64).sum::<u64>();
|
||||
let own = match &n.kind {
|
||||
Kind::Text(t) => CopyCost { work: 1 + len(&t.runs), pixels: 0 },
|
||||
Kind::Table(t) => CopyCost { work: 1 + t.cells.iter().map(|c| 1 + len(&c.runs)).sum::<u64>(), pixels: 0 },
|
||||
Kind::Image(img) => CopyCost { work: 1, pixels: u64::from(img.width) * u64::from(img.height) },
|
||||
_ => CopyCost { work: 1, pixels: 0 },
|
||||
};
|
||||
n.children.iter().fold(own, |c, ch| c.plus(copy_cost(ch, depth + 1)))
|
||||
}
|
||||
|
||||
/// Deepest node tree mapped (the reader bounds trees well below this).
|
||||
@@ -110,7 +163,20 @@ struct Placement {
|
||||
|
||||
impl Builder {
|
||||
fn new(k: f64) -> Self {
|
||||
Self { doc: Document::new(0.0, 0.0), warnings: Vec::new(), k, layer: 0, embedded: 0, masters: Vec::new(), cmyk_profile: None, cmyk_lut: None }
|
||||
Self {
|
||||
doc: Document::new(0.0, 0.0),
|
||||
warnings: Vec::new(),
|
||||
k,
|
||||
layer: 0,
|
||||
embedded: 0,
|
||||
masters: Vec::new(),
|
||||
cmyk_profile: None,
|
||||
cmyk_lut: None,
|
||||
working_cmyk: None,
|
||||
overlay_work: vectorcraft_affinity::overlay::MAX_WORK,
|
||||
copy_left: MAX_MASTER_COPIES,
|
||||
master_parts: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn warn(&mut self, w: &str) {
|
||||
@@ -160,7 +226,8 @@ impl Builder {
|
||||
artboards.push((name, to_doc.transform_rect_bbox(rect_of(r))));
|
||||
}
|
||||
}
|
||||
x = artboards.iter().map(|(_, r)| r.x1).fold(x, f64::max) + 36.0;
|
||||
// (Earlier spreads' artboards are all left of `x` already.)
|
||||
x = artboards.get(before..).unwrap_or_default().iter().map(|(_, r)| r.x1).fold(x, f64::max) + 36.0;
|
||||
if spread.transparent && i == 0 {
|
||||
self.warn("transparent page backgrounds show as white artboards");
|
||||
}
|
||||
@@ -205,34 +272,56 @@ impl Builder {
|
||||
/// Each master page shows only the objects on it, moved from its page to the one showing it.
|
||||
fn master_pages(&mut self, d: &af::Document, spread: &af::Spread, to_doc: Affine) {
|
||||
let instances = master_instances(&spread.nodes);
|
||||
if instances.is_empty() {
|
||||
return;
|
||||
}
|
||||
let owners = page_masters(&instances, spread.pages.len());
|
||||
for (pi, page) in spread.pages.iter().enumerate() {
|
||||
let Some(&(master, first, master_page, _)) = instances.iter().find(|(_, p, _, n)| pi >= *p && pi < p.saturating_add(*n)) else {
|
||||
continue;
|
||||
};
|
||||
let Some(&Some((master, first, master_page))) = owners.get(pi) else { continue };
|
||||
let Some(m) = d.masters.get(master) else { continue };
|
||||
let index = master_page.saturating_add(pi - first);
|
||||
let index = master_page.saturating_add(pi.saturating_sub(first));
|
||||
let r = m.pages.get(index).or_else(|| m.pages.last()).copied().unwrap_or(m.bounds);
|
||||
if !master_instances(&m.nodes).is_empty() {
|
||||
self.warn("master pages based on other master pages (only their own objects are used)");
|
||||
}
|
||||
let parts = self.master_parts(master, m);
|
||||
let on_page = |b: &Option<af::Rect>| b.is_some_and(|b| b.x1 > r.x0 && b.x0 < r.x1 && b.y1 > r.y0 && b.y0 < r.y1);
|
||||
// Looking through the master's objects takes one each, copying them what they hold.
|
||||
let looked = CopyCost { work: parts.len() as u64, pixels: 0 };
|
||||
let cost = parts.iter().filter(|p| on_page(&p.bounds)).fold(looked, |c, p| c.plus(p.cost));
|
||||
let Some(left) = self.copy_left.minus(cost) else {
|
||||
self.warn("master pages shown on more pages than one import copies them to (left out on the rest)");
|
||||
return;
|
||||
};
|
||||
self.copy_left = left;
|
||||
let place = to_doc * Affine::translate((page.x0 - r.x0, page.y0 - r.y0));
|
||||
let on_page =
|
||||
|n: &af::Node| af::nodes_bounds(std::slice::from_ref(n)).is_some_and(|b| b.x1 > r.x0 && b.x0 < r.x1 && b.y1 > r.y0 && b.y0 < r.y1);
|
||||
let mut art = Vec::new();
|
||||
let mut boards = Vec::new();
|
||||
self.embedded += 1;
|
||||
for n in &m.nodes {
|
||||
if matches!(n.kind, Kind::Layer) {
|
||||
// A layer of the master spread: just its objects on this page.
|
||||
let kids: Vec<Arc<Node>> =
|
||||
n.children.iter().filter(|c| on_page(c)).filter_map(|c| self.node(c, place, &mut boards, false, 1)).collect();
|
||||
if !kids.is_empty() {
|
||||
let mut g = self.base(n, NodeKind::Group { children: kids, clip: false });
|
||||
g.isolate = n.blend != Blend::PassThrough;
|
||||
art.push(Arc::new(g));
|
||||
let mut i = 0;
|
||||
while let Some(p) = parts.get(i).copied() {
|
||||
let Some(n) = m.nodes.get(p.node) else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
if p.child.is_none() {
|
||||
if on_page(&p.bounds) {
|
||||
art.extend(self.node(n, place, &mut boards, false, 0));
|
||||
}
|
||||
} else if on_page(n) {
|
||||
art.extend(self.node(n, place, &mut boards, false, 0));
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// A layer of the master spread: just its objects on this page.
|
||||
let mut kids: Vec<Arc<Node>> = Vec::new();
|
||||
while let Some(q) = parts.get(i).copied().filter(|q| q.node == p.node) {
|
||||
if on_page(&q.bounds)
|
||||
&& let Some(c) = q.child.and_then(|c| n.children.get(c))
|
||||
{
|
||||
kids.extend(self.node(c, place, &mut boards, false, 1));
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if !kids.is_empty() {
|
||||
let mut g = self.base(n, NodeKind::Group { children: kids, clip: false });
|
||||
g.isolate = n.blend != Blend::PassThrough;
|
||||
art.push(Arc::new(g));
|
||||
}
|
||||
}
|
||||
self.embedded -= 1;
|
||||
@@ -248,6 +337,34 @@ impl Builder {
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects of master spread `m` (number `index`) as its pages show them: its layers'
|
||||
/// objects one by one and its other top-level objects, with their bounds and costs.
|
||||
fn master_parts(&mut self, index: usize, m: &af::Spread) -> Arc<[MasterPart]> {
|
||||
if let Some(parts) = self.master_parts.get(&index) {
|
||||
return parts.clone();
|
||||
}
|
||||
if !master_instances(&m.nodes).is_empty() {
|
||||
self.warn("master pages based on other master pages (only their own objects are used)");
|
||||
}
|
||||
let part = |node: usize, child: Option<usize>, n: &af::Node| MasterPart {
|
||||
node,
|
||||
child,
|
||||
bounds: af::nodes_bounds(std::slice::from_ref(n)),
|
||||
cost: copy_cost(n, 0),
|
||||
};
|
||||
let mut parts = Vec::new();
|
||||
for (i, n) in m.nodes.iter().enumerate() {
|
||||
if matches!(n.kind, Kind::Layer) {
|
||||
parts.extend(n.children.iter().enumerate().map(|(c, child)| part(i, Some(c), child)));
|
||||
} else {
|
||||
parts.push(part(i, None, n));
|
||||
}
|
||||
}
|
||||
let parts: Arc<[MasterPart]> = parts.into();
|
||||
self.master_parts.insert(index, parts.clone());
|
||||
parts
|
||||
}
|
||||
|
||||
fn layer_with(&mut self, name: Option<&str>, children: Vec<Arc<Node>>) -> Arc<Node> {
|
||||
let id = self.doc.alloc_id();
|
||||
self.layer = self.layer.wrapping_add(1);
|
||||
@@ -305,8 +422,15 @@ impl Builder {
|
||||
/// `n` with its colour and gradient overlays baked into its paint and pixels (VectorCraft has
|
||||
/// no overlay effect).
|
||||
fn overlaid<'n>(&mut self, n: &'n af::Node) -> std::borrow::Cow<'n, af::Node> {
|
||||
let mut warnings = Vec::new();
|
||||
let out = vectorcraft_affinity::overlay::apply(n, &mut |w| warnings.push(w.to_string()));
|
||||
// Each kind of message once, however often it is heard.
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
let mut work = self.overlay_work;
|
||||
let out = vectorcraft_affinity::overlay::apply(n, &mut work, &mut |w| {
|
||||
if !warnings.iter().any(|x| x == w) {
|
||||
warnings.push(w.to_string());
|
||||
}
|
||||
});
|
||||
self.overlay_work = work;
|
||||
for w in warnings {
|
||||
self.warn(&w);
|
||||
}
|
||||
@@ -320,6 +444,15 @@ impl Builder {
|
||||
self.warn("objects nested too deeply (left out)");
|
||||
return None;
|
||||
}
|
||||
// VectorCraft documents stay within `MAX_COORD` of the origin (renderers and exports rely
|
||||
// on it): objects reaching beyond it are left out.
|
||||
if let Some(b) = af::kind_bounds(&n.kind) {
|
||||
let r = to_doc.transform_rect_bbox(rect_of(b));
|
||||
if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= crate::MAX_COORD) {
|
||||
self.warn("objects beyond the document range VectorCraft supports (left out)");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let n = &*self.overlaid(n);
|
||||
// Register before descending so nested artboards keep their document order. Inside an
|
||||
// embedded document an artboard is just its content's frame.
|
||||
@@ -851,14 +984,14 @@ impl Builder {
|
||||
let count = obj.runs.iter().map(|r| r.text.matches('\n').count()).sum::<usize>() + 1;
|
||||
let mut paras = Vec::with_capacity(count);
|
||||
let mut previous_after = 0.0;
|
||||
let mut first_styles = Vec::with_capacity(count);
|
||||
// The run of each paragraph's first character.
|
||||
let mut first_runs = Vec::with_capacity(count);
|
||||
{
|
||||
// The first character's style of each paragraph.
|
||||
let mut at_start = true;
|
||||
for r in &obj.runs {
|
||||
for (i, r) in obj.runs.iter().enumerate() {
|
||||
for c in r.text.chars() {
|
||||
if at_start {
|
||||
first_styles.push((r.style.font_family.clone(), r.style.font_style.clone()));
|
||||
first_runs.push(i);
|
||||
at_start = false;
|
||||
}
|
||||
if c == '\n' {
|
||||
@@ -867,6 +1000,8 @@ impl Builder {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Line gaps by font, each looked up once.
|
||||
let mut gaps: std::collections::HashMap<(&str, &str), f64> = std::collections::HashMap::new();
|
||||
for i in 0..count {
|
||||
let f = formats.get(i).or(formats.last()).copied().unwrap_or_default();
|
||||
let pt = |x: f64, k: f64| if (x * k).is_finite() { (x * k).clamp(-5000.0, 5000.0) } else { 0.0 };
|
||||
@@ -895,8 +1030,10 @@ impl Builder {
|
||||
// factor, for artistic and frame text alike. Fitted on Affinity's thumbnails: Bebas
|
||||
// Neue Pro and Impact lines about 1 em apart, DIN Condensed (a gap of 0.2 em) 1.2 em,
|
||||
// and the public Arial frame fixture fits this better than the font's line height.
|
||||
let (family, style) = first_styles.get(i).cloned().unwrap_or_default();
|
||||
let ratio = (1.0 + line_gap(&family, &style)) * f.auto_leading;
|
||||
let (family, style) =
|
||||
first_runs.get(i).and_then(|r| obj.runs.get(*r)).map_or(("", ""), |r| (r.style.font_family.as_str(), r.style.font_style.as_str()));
|
||||
let gap = *gaps.entry((family, style)).or_insert_with(|| line_gap(family, style));
|
||||
let ratio = (1.0 + gap) * f.auto_leading;
|
||||
// Affinity documents use tiny factors for spacer paragraphs (0.15 of a line); VectorCraft
|
||||
// takes 0–500 %.
|
||||
if ratio.is_finite() && (0.01..=5.0).contains(&ratio) {
|
||||
@@ -1071,10 +1208,15 @@ impl Builder {
|
||||
}
|
||||
}
|
||||
self.warn("CMYK pixel layers with transparency (converted to RGB through the working CMYK profile)");
|
||||
let lut = vectorcraft_color::cms::CmykLut::build(|[c, m, y, k]| {
|
||||
let [r, g, b] = Color::cmyk(c, m, y, k).to_rgb();
|
||||
[r, g, b, 0.0]
|
||||
});
|
||||
let lut = self
|
||||
.working_cmyk
|
||||
.get_or_insert_with(|| {
|
||||
Arc::new(vectorcraft_color::cms::CmykLut::build(|[c, m, y, k]| {
|
||||
let [r, g, b] = Color::cmyk(c, m, y, k).to_rgb();
|
||||
[r, g, b, 0.0]
|
||||
}))
|
||||
})
|
||||
.clone();
|
||||
let rgba = px
|
||||
.iter()
|
||||
.flat_map(|p| {
|
||||
@@ -1152,6 +1294,43 @@ fn master_instances(nodes: &[af::Node]) -> Vec<(usize, usize, usize, usize)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Which master page instance each of `pages` pages shows: (master, first page, first master
|
||||
/// page) of the first instance in `instances` that covers it. Each page is settled once (the
|
||||
/// next page still open is found by a path-halving jump), so this is about linear in pages and
|
||||
/// instances however much their ranges overlap.
|
||||
fn page_masters(instances: &[(usize, usize, usize, usize)], pages: usize) -> Vec<Option<(usize, usize, usize)>> {
|
||||
fn open_from(next: &mut [usize], mut i: usize) -> usize {
|
||||
while let Some(&n) = next.get(i) {
|
||||
if n == i {
|
||||
break;
|
||||
}
|
||||
let skip = next.get(n).copied().unwrap_or(n);
|
||||
if let Some(slot) = next.get_mut(i) {
|
||||
*slot = skip;
|
||||
}
|
||||
i = n;
|
||||
}
|
||||
i
|
||||
}
|
||||
let mut owner = vec![None; pages];
|
||||
// `next[p]` leads towards the first page from `p` on with no master yet (`pages`: none).
|
||||
let mut next: Vec<usize> = (0..=pages).collect();
|
||||
for &(master, first, master_page, count) in instances {
|
||||
let end = first.saturating_add(count).min(pages);
|
||||
let mut p = open_from(&mut next, first.min(pages));
|
||||
while p < end {
|
||||
if let Some(o) = owner.get_mut(p) {
|
||||
*o = Some((master, first, master_page));
|
||||
}
|
||||
if let Some(n) = next.get_mut(p) {
|
||||
*n = p + 1;
|
||||
}
|
||||
p = open_from(&mut next, p + 1);
|
||||
}
|
||||
}
|
||||
owner
|
||||
}
|
||||
|
||||
/// Master names: A, B … Z, then A1, B1 ….
|
||||
fn master_prefix(index: usize) -> String {
|
||||
let letter = char::from(b'A' + (index % 26) as u8);
|
||||
@@ -1194,7 +1373,11 @@ fn place_cell(obj: &mut TextObject, w: f64, h: f64) -> f64 {
|
||||
/// such a line out. The frame is made taller, from its top, by what those lines need. Whether it was.
|
||||
fn fit_lines(obj: &mut TextObject, w: f64, h: f64) -> bool {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
if !(w > 0.0 && h > 0.0 && h.is_finite()) || !vectorcraft_text::layout(db, obj).overflow {
|
||||
if !(w > 0.0 && h > 0.0 && h.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
let first = vectorcraft_text::layout(db, obj);
|
||||
if !first.overflow {
|
||||
return false;
|
||||
}
|
||||
// Where the lines go in a frame with room for all of them.
|
||||
@@ -1210,7 +1393,7 @@ fn fit_lines(obj: &mut TextObject, w: f64, h: f64) -> bool {
|
||||
if need <= h {
|
||||
return false;
|
||||
}
|
||||
let before = vectorcraft_text::layout(db, obj).lines.len();
|
||||
let before = first.lines.len();
|
||||
// (A little more than they need, for the rounding of the layout.)
|
||||
let short = std::mem::replace(&mut obj.kind, TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, need + 0.05)) });
|
||||
// Something else keeps the lines out: as the source has it.
|
||||
|
||||
@@ -192,8 +192,10 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N
|
||||
if !area.is_finite() {
|
||||
return None;
|
||||
}
|
||||
// Whole pixels, and no larger than 64 Mpx.
|
||||
let scale = scale.min((64.0e6 / area.max(1.0)).sqrt());
|
||||
// Whole pixels, no larger than 64 Mpx, and no side longer than the renderer can address
|
||||
// (a long thin object stays within the area but not within a side).
|
||||
let side = f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 2.0;
|
||||
let scale = scale.min((64.0e6 / area.max(1.0)).sqrt()).min(side / b.width().max(b.height()).max(1.0));
|
||||
if !(scale.is_finite() && scale > 0.0) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -4,257 +4,28 @@
|
||||
//! Affinity import tests, whose reader VectorCraft shares.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
#[path = "support/affinity_docs.rs"]
|
||||
mod affinity_docs;
|
||||
#[path = "../../affinity/tests/support/text_fixture.rs"]
|
||||
mod text_fixture;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use affinity_docs::*;
|
||||
|
||||
use kurbo::Rect;
|
||||
use text_fixture::{array_f64, array_i32, attrs, block, run, shared_objects, utf8};
|
||||
use vectorcraft_affinity::synth::{self, F, Method, tag};
|
||||
use text_fixture::{array_f64, array_i32, attrs, block, run, utf8};
|
||||
use vectorcraft_affinity::synth::{F, tag};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, StrokeAlign};
|
||||
use vectorcraft_doc::text::{TextKind, TextObject, VerticalAlign};
|
||||
use vectorcraft_doc::{Document, Node, NodeKind};
|
||||
use vectorcraft_engine::cmd::fileio::{Loaded, load};
|
||||
|
||||
type Field = (vectorcraft_affinity::stream::Tag, F);
|
||||
|
||||
/// Encode a stream whose shared arrays may list inline objects.
|
||||
fn stream(mut root: Vec<Field>) -> Vec<u8> {
|
||||
let mut next_id = 1000;
|
||||
for (_, value) in &mut root {
|
||||
shared_objects(value, &mut next_id);
|
||||
}
|
||||
synth::stream(&root)
|
||||
}
|
||||
|
||||
/// 300 dpi: a document pixel is 0.24 pt.
|
||||
const DPI: f64 = 300.0;
|
||||
const PT: f64 = 72.0 / DPI;
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-6
|
||||
}
|
||||
|
||||
fn rgba(r: f32, g: f32, b: f32) -> F {
|
||||
F::Obj(tag(b"RGBA"), vec![(tag(b"_col"), F::Struct([r, g, b, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect()))])
|
||||
}
|
||||
|
||||
fn cmyk(c: f32, m: f32, y: f32, k: f32) -> F {
|
||||
F::Obj(
|
||||
tag(b"FilS"),
|
||||
vec![(tag(b"Colr"), F::Obj(tag(b"CMYK"), vec![(tag(b"_col"), F::Struct([c, m, y, k, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect()))]))],
|
||||
)
|
||||
}
|
||||
|
||||
/// A magenta rectangle named `name`.
|
||||
fn named_rect(id: u32, name: &str, at: (f64, f64), size: (f64, f64)) -> F {
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"ShpN")],
|
||||
vec![
|
||||
(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, size.0, size.1])),
|
||||
(tag(b"Shpe"), F::Def(id + 10_000, vec![tag(b"ShNR")], vec![])),
|
||||
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, at.0, 0.0, 1.0, at.1])),
|
||||
(tag(b"BFil"), cmyk(0.0, 1.0, 0.0, 0.0)),
|
||||
(tag(b"Desc"), F::Str(name.into())),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn rectangle(id: u32, at: (f64, f64), size: (f64, f64)) -> F {
|
||||
named_rect(id, "Magenta box", at, size)
|
||||
}
|
||||
|
||||
/// An artboard (`ABEn`) named `name` over `bounds` at `at`, holding `children`.
|
||||
fn board(id: u32, name: &str, at: (f64, f64), size: (f64, f64), children: Vec<F>) -> F {
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"ShpN")],
|
||||
vec![
|
||||
(tag(b"Desc"), F::Str(name.into())),
|
||||
(tag(b"ABEn"), F::Bool(true)),
|
||||
(tag(b"Shpe"), F::Def(id + 10_000, vec![tag(b"ShNR")], vec![])),
|
||||
(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, size.0, size.1])),
|
||||
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, at.0, 0.0, 1.0, at.1])),
|
||||
(tag(b"Chld"), F::Shared(children)),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn artistic_text(id: u32, text: &str, size: f64) -> F {
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"TxtA")],
|
||||
vec![
|
||||
(
|
||||
tag(b"StSt"),
|
||||
F::Obj(
|
||||
tag(b"Stry"),
|
||||
vec![(
|
||||
tag(b"Blok"),
|
||||
F::Shared(vec![block(
|
||||
utf8(text),
|
||||
vec![run(text.chars().count() as i32, Some(attrs("Inter", "Inter-Bold", 700, false, size)))],
|
||||
&[1],
|
||||
)]),
|
||||
)],
|
||||
),
|
||||
),
|
||||
(tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 400.0, 100.0])), (tag(b"ArtV"), F::F64(80.0))])),
|
||||
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 1000.0, 0.0, 1.0, 500.0])),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn page(x0: f64, w: f64, h: f64) -> F {
|
||||
F::Obj(tag(b"PagR"), vec![(tag(b"rctp"), F::F64s(vec![x0, 0.0, x0 + w, h]))])
|
||||
}
|
||||
|
||||
/// A Publisher spread of A4 pages at 300 dpi holding `nodes`.
|
||||
fn spread(id: u32, pages: usize, nodes: Vec<F>) -> F {
|
||||
let w = 2480.0 * pages as f64;
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"Sprd")],
|
||||
vec![
|
||||
(tag(b"SprB"), F::F64s(vec![0.0, 0.0, w, 3508.0])),
|
||||
(
|
||||
tag(b"SpMd"),
|
||||
F::Obj(tag(b"SpMd"), vec![(tag(b"PagR"), F::Shared((0..pages).map(|i| page(2480.0 * i as f64, 2480.0, 3508.0)).collect()))]),
|
||||
),
|
||||
(tag(b"Chld"), F::Shared(nodes)),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn publisher_with(spreads: Vec<F>) -> Vec<u8> {
|
||||
let data = stream(vec![
|
||||
(tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(DPI))])),
|
||||
(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(spreads))])),
|
||||
]);
|
||||
synth::container(&[("doc.dat", &data, Method::Zstd)], None)
|
||||
}
|
||||
|
||||
/// A two-page spread: a layer with a CMYK rectangle, and centred artistic text.
|
||||
fn publisher() -> Vec<u8> {
|
||||
let layer = F::Def(
|
||||
3,
|
||||
vec![tag(b"Scop")],
|
||||
vec![(tag(b"Desc"), F::Str("Background".into())), (tag(b"Chld"), F::Shared(vec![rectangle(10, (100.0, 100.0), (100.0, 200.0))]))],
|
||||
);
|
||||
publisher_with(vec![spread(2, 2, vec![layer, artistic_text(20, "Hello\u{2029}World\u{2029}", 100.0)])])
|
||||
}
|
||||
|
||||
/// One A4 page at 300 dpi holding `node`.
|
||||
fn page_with(node: F) -> Vec<u8> {
|
||||
publisher_with(vec![spread(2, 1, vec![node])])
|
||||
}
|
||||
|
||||
/// A document at 72 dpi holding `nodes` on a canvas without pages; `embedded` is `edc/1`.
|
||||
fn canvas_with(nodes: Vec<F>, embedded: Option<&[u8]>) -> Vec<u8> {
|
||||
let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 1000.0, 1000.0])), (tag(b"Chld"), F::Shared(nodes))]);
|
||||
let data = stream(vec![(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]);
|
||||
match embedded {
|
||||
Some(inner) => {
|
||||
let mut entry = b"EmDc\0\0\0\0".to_vec();
|
||||
entry.extend_from_slice(inner);
|
||||
synth::container(&[("doc.dat", &data, Method::Zstd), ("edc/1", &entry, Method::Zstd)], None)
|
||||
}
|
||||
None => synth::container(&[("doc.dat", &data, Method::Zstd)], None),
|
||||
}
|
||||
}
|
||||
|
||||
/// An instance of the embedded document `edc/1`, scaled 2× with its content's centre at (500, 300).
|
||||
fn embedded_node(id: u32) -> F {
|
||||
let content = F::Obj(tag(b"EmbC"), vec![(tag(b"EmbC"), F::Entry("edc/1".into()))]);
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"EmbN")],
|
||||
vec![
|
||||
(tag(b"Bitm"), F::Obj(tag(b"EmbR"), vec![(tag(b"EmCn"), content), (tag(b"PBBx"), F::Enum(2, 0))])),
|
||||
(tag(b"Xfrm"), F::F64s(vec![2.0, 0.0, 500.0, 0.0, 2.0, 300.0])),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// A blue 2 px line on a table edge.
|
||||
fn table_edge() -> F {
|
||||
F::Obj(
|
||||
tag(b"TEdg"),
|
||||
vec![
|
||||
(tag(b"Line"), F::Obj(tag(b"LDsc"), vec![(tag(b"LDeL"), F::Obj(tag(b"LSty"), vec![(tag(b"Wght"), F::F64(2.0))]))])),
|
||||
(tag(b"Fill"), F::Obj(tag(b"FDsc"), vec![(tag(b"FDeF"), cmyk(1.0, 0.5, 0.0, 0.0))])),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// A 3 × 2 table whose cell (1, 1) is merged into (1, 0); cells "A".."E" in reading order, each
|
||||
/// ended by a cell break. Cells are 100 × 50 px from (0, 0).
|
||||
fn table_node(id: u32, transform: [f64; 6]) -> F {
|
||||
table_with(id, transform, ["A", "B", "C", "D", "E"])
|
||||
}
|
||||
|
||||
/// [`table_node`] with these cell texts.
|
||||
fn table_with(id: u32, transform: [f64; 6], texts: [&str; 5]) -> F {
|
||||
let brk = || F::Obj(tag(b"BrGl"), vec![(tag(b"HdBk"), F::Enum(4, 1))]);
|
||||
let segments: Vec<F> = texts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| {
|
||||
let mut fields = vec![(tag(b"Utf8"), F::Str((*t).into()))];
|
||||
if i > 0 {
|
||||
fields.insert(0, (tag(b"Glys"), F::Shared(vec![brk()])));
|
||||
}
|
||||
F::Obj(tag(b"GSeg"), fields)
|
||||
})
|
||||
.collect();
|
||||
let len = (texts.iter().map(|t| t.chars().count()).sum::<usize>() + texts.len() - 1) as i32;
|
||||
let glyphs = F::Obj(tag(b"GStr"), vec![(tag(b"Mixd"), F::Shared(segments))]);
|
||||
let story = block(glyphs, vec![run(len, Some(attrs("Inter", "Inter-Regular", 400, false, 40.0)))], &[]);
|
||||
let cell = |merged: bool| {
|
||||
let mut f = vec![(tag(b"AliY"), F::Enum(1, 0)), (tag(b"Inse"), F::F64s(vec![5.0, 6.0, 7.0, 8.0]))];
|
||||
if merged {
|
||||
f.push((tag(b"BrLf"), F::Bool(true)));
|
||||
}
|
||||
F::Obj(tag(b"TCel"), f)
|
||||
};
|
||||
let pos = |values: &[f64]| F::Obj(tag(b"TPos"), vec![(tag(b"Posn"), array_f64(b"Posn", values))]);
|
||||
let edges = |n: usize| F::Obj(tag(b"TEds"), vec![(tag(b"Edge"), F::Shared((0..n).map(|_| table_edge()).collect()))]);
|
||||
let table = F::Obj(
|
||||
tag(b"Tabl"),
|
||||
vec![
|
||||
(tag(b"CPos"), pos(&[0.0, 100.0, 200.0, 300.0])),
|
||||
(tag(b"RPos"), pos(&[0.0, 50.0, 100.0])),
|
||||
(tag(b"CEdg"), edges(8)),
|
||||
(tag(b"REdg"), edges(9)),
|
||||
(tag(b"Cell"), F::Obj(tag(b"TCls"), vec![(tag(b"Cell"), F::Shared((0..6).map(|i| cell(i == 4)).collect()))])),
|
||||
],
|
||||
);
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"TxtT")],
|
||||
vec![
|
||||
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![story]))])),
|
||||
(tag(b"TxtH"), F::Obj(tag(b"TbFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 300.0, 100.0])), (tag(b"Tabl"), table)])),
|
||||
(tag(b"Xfrm"), F::F64s(transform.to_vec())),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// Character attributes without their own fixed leading.
|
||||
fn auto_leading(mut attrs: F) -> F {
|
||||
if let F::Obj(_, fields) = &mut attrs {
|
||||
for (t, v) in fields.iter_mut() {
|
||||
if *t == tag(b"Ints") {
|
||||
*v = array_i32(b"Ints", &[0, 0, 0, 0, 0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
attrs
|
||||
}
|
||||
|
||||
fn open(name: &str, bytes: &[u8]) -> Loaded {
|
||||
let loaded = load(name, bytes).unwrap();
|
||||
assert!(!loaded.preview_only, "{:?}", loaded.warnings);
|
||||
@@ -603,48 +374,6 @@ fn embedded_documents_nest_only_so_deep() {
|
||||
assert!(loaded.warnings.iter().any(|w| w.contains("nested more than four deep")), "{:?}", loaded.warnings);
|
||||
}
|
||||
|
||||
/// A two-page master spread (pages 0–100 and 100–200 wide) with a box on each page; the
|
||||
/// document's page shows its second page, as Affinity's instance says (MPOf 1).
|
||||
fn with_master(pages: usize) -> Vec<u8> {
|
||||
let master = F::Def(
|
||||
7,
|
||||
vec![tag(b"Sprd")],
|
||||
vec![
|
||||
(tag(b"SpMd"), F::Obj(tag(b"SpMd"), vec![(tag(b"PagR"), F::Shared(vec![page(0.0, 100.0, 150.0), page(100.0, 100.0, 150.0)]))])),
|
||||
(
|
||||
tag(b"Chld"),
|
||||
F::Shared(vec![
|
||||
named_rect(30, "Left master box", (10.0, 10.0), (20.0, 20.0)),
|
||||
named_rect(31, "Right master box", (110.0, 10.0), (30.0, 40.0)),
|
||||
]),
|
||||
),
|
||||
],
|
||||
);
|
||||
let instance = F::Def(
|
||||
5,
|
||||
vec![tag(b"MPIN")],
|
||||
vec![
|
||||
(tag(b"SLnk"), F::Obj(tag(b"ILSN"), vec![(tag(b"ILOb"), F::Shared(vec![master, F::Ref(5)]))])),
|
||||
(tag(b"PgOf"), F::U32(0)),
|
||||
(tag(b"MPOf"), F::U32(1)),
|
||||
(tag(b"PgCt"), F::U32(pages as u32)),
|
||||
],
|
||||
);
|
||||
let spread = F::Def(
|
||||
2,
|
||||
vec![tag(b"Sprd")],
|
||||
vec![
|
||||
(tag(b"SpMd"), F::Obj(tag(b"SpMd"), vec![(tag(b"PagR"), F::Shared((0..pages).map(|i| page(100.0 * i as f64, 100.0, 150.0)).collect()))])),
|
||||
(tag(b"Chld"), F::Shared(vec![instance, named_rect(40, "Page box", (50.0, 100.0), (10.0, 10.0))])),
|
||||
],
|
||||
);
|
||||
let data = stream(vec![(
|
||||
tag(b"DocR"),
|
||||
F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread])), (tag(b"MpCh"), F::Shared(vec![F::Ref(7)]))]),
|
||||
)]);
|
||||
synth::container(&[("doc.dat", &data, Method::Zstd)], None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_pages_show_on_the_pages_that_use_them() {
|
||||
let loaded = open("master.afpub", &with_master(1));
|
||||
@@ -680,13 +409,6 @@ fn pixel(d: &Document, x: usize, y: usize) -> [u8; 4] {
|
||||
img.to_straight()[(y * img.width as usize + x) * 4..][..4].try_into().unwrap()
|
||||
}
|
||||
|
||||
fn with_effects(mut node: F, effects: Vec<F>) -> F {
|
||||
if let F::Def(_, _, fields) = &mut node {
|
||||
fields.push((tag(b"FiEf"), F::Shared(effects)));
|
||||
}
|
||||
node
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadows_go_where_affinity_puts_them() {
|
||||
// A blue shadow 10 px straight below (angle π/2), blurred 2 px, at half opacity.
|
||||
@@ -809,37 +531,6 @@ fn outline_effects_on_stroked_shapes_go_around_their_stroke() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Artistic text "Wide line" over "Mid", the paragraphs aligned `first` and `second`
|
||||
/// (0 left, 1 centre, 2 right).
|
||||
fn mixed_alignment(first: i32, second: i32) -> F {
|
||||
let para = |end: i32, align: i32| {
|
||||
vec![(tag(b"Indx"), F::I32(end)), (tag(b"Item"), F::Obj(tag(b"PAtt"), vec![(tag(b"Ints"), array_i32(b"Ints", &[align]))]))]
|
||||
};
|
||||
let blk = F::Obj(
|
||||
tag(b"StBl"),
|
||||
vec![
|
||||
(tag(b"Glyp"), utf8("Wide line\u{2029}Mid\u{2029}")),
|
||||
(
|
||||
tag(b"GAtt"),
|
||||
F::Obj(
|
||||
tag(b"GlAS"),
|
||||
vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), vec![run(14, Some(attrs("Inter", "Inter-Regular", 400, false, 40.0)))]))],
|
||||
),
|
||||
),
|
||||
(tag(b"PAtt"), F::Obj(tag(b"PaAS"), vec![(tag(b"Runs"), F::Objs(tag(b"PaAR"), vec![para(10, first), para(14, second)]))])),
|
||||
],
|
||||
);
|
||||
F::Def(
|
||||
50,
|
||||
vec![tag(b"TxtA")],
|
||||
vec![
|
||||
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])),
|
||||
(tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 400.0, 100.0])), (tag(b"ArtV"), F::F64(80.0))])),
|
||||
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 100.0, 0.0, 1.0, 100.0])),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artistic_text_aligns_its_lines_within_the_longest() {
|
||||
// (first, second alignment) → where "Mid" goes in the span of "Wide line" (0 its left end,
|
||||
@@ -867,19 +558,6 @@ fn artistic_text_aligns_its_lines_within_the_longest() {
|
||||
assert!(texts(&d)[0].paras.iter().all(|p| p.left_indent == 0.0 && p.right_indent == 0.0));
|
||||
}
|
||||
|
||||
/// Frame text of two 100 px lines in a frame `height` tall.
|
||||
fn two_line_frame(height: f64) -> F {
|
||||
let blk = block(utf8("One\u{2029}Two\u{2029}"), vec![run(8, Some(auto_leading(attrs("Inter", "Inter-Regular", 400, false, 100.0))))], &[0]);
|
||||
F::Def(
|
||||
60,
|
||||
vec![tag(b"TxtF")],
|
||||
vec![
|
||||
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])),
|
||||
(tag(b"TxtH"), F::Obj(tag(b"FrFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 500.0, height]))])),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_text_hangs_from_cap_height_and_shows_lines_whose_baseline_fits() {
|
||||
use vectorcraft_doc::text::FirstBaseline;
|
||||
@@ -949,30 +627,6 @@ fn underlined_and_struck_through_characters_stay_so() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A 16 × 16 CMYK pixel layer of pure cyan, opaque or (`transparent`) with no alpha, in a
|
||||
/// document that embeds `profile` as its CMYK profile.
|
||||
fn cmyk_layer(transparent: bool, profile: Option<&[u8]>) -> Vec<u8> {
|
||||
let mut bitmap = vec![(tag(b"Frmt"), F::Enum(4, 0)), (tag(b"BmpW"), F::I32(16)), (tag(b"BmpH"), F::I32(16))];
|
||||
for c in 1..=5 {
|
||||
// Tile state 2: every sample 255; 0: every sample 0.
|
||||
let full = c == 1 || (c == 5 && !transparent);
|
||||
bitmap.push((tag(&[b'T', b'W', b'i', b'0' + c]), F::I32(1)));
|
||||
bitmap.push((tag(&[b'T', b'H', b'i', b'0' + c]), F::I32(1)));
|
||||
bitmap.push((tag(&[b'S', b't', b'a', b'0' + c]), F::U8s(vec![if full { 2 } else { 0 }])));
|
||||
}
|
||||
let layer = F::Def(10, vec![tag(b"Rstr")], vec![(tag(b"Bitm"), F::Obj(tag(b"DyBm"), bitmap)), (tag(b"Desc"), F::Str("Photo".into()))]);
|
||||
let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 16.0, 16.0])), (tag(b"Chld"), F::Shared(vec![layer]))]);
|
||||
let mut root = vec![(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))];
|
||||
if let Some(icc) = profile {
|
||||
let mut blob = vec![0x2d];
|
||||
blob.extend(tag(b"Data").0.to_le_bytes());
|
||||
blob.extend((icc.len() as u32).to_le_bytes());
|
||||
blob.extend_from_slice(icc);
|
||||
root.push((tag(b"CMYP"), F::Obj(tag(b"ICCP"), vec![(tag(b"Data"), F::Raw(blob))])));
|
||||
}
|
||||
synth::container(&[("doc.dat", &stream(root), Method::Zstd)], None)
|
||||
}
|
||||
|
||||
/// The image of the node named "Photo", and the blob it shows.
|
||||
fn photo(d: &Document) -> &vectorcraft_doc::ImageBlob {
|
||||
let NodeKind::Image(img) = &named(d, "Photo").kind else { panic!("an image") };
|
||||
@@ -1012,32 +666,12 @@ fn cmyk_pixel_layers_without_a_profile_keep_their_inks_or_use_the_working_profil
|
||||
assert!(loaded.warnings.iter().any(|w| w.contains("CMYK pixel layers with transparency")), "{:?}", loaded.warnings);
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#![proptest_config(proptest::test_runner::Config::with_cases(128))]
|
||||
#[test]
|
||||
fn mutated_documents_never_panic(edits in proptest::collection::vec((0usize..4096, proptest::prelude::any::<u8>()), 1..16)) {
|
||||
// The stream is mutated before it is packed, so edits reach the object graph instead of
|
||||
// failing the archive's checksum.
|
||||
let mut data = {
|
||||
let shadow = F::Obj(tag(b"Shad"), vec![(tag(b"Radi"), F::F64(2.0)), (tag(b"Offs"), F::F64(3.0)), (tag(b"Colr"), rgba(0.0, 0.0, 0.0))]);
|
||||
let nodes = vec![
|
||||
with_effects(rectangle(10, (1.0, 2.0), (3.0, 4.0)), vec![shadow]),
|
||||
artistic_text(20, "Hi\u{2029}there", 40.0),
|
||||
table_node(30, [1.0, 0.0, 0.0, 0.0, 1.0, 0.0]),
|
||||
];
|
||||
stream(vec![(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread(2, 2, nodes)]))]))])
|
||||
};
|
||||
let n = data.len();
|
||||
for (at, v) in edits {
|
||||
if let Some(b) = data.get_mut(at % n) {
|
||||
*b = v;
|
||||
}
|
||||
}
|
||||
let file = synth::container(&[("doc.dat", &data, Method::Stored)], None);
|
||||
if let Ok(loaded) = load("mutated.af", &file)
|
||||
&& let Some(board) = loaded.doc.artboards.first()
|
||||
{
|
||||
let _ = vectorcraft_render::Renderer::new().render_region(&loaded.doc, board.rect, 0.05, false);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn objects_beyond_the_document_range_are_left_out() {
|
||||
// 5 million points from the origin, past `MAX_COORD`.
|
||||
let far = named_rect(11, "Far box", (5.0e6, 0.0), (10.0, 10.0));
|
||||
let loaded = open("far.af", &canvas_with(vec![rectangle(10, (0.0, 0.0), (50.0, 50.0)), far], None));
|
||||
let names: Vec<_> = all(&loaded.doc).iter().filter_map(|n| n.name.clone()).collect();
|
||||
assert!(names.iter().any(|n| n == "Magenta box") && !names.iter().any(|n| n == "Far box"), "{names:?}");
|
||||
assert!(loaded.warnings.iter().any(|w| w.contains("beyond the document range")), "{:?}", loaded.warnings);
|
||||
}
|
||||
|
||||
@@ -268,6 +268,133 @@ proptest! {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Affinity documents with every feature the reader maps ----------
|
||||
|
||||
#[path = "support/affinity_docs.rs"]
|
||||
mod affinity_docs;
|
||||
#[path = "../../affinity/tests/support/text_fixture.rs"]
|
||||
mod text_fixture;
|
||||
|
||||
/// One sample document: its object stream and, for embedded content, the embedded document's.
|
||||
struct AffinitySample {
|
||||
name: &'static str,
|
||||
stream: Vec<u8>,
|
||||
embedded: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Original synthetic documents (not an Affinity writer) reaching what the reader maps beyond
|
||||
/// [`affinity_native_stream`]: layer effects and colour overlays (also over tables), stroke
|
||||
/// styles, dashes and brushes, paragraph alignment, frame text, underline, strikethrough and
|
||||
/// line breaks inside a paragraph, tables with merged and two-line cells, master pages, CMYK
|
||||
/// pixel layers with and without the document's CMYK profile, and embedded documents.
|
||||
fn affinity_feature_samples() -> Vec<AffinitySample> {
|
||||
use affinity_docs::*;
|
||||
let identity = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0];
|
||||
let fx = vec![shadow(), effect(b"OutG", 6.0), effect(b"InnS", 3.0), effect(b"BevE", 4.0), outline(0, 3.0), outline(2, 4.0), colour_overlay(0.5)];
|
||||
let canvas = canvas_stream(vec![
|
||||
with_effects(stroked(10, 1, &[]), fx),
|
||||
stroked(11, 0, &[]),
|
||||
stroked(12, 2, &[4.0, 2.0]),
|
||||
stroked(13, 3, &[]),
|
||||
mixed_alignment(1, 2),
|
||||
two_line_frame(150.0),
|
||||
decorated_text(70),
|
||||
with_effects(table_with(30, identity, ["A", "B\u{2028}two", "C", "D", "E"]), vec![colour_overlay(1.0)]),
|
||||
]);
|
||||
let icc = vectorcraft_color::cms::icc_bytes(vectorcraft_color::cms::GENERIC_CMYK).unwrap();
|
||||
let inner = canvas_stream(vec![with_effects(rectangle(10, (0.0, 0.0), (50.0, 50.0)), vec![colour_overlay(1.0)]), artistic_text(20, "Hi", 40.0)]);
|
||||
vec![
|
||||
AffinitySample { name: "effects, strokes, text and tables", stream: canvas, embedded: None },
|
||||
AffinitySample { name: "master pages", stream: master_stream(3), embedded: None },
|
||||
AffinitySample { name: "CMYK pixels with a profile", stream: cmyk_stream(false, Some(&icc)), embedded: None },
|
||||
AffinitySample { name: "CMYK pixels with transparency", stream: cmyk_stream(true, None), embedded: None },
|
||||
AffinitySample { name: "embedded document", stream: canvas_stream(vec![embedded_node(20)]), embedded: Some(inner) },
|
||||
]
|
||||
}
|
||||
|
||||
/// A sample's file: its streams stored uncompressed, so mutations reach the object graph
|
||||
/// instead of failing a decompressor's checks.
|
||||
fn affinity_sample_file(stream: &[u8], embedded: Option<&[u8]>) -> Vec<u8> {
|
||||
use vectorcraft_affinity::synth::Method;
|
||||
let inner = embedded.map(|e| affinity_docs::packed(e, None, Method::Stored));
|
||||
affinity_docs::packed(stream, inner.as_deref(), Method::Stored)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_affinity_feature_samples_reach_what_they_are_for() {
|
||||
use vectorcraft_doc::NodeKind;
|
||||
let samples = affinity_feature_samples();
|
||||
let open = |i: usize| {
|
||||
let s: &AffinitySample = &samples[i];
|
||||
let l = vectorcraft_engine::cmd::fileio::load("x.af", &affinity_sample_file(&s.stream, s.embedded.as_deref())).unwrap();
|
||||
assert!(!l.preview_only, "{}: {:?}", s.name, l.warnings);
|
||||
l
|
||||
};
|
||||
fn all(d: &Document) -> Vec<&vectorcraft_doc::Node> {
|
||||
fn walk<'a>(nodes: &'a [std::sync::Arc<vectorcraft_doc::Node>], out: &mut Vec<&'a vectorcraft_doc::Node>) {
|
||||
for n in nodes {
|
||||
out.push(n);
|
||||
if let Some(c) = n.children() {
|
||||
walk(c, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
walk(&d.layers, &mut out);
|
||||
out
|
||||
}
|
||||
let l = open(0);
|
||||
let nodes = all(&l.doc);
|
||||
let runs: Vec<&vectorcraft_doc::text::TextRun> =
|
||||
nodes.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.runs.iter()) } else { None }).flatten().collect();
|
||||
assert!(runs.iter().any(|r| r.style.underline) && runs.iter().any(|r| r.style.strikethrough));
|
||||
assert!(runs.iter().any(|r| r.text.contains("two")), "the table's two-line cell");
|
||||
assert!(nodes.iter().any(|n| n.appearance.effects.iter().any(|e| e.id == "stylize.dropShadow")));
|
||||
assert!(l.warnings.iter().any(|w| w.contains("bevel")), "{:?}", l.warnings);
|
||||
// Line styles: solid, None (no stroke), dashed and brush.
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, StrokeLayer};
|
||||
let strokes = |n: &vectorcraft_doc::Node| -> Vec<StrokeLayer> {
|
||||
n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.clone()) } else { None }).collect()
|
||||
};
|
||||
let boxes: Vec<_> = nodes.iter().filter(|n| n.name.as_deref() == Some("Magenta box")).collect();
|
||||
assert_eq!(boxes.len(), 4);
|
||||
assert_eq!(boxes.iter().filter(|b| strokes(b).is_empty()).count(), 1, "line style None");
|
||||
assert!(boxes.iter().any(|b| strokes(b).iter().any(|s| s.dash.is_some())), "dashed");
|
||||
let l = open(1);
|
||||
assert_eq!(l.doc.artboards.len(), 3);
|
||||
assert!(l.doc.layers.iter().any(|n| n.name.as_deref() == Some("Master A")));
|
||||
for i in [2, 3] {
|
||||
assert!(all(&open(i).doc).iter().any(|n| matches!(n.kind, NodeKind::Image(_))), "{}", samples[i].name);
|
||||
}
|
||||
assert!(all(&open(4).doc).iter().any(|n| n.name.as_deref() == Some("Magenta box")));
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
#[test]
|
||||
fn affinity_feature_documents_with_mutated_streams_render_and_export(
|
||||
sample in 0usize..5,
|
||||
inner in any::<bool>(),
|
||||
cut in 0usize..8192,
|
||||
edits in prop::collection::vec((0usize..8192, any::<u8>()), 0..24),
|
||||
) {
|
||||
let samples = affinity_feature_samples();
|
||||
let s = &samples[sample % samples.len()];
|
||||
let (mut stream, mut embedded) = (s.stream.clone(), s.embedded.clone());
|
||||
// Mutate the embedded document's stream instead, where there is one.
|
||||
let target = match embedded.as_mut() {
|
||||
Some(e) if inner => e,
|
||||
_ => &mut stream,
|
||||
};
|
||||
let n = target.len();
|
||||
for (at, b) in edits { target[at % n] = b; }
|
||||
target.truncate(cut.max(16));
|
||||
let bytes = affinity_sample_file(&stream, embedded.as_deref());
|
||||
survive(s.name, || vectorcraft_engine::cmd::fileio::load("x.af", &bytes).ok().map(|l| l.doc))?;
|
||||
}
|
||||
}
|
||||
|
||||
/// Import must not panic; whatever comes back must render and export without panicking either.
|
||||
fn survive(what: &str, import: impl FnOnce() -> Option<Document>) -> Result<(), TestCaseError> {
|
||||
let r = catch_quiet(|| {
|
||||
@@ -4046,3 +4173,15 @@ fn ai_cut_inside_an_array_or_dictionary_opens_without_panicking() {
|
||||
survive("ai cut inside an array or dictionary", || vectorcraft_engine::cmd::fileio::load("saved.ai", &b).ok().map(|l| l.doc)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Found by the fuzzer above: a shape 3.3 million points long and 50 high, with a drop shadow,
|
||||
/// made the PDF export rasterise its shadow wider than the renderer can address (within the
|
||||
/// effect image's area limit, past any side), which panicked.
|
||||
#[test]
|
||||
fn a_long_thin_shape_with_a_shadow_renders_and_exports() {
|
||||
use affinity_docs::*;
|
||||
let stream = canvas_stream(vec![with_effects(rectangle(10, (0.0, 0.0), (3_276_800.0, 50.0)), vec![shadow()])]);
|
||||
let bytes = affinity_sample_file(&stream, None);
|
||||
let doc = vectorcraft_engine::cmd::fileio::load("long.af", &bytes).unwrap().doc;
|
||||
survive("long shape", || Some(doc)).unwrap();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
//! Original synthetic Affinity documents for the engine's Affinity tests and the import fuzzer,
|
||||
//! built with the reader's `synth` feature in the layout the reader accepts (no Affinity
|
||||
//! software or files were used).
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::text_fixture::{array_f64, array_i32, attrs, block, run, shared_objects, utf8};
|
||||
use vectorcraft_affinity::synth::{self, F, Method, tag};
|
||||
|
||||
pub type Field = (vectorcraft_affinity::stream::Tag, F);
|
||||
|
||||
/// Encode a stream whose shared arrays may list inline objects.
|
||||
pub fn stream(mut root: Vec<Field>) -> Vec<u8> {
|
||||
let mut next_id = 1000;
|
||||
for (_, value) in &mut root {
|
||||
shared_objects(value, &mut next_id);
|
||||
}
|
||||
synth::stream(&root)
|
||||
}
|
||||
|
||||
/// 300 dpi: a document pixel is 0.24 pt.
|
||||
pub const DPI: f64 = 300.0;
|
||||
|
||||
pub const PT: f64 = 72.0 / DPI;
|
||||
|
||||
pub fn rgba(r: f32, g: f32, b: f32) -> F {
|
||||
F::Obj(tag(b"RGBA"), vec![(tag(b"_col"), F::Struct([r, g, b, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect()))])
|
||||
}
|
||||
|
||||
pub fn cmyk(c: f32, m: f32, y: f32, k: f32) -> F {
|
||||
F::Obj(
|
||||
tag(b"FilS"),
|
||||
vec![(tag(b"Colr"), F::Obj(tag(b"CMYK"), vec![(tag(b"_col"), F::Struct([c, m, y, k, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect()))]))],
|
||||
)
|
||||
}
|
||||
|
||||
/// A magenta rectangle named `name`.
|
||||
pub fn named_rect(id: u32, name: &str, at: (f64, f64), size: (f64, f64)) -> F {
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"ShpN")],
|
||||
vec![
|
||||
(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, size.0, size.1])),
|
||||
(tag(b"Shpe"), F::Def(id + 10_000, vec![tag(b"ShNR")], vec![])),
|
||||
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, at.0, 0.0, 1.0, at.1])),
|
||||
(tag(b"BFil"), cmyk(0.0, 1.0, 0.0, 0.0)),
|
||||
(tag(b"Desc"), F::Str(name.into())),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn rectangle(id: u32, at: (f64, f64), size: (f64, f64)) -> F {
|
||||
named_rect(id, "Magenta box", at, size)
|
||||
}
|
||||
|
||||
/// An artboard (`ABEn`) named `name` over `bounds` at `at`, holding `children`.
|
||||
pub fn board(id: u32, name: &str, at: (f64, f64), size: (f64, f64), children: Vec<F>) -> F {
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"ShpN")],
|
||||
vec![
|
||||
(tag(b"Desc"), F::Str(name.into())),
|
||||
(tag(b"ABEn"), F::Bool(true)),
|
||||
(tag(b"Shpe"), F::Def(id + 10_000, vec![tag(b"ShNR")], vec![])),
|
||||
(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, size.0, size.1])),
|
||||
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, at.0, 0.0, 1.0, at.1])),
|
||||
(tag(b"Chld"), F::Shared(children)),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn artistic_text(id: u32, text: &str, size: f64) -> F {
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"TxtA")],
|
||||
vec![
|
||||
(
|
||||
tag(b"StSt"),
|
||||
F::Obj(
|
||||
tag(b"Stry"),
|
||||
vec![(
|
||||
tag(b"Blok"),
|
||||
F::Shared(vec![block(
|
||||
utf8(text),
|
||||
vec![run(text.chars().count() as i32, Some(attrs("Inter", "Inter-Bold", 700, false, size)))],
|
||||
&[1],
|
||||
)]),
|
||||
)],
|
||||
),
|
||||
),
|
||||
(tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 400.0, 100.0])), (tag(b"ArtV"), F::F64(80.0))])),
|
||||
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 1000.0, 0.0, 1.0, 500.0])),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn page(x0: f64, w: f64, h: f64) -> F {
|
||||
F::Obj(tag(b"PagR"), vec![(tag(b"rctp"), F::F64s(vec![x0, 0.0, x0 + w, h]))])
|
||||
}
|
||||
|
||||
/// A Publisher spread of A4 pages at 300 dpi holding `nodes`.
|
||||
pub fn spread(id: u32, pages: usize, nodes: Vec<F>) -> F {
|
||||
let w = 2480.0 * pages as f64;
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"Sprd")],
|
||||
vec![
|
||||
(tag(b"SprB"), F::F64s(vec![0.0, 0.0, w, 3508.0])),
|
||||
(
|
||||
tag(b"SpMd"),
|
||||
F::Obj(tag(b"SpMd"), vec![(tag(b"PagR"), F::Shared((0..pages).map(|i| page(2480.0 * i as f64, 2480.0, 3508.0)).collect()))]),
|
||||
),
|
||||
(tag(b"Chld"), F::Shared(nodes)),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn publisher_with(spreads: Vec<F>) -> Vec<u8> {
|
||||
let data = stream(vec![
|
||||
(tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(DPI))])),
|
||||
(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(spreads))])),
|
||||
]);
|
||||
synth::container(&[("doc.dat", &data, Method::Zstd)], None)
|
||||
}
|
||||
|
||||
/// A two-page spread: a layer with a CMYK rectangle, and centred artistic text.
|
||||
pub fn publisher() -> Vec<u8> {
|
||||
let layer = F::Def(
|
||||
3,
|
||||
vec![tag(b"Scop")],
|
||||
vec![(tag(b"Desc"), F::Str("Background".into())), (tag(b"Chld"), F::Shared(vec![rectangle(10, (100.0, 100.0), (100.0, 200.0))]))],
|
||||
);
|
||||
publisher_with(vec![spread(2, 2, vec![layer, artistic_text(20, "Hello\u{2029}World\u{2029}", 100.0)])])
|
||||
}
|
||||
|
||||
/// One A4 page at 300 dpi holding `node`.
|
||||
pub fn page_with(node: F) -> Vec<u8> {
|
||||
publisher_with(vec![spread(2, 1, vec![node])])
|
||||
}
|
||||
|
||||
/// A document at 72 dpi holding `nodes` on a canvas without pages; `embedded` is `edc/1`.
|
||||
pub fn canvas_with(nodes: Vec<F>, embedded: Option<&[u8]>) -> Vec<u8> {
|
||||
packed(&canvas_stream(nodes), embedded, Method::Zstd)
|
||||
}
|
||||
|
||||
/// The object stream of [`canvas_with`].
|
||||
pub fn canvas_stream(nodes: Vec<F>) -> Vec<u8> {
|
||||
let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 1000.0, 1000.0])), (tag(b"Chld"), F::Shared(nodes))]);
|
||||
stream(vec![(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))])
|
||||
}
|
||||
|
||||
/// A document of object stream `data`, packed with `method`; `embedded` is entry `edc/1`.
|
||||
pub fn packed(data: &[u8], embedded: Option<&[u8]>, method: Method) -> Vec<u8> {
|
||||
match embedded {
|
||||
Some(inner) => {
|
||||
let mut entry = b"EmDc\0\0\0\0".to_vec();
|
||||
entry.extend_from_slice(inner);
|
||||
synth::container(&[("doc.dat", data, method), ("edc/1", &entry, method)], None)
|
||||
}
|
||||
None => synth::container(&[("doc.dat", data, method)], None),
|
||||
}
|
||||
}
|
||||
|
||||
/// An instance of the embedded document `edc/1`, scaled 2× with its content's centre at (500, 300).
|
||||
pub fn embedded_node(id: u32) -> F {
|
||||
let content = F::Obj(tag(b"EmbC"), vec![(tag(b"EmbC"), F::Entry("edc/1".into()))]);
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"EmbN")],
|
||||
vec![
|
||||
(tag(b"Bitm"), F::Obj(tag(b"EmbR"), vec![(tag(b"EmCn"), content), (tag(b"PBBx"), F::Enum(2, 0))])),
|
||||
(tag(b"Xfrm"), F::F64s(vec![2.0, 0.0, 500.0, 0.0, 2.0, 300.0])),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// A blue 2 px line on a table edge.
|
||||
pub fn table_edge() -> F {
|
||||
F::Obj(
|
||||
tag(b"TEdg"),
|
||||
vec![
|
||||
(tag(b"Line"), F::Obj(tag(b"LDsc"), vec![(tag(b"LDeL"), F::Obj(tag(b"LSty"), vec![(tag(b"Wght"), F::F64(2.0))]))])),
|
||||
(tag(b"Fill"), F::Obj(tag(b"FDsc"), vec![(tag(b"FDeF"), cmyk(1.0, 0.5, 0.0, 0.0))])),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// A 3 × 2 table whose cell (1, 1) is merged into (1, 0); cells "A".."E" in reading order, each
|
||||
/// ended by a cell break. Cells are 100 × 50 px from (0, 0).
|
||||
pub fn table_node(id: u32, transform: [f64; 6]) -> F {
|
||||
table_with(id, transform, ["A", "B", "C", "D", "E"])
|
||||
}
|
||||
|
||||
/// [`table_node`] with these cell texts.
|
||||
pub fn table_with(id: u32, transform: [f64; 6], texts: [&str; 5]) -> F {
|
||||
let brk = || F::Obj(tag(b"BrGl"), vec![(tag(b"HdBk"), F::Enum(4, 1))]);
|
||||
let segments: Vec<F> = texts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| {
|
||||
let mut fields = vec![(tag(b"Utf8"), F::Str((*t).into()))];
|
||||
if i > 0 {
|
||||
fields.insert(0, (tag(b"Glys"), F::Shared(vec![brk()])));
|
||||
}
|
||||
F::Obj(tag(b"GSeg"), fields)
|
||||
})
|
||||
.collect();
|
||||
let len = (texts.iter().map(|t| t.chars().count()).sum::<usize>() + texts.len() - 1) as i32;
|
||||
let glyphs = F::Obj(tag(b"GStr"), vec![(tag(b"Mixd"), F::Shared(segments))]);
|
||||
let story = block(glyphs, vec![run(len, Some(attrs("Inter", "Inter-Regular", 400, false, 40.0)))], &[]);
|
||||
let cell = |merged: bool| {
|
||||
let mut f = vec![(tag(b"AliY"), F::Enum(1, 0)), (tag(b"Inse"), F::F64s(vec![5.0, 6.0, 7.0, 8.0]))];
|
||||
if merged {
|
||||
f.push((tag(b"BrLf"), F::Bool(true)));
|
||||
}
|
||||
F::Obj(tag(b"TCel"), f)
|
||||
};
|
||||
let pos = |values: &[f64]| F::Obj(tag(b"TPos"), vec![(tag(b"Posn"), array_f64(b"Posn", values))]);
|
||||
let edges = |n: usize| F::Obj(tag(b"TEds"), vec![(tag(b"Edge"), F::Shared((0..n).map(|_| table_edge()).collect()))]);
|
||||
let table = F::Obj(
|
||||
tag(b"Tabl"),
|
||||
vec![
|
||||
(tag(b"CPos"), pos(&[0.0, 100.0, 200.0, 300.0])),
|
||||
(tag(b"RPos"), pos(&[0.0, 50.0, 100.0])),
|
||||
(tag(b"CEdg"), edges(8)),
|
||||
(tag(b"REdg"), edges(9)),
|
||||
(tag(b"Cell"), F::Obj(tag(b"TCls"), vec![(tag(b"Cell"), F::Shared((0..6).map(|i| cell(i == 4)).collect()))])),
|
||||
],
|
||||
);
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"TxtT")],
|
||||
vec![
|
||||
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![story]))])),
|
||||
(tag(b"TxtH"), F::Obj(tag(b"TbFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 300.0, 100.0])), (tag(b"Tabl"), table)])),
|
||||
(tag(b"Xfrm"), F::F64s(transform.to_vec())),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// Character attributes without their own fixed leading.
|
||||
pub fn auto_leading(mut attrs: F) -> F {
|
||||
if let F::Obj(_, fields) = &mut attrs {
|
||||
for (t, v) in fields.iter_mut() {
|
||||
if *t == tag(b"Ints") {
|
||||
*v = array_i32(b"Ints", &[0, 0, 0, 0, 0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
attrs
|
||||
}
|
||||
|
||||
/// A two-page master spread (pages 0–100 and 100–200 wide) with a box on each page; the
|
||||
/// document's page shows its second page, as Affinity's instance says (MPOf 1).
|
||||
pub fn with_master(pages: usize) -> Vec<u8> {
|
||||
packed(&master_stream(pages), None, Method::Zstd)
|
||||
}
|
||||
|
||||
/// The object stream of [`with_master`].
|
||||
pub fn master_stream(pages: usize) -> Vec<u8> {
|
||||
let master = F::Def(
|
||||
7,
|
||||
vec![tag(b"Sprd")],
|
||||
vec![
|
||||
(tag(b"SpMd"), F::Obj(tag(b"SpMd"), vec![(tag(b"PagR"), F::Shared(vec![page(0.0, 100.0, 150.0), page(100.0, 100.0, 150.0)]))])),
|
||||
(
|
||||
tag(b"Chld"),
|
||||
F::Shared(vec![
|
||||
named_rect(30, "Left master box", (10.0, 10.0), (20.0, 20.0)),
|
||||
named_rect(31, "Right master box", (110.0, 10.0), (30.0, 40.0)),
|
||||
]),
|
||||
),
|
||||
],
|
||||
);
|
||||
let instance = F::Def(
|
||||
5,
|
||||
vec![tag(b"MPIN")],
|
||||
vec![
|
||||
(tag(b"SLnk"), F::Obj(tag(b"ILSN"), vec![(tag(b"ILOb"), F::Shared(vec![master, F::Ref(5)]))])),
|
||||
(tag(b"PgOf"), F::U32(0)),
|
||||
(tag(b"MPOf"), F::U32(1)),
|
||||
(tag(b"PgCt"), F::U32(pages as u32)),
|
||||
],
|
||||
);
|
||||
let spread = F::Def(
|
||||
2,
|
||||
vec![tag(b"Sprd")],
|
||||
vec![
|
||||
(tag(b"SpMd"), F::Obj(tag(b"SpMd"), vec![(tag(b"PagR"), F::Shared((0..pages).map(|i| page(100.0 * i as f64, 100.0, 150.0)).collect()))])),
|
||||
(tag(b"Chld"), F::Shared(vec![instance, named_rect(40, "Page box", (50.0, 100.0), (10.0, 10.0))])),
|
||||
],
|
||||
);
|
||||
stream(vec![(
|
||||
tag(b"DocR"),
|
||||
F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread])), (tag(b"MpCh"), F::Shared(vec![F::Ref(7)]))]),
|
||||
)])
|
||||
}
|
||||
|
||||
pub fn with_effects(mut node: F, effects: Vec<F>) -> F {
|
||||
if let F::Def(_, _, fields) = &mut node {
|
||||
fields.push((tag(b"FiEf"), F::Shared(effects)));
|
||||
}
|
||||
node
|
||||
}
|
||||
|
||||
/// Artistic text "Wide line" over "Mid", the paragraphs aligned `first` and `second`
|
||||
/// (0 left, 1 centre, 2 right).
|
||||
pub fn mixed_alignment(first: i32, second: i32) -> F {
|
||||
let para = |end: i32, align: i32| {
|
||||
vec![(tag(b"Indx"), F::I32(end)), (tag(b"Item"), F::Obj(tag(b"PAtt"), vec![(tag(b"Ints"), array_i32(b"Ints", &[align]))]))]
|
||||
};
|
||||
let blk = F::Obj(
|
||||
tag(b"StBl"),
|
||||
vec![
|
||||
(tag(b"Glyp"), utf8("Wide line\u{2029}Mid\u{2029}")),
|
||||
(
|
||||
tag(b"GAtt"),
|
||||
F::Obj(
|
||||
tag(b"GlAS"),
|
||||
vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), vec![run(14, Some(attrs("Inter", "Inter-Regular", 400, false, 40.0)))]))],
|
||||
),
|
||||
),
|
||||
(tag(b"PAtt"), F::Obj(tag(b"PaAS"), vec![(tag(b"Runs"), F::Objs(tag(b"PaAR"), vec![para(10, first), para(14, second)]))])),
|
||||
],
|
||||
);
|
||||
F::Def(
|
||||
50,
|
||||
vec![tag(b"TxtA")],
|
||||
vec![
|
||||
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])),
|
||||
(tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 400.0, 100.0])), (tag(b"ArtV"), F::F64(80.0))])),
|
||||
(tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 100.0, 0.0, 1.0, 100.0])),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// Frame text of two 100 px lines in a frame `height` tall.
|
||||
pub fn two_line_frame(height: f64) -> F {
|
||||
let blk = block(utf8("One\u{2029}Two\u{2029}"), vec![run(8, Some(auto_leading(attrs("Inter", "Inter-Regular", 400, false, 100.0))))], &[0]);
|
||||
F::Def(
|
||||
60,
|
||||
vec![tag(b"TxtF")],
|
||||
vec![
|
||||
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])),
|
||||
(tag(b"TxtH"), F::Obj(tag(b"FrFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 500.0, height]))])),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// A 16 × 16 CMYK pixel layer of pure cyan, opaque or (`transparent`) with no alpha, in a
|
||||
/// document that embeds `profile` as its CMYK profile.
|
||||
pub fn cmyk_layer(transparent: bool, profile: Option<&[u8]>) -> Vec<u8> {
|
||||
packed(&cmyk_stream(transparent, profile), None, Method::Zstd)
|
||||
}
|
||||
|
||||
/// The object stream of [`cmyk_layer`].
|
||||
pub fn cmyk_stream(transparent: bool, profile: Option<&[u8]>) -> Vec<u8> {
|
||||
let mut bitmap = vec![(tag(b"Frmt"), F::Enum(4, 0)), (tag(b"BmpW"), F::I32(16)), (tag(b"BmpH"), F::I32(16))];
|
||||
for c in 1..=5 {
|
||||
// Tile state 2: every sample 255; 0: every sample 0.
|
||||
let full = c == 1 || (c == 5 && !transparent);
|
||||
bitmap.push((tag(&[b'T', b'W', b'i', b'0' + c]), F::I32(1)));
|
||||
bitmap.push((tag(&[b'T', b'H', b'i', b'0' + c]), F::I32(1)));
|
||||
bitmap.push((tag(&[b'S', b't', b'a', b'0' + c]), F::U8s(vec![if full { 2 } else { 0 }])));
|
||||
}
|
||||
let layer = F::Def(10, vec![tag(b"Rstr")], vec![(tag(b"Bitm"), F::Obj(tag(b"DyBm"), bitmap)), (tag(b"Desc"), F::Str("Photo".into()))]);
|
||||
let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 16.0, 16.0])), (tag(b"Chld"), F::Shared(vec![layer]))]);
|
||||
let mut root = vec![(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))];
|
||||
if let Some(icc) = profile {
|
||||
root.push((tag(b"CMYP"), F::Obj(tag(b"ICCP"), vec![(tag(b"Data"), blob(b"Data", icc))])));
|
||||
}
|
||||
stream(root)
|
||||
}
|
||||
|
||||
/// A byte blob field, as `LSty` `Data` and `ICCP` `Data` are stored.
|
||||
pub fn blob(field: &[u8; 4], bytes: &[u8]) -> F {
|
||||
let mut b = vec![0x2d];
|
||||
b.extend(tag(field).0.to_le_bytes());
|
||||
b.extend((bytes.len() as u32).to_le_bytes());
|
||||
b.extend_from_slice(bytes);
|
||||
F::Raw(b)
|
||||
}
|
||||
|
||||
/// A drop shadow 3 px below, blurred 2 px.
|
||||
pub fn shadow() -> F {
|
||||
F::Obj(
|
||||
tag(b"Shad"),
|
||||
vec![
|
||||
(tag(b"Enab"), F::Bool(true)),
|
||||
(tag(b"Opac"), F::F64(0.5)),
|
||||
(tag(b"Radi"), F::F64(2.0)),
|
||||
(tag(b"Offs"), F::F64(3.0)),
|
||||
(tag(b"Angl"), F::F64(std::f64::consts::FRAC_PI_2)),
|
||||
(tag(b"Colr"), rgba(0.0, 0.0, 1.0)),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// An effect of `class` (`OutG`, `InnS`, `BevE` …) with this radius, yellow at 80 %.
|
||||
pub fn effect(class: &[u8; 4], radius: f64) -> F {
|
||||
F::Obj(
|
||||
tag(class),
|
||||
vec![(tag(b"Enab"), F::Bool(true)), (tag(b"Opac"), F::F64(0.8)), (tag(b"Radi"), F::F64(radius)), (tag(b"Colr"), rgba(1.0, 1.0, 0.0))],
|
||||
)
|
||||
}
|
||||
|
||||
/// A red outline effect `width` wide, aligned `align` (0 outside, 1 inside, 2 centred).
|
||||
pub fn outline(align: u16, width: f64) -> F {
|
||||
F::Obj(
|
||||
tag(b"Strk"),
|
||||
vec![(tag(b"Enab"), F::Bool(true)), (tag(b"Radi"), F::F64(width)), (tag(b"Alig"), F::Enum(align, 0)), (tag(b"Colr"), rgba(1.0, 0.0, 0.0))],
|
||||
)
|
||||
}
|
||||
|
||||
/// A green colour overlay at `opacity`.
|
||||
pub fn colour_overlay(opacity: f64) -> F {
|
||||
F::Obj(tag(b"ColO"), vec![(tag(b"Enab"), F::Bool(true)), (tag(b"Opac"), F::F64(opacity)), (tag(b"Colr"), rgba(0.0, 1.0, 0.0))])
|
||||
}
|
||||
|
||||
/// A rectangle with a black stroke 2 px wide in line style `style` (`LSty` `Data` byte 10:
|
||||
/// 0 none, 1 solid, 2 dashed, 3 brush), dashed when `dash` has lengths.
|
||||
pub fn stroked(id: u32, style: u8, dash: &[f64]) -> F {
|
||||
let mut node = rectangle(id, (0.0, 0.0), (50.0, 50.0));
|
||||
let mut data = 10.0f64.to_le_bytes().to_vec();
|
||||
data.extend([0, 0, style]);
|
||||
let mut lsty = vec![(tag(b"Wght"), F::F64(2.0)), (tag(b"Data"), blob(b"Data", &data))];
|
||||
if !dash.is_empty() {
|
||||
lsty.push((tag(b"Patn"), array_f64(b"Patn", dash)));
|
||||
}
|
||||
if let F::Def(_, _, fields) = &mut node {
|
||||
fields.push((tag(b"PFil"), cmyk(0.0, 0.0, 0.0, 1.0)));
|
||||
fields.push((tag(b"LSty"), F::Obj(tag(b"LSty"), lsty)));
|
||||
}
|
||||
node
|
||||
}
|
||||
|
||||
/// Character attributes of 40 px Inter with these `Ints` (slot 0 underline, 1 strikethrough).
|
||||
pub fn decorated(ints: &[i32]) -> F {
|
||||
let mut a = attrs("Inter", "Inter-Regular", 400, false, 40.0);
|
||||
if let F::Obj(_, fields) = &mut a {
|
||||
for (t, v) in fields.iter_mut() {
|
||||
if *t == tag(b"Ints") {
|
||||
*v = array_i32(b"Ints", ints);
|
||||
}
|
||||
}
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// Artistic text with an underlined run, a struck-through one and a line break inside a
|
||||
/// paragraph.
|
||||
pub fn decorated_text(id: u32) -> F {
|
||||
let blk = block(
|
||||
utf8("Plain under\u{2028}struck\u{2029}"),
|
||||
vec![
|
||||
run(6, Some(attrs("Inter", "Inter-Regular", 400, false, 40.0))),
|
||||
run(12, Some(decorated(&[1, 0, 0, 0, 1]))),
|
||||
run(19, Some(decorated(&[0, 1, 0, 0, 1]))),
|
||||
],
|
||||
&[0],
|
||||
);
|
||||
F::Def(
|
||||
id,
|
||||
vec![tag(b"TxtA")],
|
||||
vec![
|
||||
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])),
|
||||
(tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 400.0, 100.0])), (tag(b"ArtV"), F::F64(80.0))])),
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -82,6 +82,11 @@ pub fn drawn_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> {
|
||||
/// Largest raster an export may ask [`Renderer::render_region`] for, per side. The CPU rasteriser
|
||||
/// addresses at most `u16::MAX` pixels per side (and panics at that edge).
|
||||
pub const MAX_RASTER_SIDE: u32 = 32_768;
|
||||
/// Largest side of a render context the renderer makes. The CPU rasteriser keeps pixel positions
|
||||
/// in `u16` and overflows near `u16::MAX` (its tile and bucket arithmetic rounds past it), so
|
||||
/// contexts stay within the side raster exports are limited to.
|
||||
const MAX_CONTEXT_SIDE: u32 = MAX_RASTER_SIDE;
|
||||
|
||||
/// Largest raster an export may ask for in total (16384², 1 GiB of RGBA pixels).
|
||||
pub const MAX_RASTER_PIXELS: u64 = 1 << 28;
|
||||
|
||||
@@ -282,7 +287,7 @@ const MAX_SUPERSAMPLED_PIXELS: u64 = 40_000_000;
|
||||
pub fn supersample_factor(w: u32, h: u32) -> Option<u32> {
|
||||
(2..=4u32).rev().find(|k| {
|
||||
let (sw, sh) = (u64::from(w) * u64::from(*k), u64::from(h) * u64::from(*k));
|
||||
sw <= u64::from(u16::MAX) && sh <= u64::from(u16::MAX) && sw * sh <= MAX_SUPERSAMPLED_PIXELS
|
||||
sw <= u64::from(MAX_CONTEXT_SIDE) && sh <= u64::from(MAX_CONTEXT_SIDE) && sw * sh <= MAX_SUPERSAMPLED_PIXELS
|
||||
})
|
||||
}
|
||||
|
||||
@@ -667,7 +672,7 @@ impl Renderer {
|
||||
fn render_as(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions, inks: bool) -> Rendered {
|
||||
let start = now();
|
||||
if opts.supersample && opts.anti_alias == AntiAlias::Art && opts.mask_view.is_none() {
|
||||
let (w, h) = (width.clamp(1, u16::MAX as u32), height.clamp(1, u16::MAX as u32));
|
||||
let (w, h) = (width.clamp(1, MAX_CONTEXT_SIDE), height.clamp(1, MAX_CONTEXT_SIDE));
|
||||
if let Some(k) = supersample_factor(w, h) {
|
||||
let hard = RenderOptions { anti_alias: AntiAlias::None, supersample: false, ..opts.clone() };
|
||||
let big = self.render_as(doc, w * k, h * k, Affine::scale(f64::from(k)) * view, &hard, inks);
|
||||
@@ -681,8 +686,8 @@ impl Renderer {
|
||||
self.check_symbols(doc);
|
||||
let prepared = proof::prepare(doc, opts, &mut self.proof_art);
|
||||
let doc: &Document = &prepared;
|
||||
let w = width.clamp(1, u16::MAX as u32) as u16;
|
||||
let h = height.clamp(1, u16::MAX as u32) as u16;
|
||||
let w = width.clamp(1, MAX_CONTEXT_SIDE) as u16;
|
||||
let h = height.clamp(1, MAX_CONTEXT_SIDE) as u16;
|
||||
// Raster filters (drop shadow, glows, blur) are rendered offscreen on this thread when the
|
||||
// main context is multithreaded (see `fx::with_filters`).
|
||||
let threads = self.threads;
|
||||
@@ -910,7 +915,7 @@ impl Renderer {
|
||||
let b = brush_fx::cull_bounds(&doc.symbol_boxes(), n)?;
|
||||
let s = (size as f64 - 2.0) / b.width().max(b.height()).max(1e-6);
|
||||
let view = Affine::translate((size as f64 / 2.0, size as f64 / 2.0)) * Affine::scale(s) * Affine::translate(-b.center().to_vec2());
|
||||
let w = size.clamp(1, u16::MAX as u32) as u16;
|
||||
let w = size.clamp(1, MAX_CONTEXT_SIDE) as u16;
|
||||
let mut ctx = single_threaded_context(w, w);
|
||||
if let Some(bg) = background {
|
||||
ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3]));
|
||||
|
||||
+1
-1
@@ -433,7 +433,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-22 | minor | Affinity import: master pages, tables, paragraph formats, text scale, shadows, glows, outlines, overlays, underlines, strikethrough, CMYK pixel layers through the document's profile and editable embedded documents, text placed as Affinity places it (AF.5, from storytold/designcraft#291). No entry closed, estimates unchanged. |
|
||||
| 2026-10-22 | minor | Affinity import: master pages, tables, paragraph formats, text scale, shadows, glows, outlines, overlays, underlines, strikethrough, CMYK pixel layers through the document's profile and editable embedded documents, text placed as Affinity places it (AF.5, from storytold/designcraft#291). No entry closed, estimates unchanged. Hostile files stay bounded: one budget for the whole read (layers, pixels, characters, table cells, embedded documents included), a work budget for overlays, capped master page copies, objects past `MAX_COORD` left out; the import fuzzer and limit tests cover tables, paragraphs, effects, overlays, master pages, CMYK pixels and embedded documents. |
|
||||
| 2026-10-22 | minor | Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged. |
|
||||
| 2026-10-21 | minor | Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations. Scores and estimates unchanged. |
|
||||
| 2026-10-21 | minor | M3: highlighted-text Eyedropper sampling preserves ranges and rejects unsupported source/target transparency atomically; regression-tested; scores and estimates unchanged. |
|
||||
|
||||
@@ -981,7 +981,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-22 | minor | File formats: Affinity import gains master pages, tables, paragraph formats, text scale, shadows, glows, outlines (also on stroked shapes), overlays, underlines, strikethrough and editable embedded documents, strokes styled None left out, CMYK pixel layers through the document's CMYK profile, text placed as Affinity places it (AF.5, from storytold/designcraft#291); synthetic, corpus and local-thumbnail tests; score and hours unchanged (estimated). |
|
||||
| 2026-10-22 | minor | File formats: Affinity import gains master pages, tables, paragraph formats, text scale, shadows, glows, outlines (also on stroked shapes), overlays, underlines, strikethrough and editable embedded documents, strokes styled None left out, CMYK pixel layers through the document's CMYK profile, text placed as Affinity places it (AF.5, from storytold/designcraft#291); synthetic, corpus and local-thumbnail tests; score and hours unchanged (estimated). Hostile files stay bounded: one budget for the whole read (layers, pixels, characters, table cells, embedded documents included), a work budget for overlays, capped master page copies, objects past `MAX_COORD` left out; the import fuzzer and limit tests cover tables, paragraphs, effects, overlays, master pages, CMYK pixels and embedded documents. |
|
||||
| 2026-10-22 | minor | Recolor Artwork Edit chips scroll within the available screen height (#3416); small palettes retain their tab height; scores and estimates unchanged. |
|
||||
| 2026-10-22 | minor | Font pickers list recently applied families first (#3515), using the existing recent-font limit; search, filters, previews and keyboard selection share the same rows. Scores and estimates unchanged. |
|
||||
| 2026-10-21 | minor | Print: PDF/X-1a and PDF/X-3 flattening in a CMYK document rasterizes as ink amounts, so CMYK colours under an image with see-through pixels keep their numbers, K included, instead of going through RGB (#2160); own-fixture test; scores and hours unchanged (estimated) |
|
||||
|
||||
Reference in New Issue
Block a user