.ai save: artboards larger than the canvas are written on a large canvas

Artboards that together did not fit on the 16,383 pt canvas were
written with %AI24_LargeCanvasScale: 1, so part of them lay outside
the canvas. The editing data now declares a scale of 10 for them and
writes the art, boxes, artboards, strokes and effects at 1/10 of their
size, symbol and pattern art included.

Reading such a .ai, the editing data comes in at its declared scale
when it matches the page that way rather than in the units of the
page's UserUnit, also when the PDF part can't be read, and a file saved
without its PDF part applies the declared scale too.
This commit is contained in:
dotCooCoo
2026-11-03 16:02:28 -08:00
parent d2c5ea127a
commit f87ba9224c
9 changed files with 166 additions and 25 deletions
+53 -5
View File
@@ -12,7 +12,9 @@
//! document's author), `%%Canvassize`, `%%BoundingBox`,
//! `%%HiResBoundingBox`, `%AI5_FileFormat 14.0`, `%AI7_ImageSettings`, the first artboard as
//! `%AI3_Cropmarks` and `%AI3_TileBox`, the canvas's center as `%AI3_TemplateBox`,
//! `%AI5_ArtSize 14400 14400`, `%AI5_RulerUnits`, `%AI9_ColorModel` (1 RGB, 2 CMYK),
//! `%AI5_ArtSize 14400 14400`, `%AI5_RulerUnits`, `%AI24_LargeCanvasScale` (1, or 10 when the
//! artboards together don't fit on the canvas: the art, boxes and artboards are then written at
//! 1/10 of their size), `%AI9_ColorModel` (1 RGB, 2 CMYK),
//! `%AI5_ArtFlags`, `%AI5_TargetResolution`, `%AI5_NumLayers`, `%AI5_OpenViewLayers` (a digit for
//! each layer, 7 shown and 6 hidden), `%AI7_GridSettings` (§2.1.1), and the version comment
//! `%%AI8_CreatorVersion` ([`CREATOR_VERSION`]).
@@ -73,6 +75,9 @@ const ROUNDTRIP_STREAM_TYPE: u32 = 2;
const ZSTD_MARKER: &[u8] = b"%AI24_ZStandard_Data";
/// `%%Canvassize`: the side of the canvas in points, as the files users sent have it.
const CANVAS_SIZE: u32 = 16383;
/// The scale of a large canvas: its sides are ten times the canvas's, as the format's large canvas
/// documents have them (2270 inches square).
const LARGE_CANVAS_SCALE: f64 = 10.0;
/// `%AI5_ArtSize`, as the files users sent have it.
const ART_SIZE: u32 = 14400;
/// `%AI5_ArtFlags` (§2.1.1, Table 4), as the files users sent have them: page setup, print tiles,
@@ -120,6 +125,25 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditi
// Live geometry effects as their result, as the PDF part draws them.
let baked = baked(doc);
let doc = baked.as_ref().unwrap_or(doc);
// Artboards that don't fit on the canvas are written on a large canvas, at 1/scale.
let scale = canvas_scale(doc);
let reduced = (scale != 1.0).then(|| {
let mut d = doc.clone();
crate::import::scale_document(&mut d, 1.0 / scale, true);
// Symbol and pattern art is placed without scaling its strokes, so its strokes and effects
// are scaled here.
for s in d.symbols.iter_mut() {
scale_strokes(Arc::make_mut(&mut s.art), 1.0 / scale);
}
for p in d.patterns.iter_mut() {
for a in &mut p.art {
scale_strokes(Arc::make_mut(a), 1.0 / scale);
}
}
d
});
let doc = reduced.as_ref().unwrap_or(doc);
let board = doc.artboards.first().map_or(board, |a| a.rect);
// The document is y down; art space is ruler space: y up, its origin the first artboard's
// top-left corner.
let to_art = Affine::new([1.0, 0.0, 0.0, -1.0, -board.x0, board.y0]);
@@ -138,7 +162,7 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditi
.reduce(|a, b| a.union(b))
.unwrap_or(first);
let origin = ruler_origin(doc, to_art);
let header = header(doc, o, &w, art, first, origin);
let header = header(doc, o, &w, art, first, origin, scale);
let mut s = header.clone();
// No gradient definitions: gradients are written as their average color.
s.push_str("%%BeginProlog\n%%EndProlog\n%%BeginSetup\n0 Bn\n");
@@ -216,14 +240,38 @@ fn ruler_origin(doc: &Document, to_art: Affine) -> (f64, f64) {
((mid - c.0).floor(), (mid + c.1).floor())
}
/// `%AI24_LargeCanvasScale`: 1 when the artboards together fit on the canvas, with a point to
/// spare on each side for the rounding of [`ruler_origin`], else [`LARGE_CANVAS_SCALE`]: then the
/// art, boxes and artboards are written at 1/[`LARGE_CANVAS_SCALE`] of their size.
fn canvas_scale(doc: &Document) -> f64 {
let all = doc.artboards.iter().map(|ab| ab.rect).filter(|r| r.is_finite()).reduce(|a, b| a.union(b));
let room = f64::from(CANVAS_SIZE) - 2.0;
if all.is_some_and(|r| r.width() > room || r.height() > room) { LARGE_CANVAS_SCALE } else { 1.0 }
}
/// The strokes and effects of `n` and of what it holds, `k` times as large.
fn scale_strokes(n: &mut Node, k: f64) {
n.appearance.scale_strokes(k);
n.appearance.scale_effects(k, vectorcraft_effects::scale_effect);
if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
scale_strokes(Arc::make_mut(&mut mask.art), k);
}
if let Some(children) = n.children_mut() {
for c in children {
scale_strokes(Arc::make_mut(c), k);
}
}
}
/// The canvas's center, on its own axes and in both directions.
fn canvas_center() -> f64 {
f64::from(CANVAS_SIZE) / 2.0
}
/// The header comments, through `%%EndComments`. `art`: the visible art's bounds, `first` the first
/// artboard, in art space; `origin` art space's origin on the canvas ([`ruler_origin`]).
fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, first: Rect, origin: (f64, f64)) -> String {
/// artboard, in art space; `origin` art space's origin on the canvas ([`ruler_origin`]); `scale`
/// the large canvas scale ([`canvas_scale`]).
fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, first: Rect, origin: (f64, f64), scale: f64) -> String {
let mut s = String::new();
s.push_str(PS_HEADER);
s.push('\n');
@@ -267,7 +315,7 @@ fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, first: Re
s.push_str("%AI3_DocumentPreview: None\n");
let _ = writeln!(s, "%AI5_ArtSize: {ART_SIZE} {ART_SIZE}");
let _ = writeln!(s, "%AI5_RulerUnits: {}", ruler_units(doc.units));
s.push_str("%AI24_LargeCanvasScale: 1\n");
let _ = writeln!(s, "%AI24_LargeCanvasScale: {}", ps::num(scale));
let model = if doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { 2 } else { 1 };
let _ = writeln!(s, "%AI9_ColorModel: {model}");
let _ = writeln!(s, "%AI5_ArtFlags: {ART_FLAGS}");
+55
View File
@@ -412,6 +412,61 @@ fn artboards_of_different_sizes_keep_their_names_and_places() {
assert_eq!(bounds, Rect::new(50.0, 50.0, 590.0, 100.0));
}
/// An artboard larger than the canvas (20,000 points square): the data is written on a large
/// canvas, at 1/10 of the size, so the artboard lies on the canvas; read beside a page of the
/// artboard's size in points, it comes back at its size.
#[test]
fn an_artboard_larger_than_the_canvas_is_written_on_a_large_canvas() {
let mut doc = Document::new(20_000.0, 20_000.0);
add_rect(&mut doc, Rect::new(1000.0, 1000.0, 3000.0, 2000.0));
let text = program(&doc);
for line in [
"%AI24_LargeCanvasScale: 10\n",
"%AI3_Cropmarks: 0 -2000 2000 0\n",
"\n100 -100 m\n",
"%_0 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_2000 -2000 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_7191 7191 /RealPoint\n%_ (RulerOrigin) ,\n",
RECORDED,
] {
assert!(text.contains(line), "{line}\n{text}");
}
// A page of the artboard's size in points, as a PDF part in larger units gives it.
let mut page = Document::new(20_000.0, 20_000.0);
add_rect(&mut page, Rect::new(1000.0, 1000.0, 3000.0, 2000.0));
let (p, _) = private_data(&doc, &opts()).unwrap();
let (read, notes, layers) = crate::layered_ai(&p.data, page, vec![], false, true, 1.389);
assert!(layers, "{notes:?}");
assert_eq!(read.artboards.iter().map(|a| a.rect).collect::<Vec<_>>(), [Rect::new(0.0, 0.0, 20_000.0, 20_000.0)], "{notes:?}");
let bounds = read.layers.iter().filter_map(|l| l.visual_bounds()).reduce(|a, b| a.union(b)).unwrap();
assert!((bounds.x0 - 1000.0).abs() < 1e-6 && (bounds.y1 - 2000.0).abs() < 1e-6 && (bounds.width() - 2000.0).abs() < 1e-6, "{bounds:?}");
// The same without a readable PDF part, on its first page's size.
let (alone, notes) = crate::ai_alone_on_page(&p.data, (20_000.0, 20_000.0), 1.389).unwrap();
assert_eq!(alone.artboards.iter().map(|a| a.rect).collect::<Vec<_>>(), [Rect::new(0.0, 0.0, 20_000.0, 20_000.0)], "{notes:?}");
// And from the editing data alone, as a file saved without its PDF part opens.
let alone = read_back(&doc);
assert_eq!(alone.artboards.iter().map(|a| a.rect).collect::<Vec<_>>(), [Rect::new(0.0, 0.0, 20_000.0, 20_000.0)]);
let bounds = alone.layers.iter().filter_map(|l| l.visual_bounds()).reduce(|a, b| a.union(b)).unwrap();
assert!((bounds.x0 - 1000.0).abs() < 1e-6 && (bounds.width() - 2000.0).abs() < 1e-6, "{bounds:?}");
// A symbol's 30-point stroke is written at 1/10 too, as the layers' strokes are.
let mut doc = Document::new(20_000.0, 20_000.0);
let line = shapes::rectangle(Rect::new(0.0, 0.0, 500.0, 500.0));
let art = Node::path(doc.alloc_id(), line, Appearance::basic(Paint::None, solid(Color::gray(0.5)), 30.0));
doc.symbols.push(Symbol { dynamic: false, name: "Box".into(), art: Arc::new(art) });
let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Box".into(), xf: Affine::translate((1000.0, 1000.0)) });
let layer = doc.layers[0].id;
doc.insert(Some(layer), usize::MAX, instance).unwrap();
let (p, _) = private_data(&doc, &opts()).unwrap();
let (read, notes, layers) = crate::layered_ai(&p.data, Document::new(20_000.0, 20_000.0), vec![], false, true, 1.389);
assert!(layers, "{notes:?}");
let mut all = vec![];
paths(read.layers[0].children().unwrap(), &mut all);
let widths: Vec<f64> = all.iter().filter_map(|n| n.appearance.stroke().map(|s| s.width)).collect();
assert!(!widths.is_empty() && widths.iter().all(|w| (w - 30.0).abs() < 1e-6), "{widths:?}");
// Artboards that fit stay on the canvas at their size.
assert!(program(&Document::new(16_000.0, 16_000.0)).contains("%AI24_LargeCanvasScale: 1\n"));
}
#[test]
fn a_pdf_carries_the_data_on_its_first_page() {
let doc = sample();
+1
View File
@@ -70,6 +70,7 @@ use graphics::{GState, Out};
use interp::{Fault, Interp};
use obj::PsError;
pub(crate) use native::scale_document;
pub use native::{ai_alone, ai_alone_on_page, is_loss, layered_ai};
pub use text::family_style;
+5
View File
@@ -159,6 +159,8 @@ pub struct Structure {
pub envelopes_drawn: Vec<(NodeId, Node)>,
/// The document's graphic styles are those of the file's style list, in place of the defaults.
pub styles_read: bool,
/// The file's `%AI24_LargeCanvasScale` (1 when it declares none), applied or not.
pub canvas_scale: f64,
pub warnings: Vec<String>,
}
@@ -325,6 +327,8 @@ struct Reader<'a> {
/// (see [`Reader::ensure_space`]); 1 (no scaling) when the file doesn't declare one, `None`
/// when the declared factor is not applied ([`read`]).
large_canvas: Option<f64>,
/// The declared `%AI24_LargeCanvasScale` (1 without one), applied or not.
canvas_scale: f64,
/// `%AI5_RulerUnits` from the header (Illustrator 7 spec table 3, plus centimeters and pixels).
ruler_units: Option<u8>,
/// This reader reads a pattern's definition (see [`Reader::define_pattern`]).
@@ -435,6 +439,7 @@ impl<'a> Reader<'a> {
art_size: None,
template_center: None,
large_canvas: None,
canvas_scale: 1.0,
ruler_units: None,
pattern: None,
pattern_tiles: HashMap::new(),
+17 -9
View File
@@ -208,21 +208,29 @@ impl<'a> Reader<'a> {
self.template_center = two(v);
self.template = four(v).or(self.template);
} else if let Some(v) = line.strip_prefix("AI24_LargeCanvasScale:") {
// Illustrator's Large Canvas writes the editing data (art and boxes) at 1/N scale, N
// being this; the printed page applies the same factor, so the art reaches the page's
// size once it is applied to the art space ([`Self::ensure_space`]). A value outside
// the range Illustrator writes is left alone.
if let (Some(scale), Ok(n)) = (&mut self.large_canvas, v.trim().parse::<f64>())
&& (1.0..=100.0).contains(&n)
{
*scale = n;
}
self.large_canvas_scale(v);
} else if let Some(v) = line.strip_prefix("AI9_ColorModel:") {
self.color_model = Some(v.trim() == "2");
}
Ok(())
}
/// `%AI24_LargeCanvasScale:` with its value `v`.
fn large_canvas_scale(&mut self, v: &str) {
// Illustrator's Large Canvas writes the editing data (art and boxes) at 1/N scale, N
// being this; the printed page applies the same factor, so the art reaches the page's
// size once it is applied to the art space ([`Self::ensure_space`]). A value outside
// the range Illustrator writes is left alone.
if let Ok(n) = v.trim().parse::<f64>()
&& (1.0..=100.0).contains(&n)
{
self.canvas_scale = n;
if let Some(scale) = &mut self.large_canvas {
*scale = n;
}
}
}
/// `%AI3_Note:` (or `%%Note:`) sets the note of the paths that follow, an empty one clearing it,
/// and `%AI5_NoteMore:` adds a line to it. The text is kept as written, up to [`MAX_NOTE`]
/// characters.
@@ -723,6 +723,7 @@ impl<'a> Reader<'a> {
placed_elsewhere: self.placed_elsewhere,
envelopes_drawn: self.envelopes_drawn,
styles_read,
canvas_scale: self.canvas_scale,
warnings,
})
}
+1
View File
@@ -45,6 +45,7 @@ mod layers;
mod plan;
mod slots;
pub(crate) use build::scale_document;
#[cfg(test)]
use build::{Frame, MADE, prune_unseen};
#[cfg(test)]
+31 -10
View File
@@ -56,7 +56,8 @@ pub(super) fn build(
/// [`build`] without a page, for a `.ai` whose PDF part can't be read, its first page `page`
/// (width and height, either way round) points large in units of `unit` points: the document is
/// drawn `unit` times as large when its first artboard, or its art's box, has the page's size in
/// those units and not in points, as [`build_from`] decides with a page.
/// those units and not in points, as [`build_from`] decides with a page; else as many times as
/// large as its declared large canvas scale when it has the page's size at that scale.
pub(super) fn build_on_page(data: &[u8], page: (f64, f64), unit: f64) -> Result<(Document, Vec<String>), String> {
let structure = ai::read(data, false)?;
let board = structure.artboard.map(|a| structure.to_doc.transform_rect_bbox(a));
@@ -65,12 +66,12 @@ pub(super) fn build_on_page(data: &[u8], page: (f64, f64), unit: f64) -> Result<
let fits = |r: Option<Rect>, k: f64| {
r.is_some_and(|r| [(w, h), (h, w)].iter().any(|(a, b)| (r.width() - a / k).abs() <= 0.5 && (r.height() - b / k).abs() <= 0.5))
};
let in_units = |r: Option<Rect>| unit.is_finite() && unit > 1.0 && fits(r, unit) && !fits(r, 1.0);
let scale = in_units(board) || (!fits(board, 1.0) && in_units(art));
let in_units = |r: Option<Rect>, k: f64| k.is_finite() && k > 1.0 && fits(r, k) && !fits(r, 1.0);
let scale = [unit, structure.canvas_scale].into_iter().find(|&k| in_units(board, k) || (!fits(board, 1.0) && in_units(art, k)));
let (mut doc, notes) = build_from(structure, data, None, Page::Artboard, false, false, 1.0)?;
if scale {
if let Some(k) = scale {
// The graphic styles here are all the file's, drawn in its units too.
scale_document(&mut doc, unit, true);
scale_document(&mut doc, k, true);
}
Ok((doc, notes))
}
@@ -99,16 +100,19 @@ pub(super) fn build_from(
placed_elsewhere,
envelopes_drawn,
styles_read,
canvas_scale,
warnings,
} = structure;
// Editing data in the units of the page's user space ([`in_units`]) is compared with the page
// scaled down to those units, and the document read from it is scaled up by `unit`.
// Editing data in the units of the page's user space ([`in_units`]), or at its declared large
// canvas scale ([`page_scale`]), is compared with the page scaled down to those units, and the
// document read from it is scaled up by `unit`.
let board = artboard.map(|a| to_doc.transform_rect_bbox(a));
// A page cropped to the art (see below) is in those units as well, when the art's box is and
// the artboard isn't the page in points.
let art = bbox.map(|[a, b, c, d]| to_doc.transform_rect_bbox(Rect::new(a, b, c, d)));
let units = |v: &Document| in_units(board, v, unit) || (!same_size(board, v) && in_units(art, v, unit));
let small = visible.filter(|v| matches!(page, Page::Artboard) && units(v)).map(|v| {
let scaled = page_scale(board, art, visible.filter(|_| matches!(page, Page::Artboard)), unit, canvas_scale);
let unit = scaled.unwrap_or(unit);
let small = visible.filter(|_| scaled.is_some()).map(|v| {
let mut v = v.clone();
scale_document(&mut v, 1.0 / unit, false);
v
@@ -1001,6 +1005,23 @@ fn placed_files(nodes: &[Arc<Node>], out: &mut Vec<Rect>) {
}
}
/// Is the editing data in units of `unit` points beside `page` ([`in_units`])? Its first artboard
/// `board` is, or, when that isn't the page in points, its art's box `art` is (a page cropped to the
/// art).
fn in_units_or_art(board: Option<Rect>, art: Option<Rect>, page: &Document, unit: f64) -> bool {
in_units(board, page, unit) || (!same_size(board, page) && in_units(art, page, unit))
}
/// The size in points of a unit of the editing data whose first artboard is `board` and whose
/// art's box is `art` (the document's coordinates), read beside `page`: `unit` (the page's
/// `UserUnit`) when the data is in those units ([`in_units_or_art`]), else `canvas_scale` (its
/// declared large canvas scale) when the data is at that scale; `None` when it is in points or
/// there is no page.
fn page_scale(board: Option<Rect>, art: Option<Rect>, page: Option<&Document>, unit: f64, canvas_scale: f64) -> Option<f64> {
let page = page?;
[unit, canvas_scale].into_iter().find(|&k| in_units_or_art(board, art, page, k))
}
/// Does the editing data's first artboard `board` (in the document read from it: art space through
/// `to_doc`) have the size of `page`'s first artboard in units of `unit` points, `unit` being above
/// 1, and not its size in points? `page` is a PDF part read in points from pages of `unit` points a
@@ -1054,7 +1075,7 @@ fn drop_guides_off_boards(doc: &mut Document) {
/// Draw `doc`'s layers, artboards and guides `k` times as large, from the origin, with their
/// strokes, effects and pattern tiles, and with `styles` the strokes and effects of its graphic
/// styles.
fn scale_document(doc: &mut Document, k: f64, styles: bool) {
pub(crate) fn scale_document(doc: &mut Document, k: f64, styles: bool) {
let a = Affine::scale(k);
let scaling = Scaling { strokes: true, effects: Some(vectorcraft_effects::scale_effect), patterns: true, ..Default::default() };
for layer in &mut doc.layers {
+2 -1
View File
@@ -242,9 +242,10 @@ fn place_art(layer: &mut Node, pages: &[Rect], moves: &[Vec2]) {
/// A `.ai` saved without its PDF part (its pages show only a placeholder) from its editing copy
/// alone (`private`, the joined `AIPrivateData` streams) → the document and the import's notes.
/// Editing data written on a large canvas comes in at its declared scale: no page holds the size.
pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec<String>), String> {
let data = ai::decode_private(private)?;
build(&data, None, Page::Artboard, false, false, 1.0)
build_from(ai::read(&data, true)?, &data, None, Page::Artboard, false, false, 1.0)
}
/// [`ai_alone`] for a `.ai` whose PDF part can't be read, its first page `page` (width and height)