mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 21:27:00 -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
@@ -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))])),
|
||||
],
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user