Files
vectorcraft/crates/eps/src/ai_editing.rs
T
dotCooCoo aed9898b8d .ai save: the editing copy keeps type as type
The editing copy of a PDF-compatible .ai wrote every text object as its
outlines. Point type, area type and type on a path are now written as
text: a text object in the layers that names its story, and the text
document after the document data (%AI11_BeginTextDocument) holding the
stories.

- Each story holds its characters (a paragraph ends in \r, a forced line
  break is \x03), its character runs (font, size, leading, tracking,
  baseline shift, horizontal and vertical scale, solid fill and stroke
  with its weight, capitals, Tate-chu-yoko) and its paragraph runs
  (alignment, indents, space before and after, hyphenation, right-to-left
  direction).
- Point type keeps its point and, when it is turned, its matrix. Type
  scaled by one factor, such as all type on a large canvas, has its scale
  in its sizes, frame and path. Area type keeps its frame, rows,
  columns, gutters and first baseline, type on a path its path and where
  it starts and ends, and vertical type its orientation. A threaded story
  is one story through its frames in order. Area type and type on a path
  carry their frame's path in the layers as well.
- Type the text document can't hold is written as its outlines, with a
  warning: type in opacity masks, pattern tiles and art cut to a clipping
  object, mirrored type, turned or unevenly scaled area type and type on
  a path, frames of several parts, area type that flows around a text wrap,
  threads whose frames differ in orientation or include a frame written
  as outlines, type and stories past the sizes the reader reads, and type
  with inline graphics, ruby, gradient or pattern paint or an appearance
  or opacity mask of its own. Other character and paragraph attributes are left out, with
  a warning.

Tracking is written as a whole number of thousandths of an em, and
solid paints as simple paints; tracking that isn't a whole number is
rounded, with the warning.

Each construct is written in the form VectorCraft's .ai reader reads
(crates/eps/src/import/ate.rs at 36ef34dfdd: Texts::read :407, story
:501, frame :456, para :562, style :588, paint :622, AreaFrame::read
:284; the text object in import/ai/read/art.rs:15 and its path in
import/ai/frames.rs:26), and the tests read the written data back
through that reader. The scripting
reference documents the same attributes on the reading side (text frame
kinds, columnCount, columnGutter, rowCount, startTValue, endTValue,
orientation, and the character attributes):
https://web.archive.org/web/20160410101607/https://wwwimages.adobe.com/content/dam/Adobe/en/devnet/illustrator/sdk/CC2014/Illustrator%20Scripting%20Reference%20-%20JavaScript.pdf

The reader now closes a text object's frame path painted with a closing
operator (n, f, s, b, h), so area type's frame comes back closed; places
point type whose story keeps no composed lines at its frame's point;
keeps a story's last empty paragraph (only the \r that ends the story is
dropped); gives type made from the text document its text object's lock,
as it gives it its visibility; and gives type its text object's knockout
shape flag, as it gives it its opacity and blend mode.

Tests: the round trip of every kind of type and its state, a second
write of what is read back giving the same bytes, the text document and
stubs, scaled point type, a story split between threaded frames, the
fallbacks and the warnings, and the engine's editing copy with every save
option; a fuzz case for damaged text documents and stubs.

Docs: docs/file-format-parity.md.
2026-11-07 05:15:15 +07:00

479 lines
27 KiB
Rust

//! The editing copy of the art that a PDF-compatible `.ai` file carries in its pages'
//! `PieceInfo` ([`vectorcraft_pdf::PrivateData`]): the document's layers, sublayers, groups, paths,
//! colors, swatches and artboards in the form [`crate::import`]'s `.ai` reader reads, so that apps
//! that open `.ai` files from that copy get them as editable objects.
//!
//! The form is the one the published specification documents (Adobe Illustrator File Format
//! Specification, version 7.0, see [`crate::legacy_ai`]) for paths (§5.3), colors (§5.4),
//! groups and compound paths (§5.3.5), guides (§6) and layers (§9), with the newer forms the reader
//! reads from files users sent:
//!
//! - Header comments: `%%Creator` (naming the format's app, see [`CREATOR_TEXT`]), `%%For` (the
//! 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`, `%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`]).
//! - Setup: the gradients the art uses as `n Bn` and a definition of each (§3.5: `%AI5_BeginGradient:
//! (name)`, `(name) type stops Bd`, `[`, each stop on a `%_` line as `color style opacity 6 midpoint
//! rampPoint Bs`, `BD`, `%AI5_EndGradient`), those only the swatches use in a non-printing block
//! (§14: `%AI5_Begin_NonPrinting`, `Np`, `%AI5_End_NonPrinting--`); the patterns the same way
//! (§3.4: `%AI3_BeginPattern: (name)`, `(name) llx lly urx ury`, the tile's art on `%_` lines, `E`,
//! `%AI3_EndPattern`); the swatches (`%AI5_BeginPalette`, `0 0 Pb`, the Registration swatch, each
//! swatch's color operator, gradient instance or pattern operator and `(name) Pc`, a color group as
//! `0 (name) count Pg` before its swatches, `PB`), then the artboards as the `ArtboardArray` of the
//! `/Document` dictionary's `/Recorded` part and the canvas size as its `AIDocumentCanvasSize`, on
//! `%_` lines.
//! - Layers: `%AI5_BeginLayer`, `visible preview unlocked printing dimmed 0 1 colorIndex r g b 0
//! dimPercent clipping Lb` (14 operands; a color that isn't one of the preset layer colors has the
//! index -1; `clipping` is 1 for a clipping layer), `(name) Ln`, the layer's name as an XML id in an
//! `/ArtDictionary`, its art and sublayers, `LB`, `%AI5_EndLayer--`.
//! - Colors: `g` gray, `k` CMYK, `c m y k r g b Xa` RGB (Lab is written as RGB); a spot color
//! `c m y k (name) tint x` (CMYK) or `c m y k r g b|L a b (name) tint 1|2 Xx` (RGB or Lab); a
//! global process color `… (name) tint model Xk`; Registration `1 1 1 1 ([Registration]) tint
//! Xs`; a pattern `(name) 0 0 1 1 0 0 0 0 0 [matrix] p` (`P`), the matrix taking the pattern's
//! space (its tile's lower left corner the origin) to art space. Upper case paints the stroke.
//! - Gradients (§3.5.3): around the painting operator of a path (of a compound path's last path),
//! `Bb` (`1 Bb` for the stroke), a radial gradient's focal point `x y angle fraction Bh`, then
//! `flag (name) x y angle length a b c d tx ty 1 Bg`, which places the gradient on the path: a
//! linear one with the identity matrix, `x` the start and `length` the length as fractions of the
//! path's extent along `angle`; a radial one with the matrix taking the unit circle onto its
//! extent ellipse, `x` and `y` its center as fractions of the path's bounds there and `length` its
//! radius as a multiple of the one the bounds give (`writer::paints::placement`). The imaging data
//! follows (a linear gradient's `a b c d tx ty Xm`, its caps `Bc` and a `Bm` for each pair of
//! stops; a radial one's `Bm`), then the operator and `0 BB` (`2 BB` or `1 BB` when the path is
//! stroked too; a fill's instance ends `2 BB` before a stroke's). In a pattern's tile the matrices
//! are in the canvas's space (y down, from art space's origin there).
//! - State before each object: locked `A`, hidden `Xw` (`1` or `0`). An object's name follows it as
//! an `/ArtDictionary` whose `AI10_ArtUID` is the name as an XML id. Guides are the path
//! followed by `(N) *` (`(n) *` when closed), without the `(N) *` that §6 also puts before it.
//! - Transparency: `mode opacity isolate knockout shape Xy` (the blending mode's number, Knockout
//! Group 0 off, 1 on, 2 neutral), written when it changes: in a path's paint after its colors,
//! before a guide, and after a group's end (`U` or `Q`) followed by `0 0 Xd` and `6 () XW`; a
//! group without transparency of its own ends `9 () XW`. A layer's comes after its art: `Xy`,
//! `0 0 Xd`, `7 () XW`, before `LB`.
//! - Compound paths: `*u`, the subpaths, `*U` (§5.3.5). Clipping groups: `q`, the art, the clipping
//! path last with `h` (or `H` when open) and `W` before its painting operator, `Q` (§5.6.2); a
//! clipping layer's clipping path is written the same way, last in the layer.
//! - Opacity masks: an `AI9_Mask` entry in the object's `/ArtDictionary`, a `/Mask` dictionary
//! holding the mask's art between `X=` and `X+` on `%_` lines, `(Art) ,`, and `Clipping`,
//! `Inverted`, `Disabled` and `Linked` as `/Bool` entries.
//! - Embedded images (§8): `%AI5_File:`, `%AI5_BeginRaster`, `() 1 XG`, the color space
//! (`/DeviceGray`, `/DeviceRGB` or `/DeviceCMYK`) with `XN`, `[matrix] w h 0 Xh`, `[matrix] 0 0
//! w h w h 8 type alpha flags 1 0 4 4 0 0` (type 1 gray, 3 RGB, 4 CMYK; alpha 1 with an alpha
//! channel; flags 0 gray, 1 RGB, 2 CMYK, 16 more with an alpha channel), `%%BeginData:` counting
//! `XI`, its line end and the color samples, `XI`, the samples in binary (8 bits, rows from the
//! top, CMYK images with their ink amounts) followed by the alpha channel, `%%EndData`, `XH`,
//! `%AI5_EndRaster`, `N`. The matrix (to ten decimals) is an art-space matrix applied to the
//! pixels with their rows running downwards. In a part on `%_` lines, `XI` and the samples are
//! not, and `%_%%EndData` ends them.
//! - Linked images (version 7 specification §16, with the newer sections): `%AI5_BeginPlace`,
//! `%%FileType: 1`, `[matrix] llx lly urx ury 4 4 0` and `(path)` with `` ` ``, `%%IncludeFile:`,
//! the folder relative to the document's between `%AI28_BeginPlacedRelativePath` and its end,
//! a preview of at most 256 pixels a side between `%AI26_BeginPlacedObjectPreview` and its end
//! (`w h rowBytes 32 17 `, then rows of premultiplied ARGB), each in versioned content, `~`,
//! `%AI5_EndPlace`. The box is the file's pixels at art space's origin on the canvas; the matrix
//! takes it into art space. In opacity masks and pattern tiles, linked images are embedded.
//! - Type: a text object `/AI11Text :` naming its story and frame (`/StoryIndex`, `/FrameIndex`),
//! for area type and type on a path with its frame's path (`/Art :` … `; /ConfiningPath ,`), and
//! the text document after the document data (`%AI11_BeginTextDocument`, see
//! `writer::texts`), in the form the reader's `ate.rs` reads: the characters, the fonts, sizes,
//! leading, tracking, baseline shift, scales, solid fills and strokes and capitals of each run,
//! the alignment, indents, spacing, hyphenation and direction of each paragraph, and the frame:
//! point type's point and matrix, area type's frame, rows, columns, gutters and first baseline,
//! type on a path's path and span, vertical type, and the frames a threaded story flows through.
//!
//! Lines end in CR LF. Strings are written in UTF-8. Art space is ruler space: y up, its origin at
//! the first artboard's top-left corner. On the canvas ([`CANVAS_SIZE`] points square, y down) the
//! artboards together are centered; each artboard's `RulerOrigin` is its top-left corner there, and
//! `%AI3_TemplateBox` holds the canvas's center in art space. What the form above can't hold is written as plain filled and stroked paths, each such
//! loss a warning: type the text document can't hold (in opacity masks, pattern tiles and art cut to
//! a clipping object, mirrored, turned or unevenly scaled area type and type on a path, inline graphics, ruby,
//! gradient or pattern paint, an appearance of its own) as its outlines and its inline graphics'
//! art, the character and paragraph attributes the text document doesn't hold, freeform gradients, gradients
//! along or across a stroke and gradients without stops, with more than 256 stops or without extent
//! as their average color
//! (gradients that dither or mix perceptually are written as classic ones, and gradient stops linked
//! to global process colors as their colors), pattern fills whose tiles aren't a grid, whose art reaches past
//! the tile or is painted with patterns as their tiles cut to the shape, clipping groups and clipping layers whose clipping object isn't a path,
//! and the clipping groups inside them, as their art cut to that object (its fill cut too, its
//! stroke not; an image in a clipping group of the region it is cut to), images that can't be
//! decoded or have more than 33,554,432 pixels as their outlines filled mid gray, images of 16 bits
//! a channel at 8 bits, linked images in opacity masks and pattern tiles as embedded images,
//! symbols and live objects (placed documents included) as their art (symbol art past
//! [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects in all left out), strokes a plain line can't draw
//! as their filled outlines, several fills or strokes as a group of a path for each, freeform
//! gradient swatches, gradient swatches of more than 256 stops, swatches of patterns written as
//! tiles and the placement of pattern swatches that move, turn or scale their pattern, the opacity and blending modes of single fills and strokes, opacity masks on
//! layers and opacity masks nested too deep, and live effects other than geometry effects left out.
//!
//! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with
//! the version numbers of the newest files users sent ([`private_data`]).
use std::fmt::Write;
use std::sync::Arc;
use vectorcraft_doc::{Document, Node, Unit};
use vectorcraft_geom::{Affine, Rect};
use vectorcraft_pdf::PrivateData;
use crate::ps;
mod writer;
use writer::Writer;
/// The version comment's value: the creator version of the newest files users sent.
pub const CREATOR_VERSION: &str = "30.7.0";
/// The `PieceInfo` numbers that go with Zstandard-compressed data: container 12, creator 30,
/// round trip 24, stream type 2.
const CONTAINER_VERSION: u32 = 12;
const CREATOR: u32 = 30;
const ROUNDTRIP_VERSION: u32 = 24;
const ROUNDTRIP_STREAM_TYPE: u32 = 2;
/// What starts the data streams of Zstandard-compressed data.
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,
/// placed images, preview patterns, split long paths, tile full pages, printer's default
/// screens, auto default screens, compatible gradient printing.
const ART_FLAGS: &str = "0 0 0 1 0 0 1 0 0";
/// `%AI5_TargetResolution` (§2.1.1).
const TARGET_RESOLUTION: u32 = 800;
/// The gridlines' gray: VectorCraft's default grid color (200 of 255).
const GRID_GRAY: f64 = 200.0 / 255.0;
/// `%%Creator`: the creator line VectorCraft writes in the header of the editing data. It names
/// VectorCraft and the format's app, and holds no version number; the version is written on its
/// own in `%%AI8_CreatorVersion` ([`CREATOR_VERSION`]).
const CREATOR_TEXT: &str = "VectorCraft, editing data for Illustrator"; // brand-ok: the creator line VectorCraft writes
/// The first line of a PostScript file (the DSC version comment).
const PS_HEADER: &str = "%!PS-Adobe-3.0"; // brand-ok: the PostScript header comment
/// What to write in the header.
#[derive(Clone, Debug, Default)]
pub struct AiEditingOptions {
/// `%%Title`.
pub title: String,
/// `%%CreationDate`, Unix seconds.
pub created: Option<i64>,
}
/// The editing data and what it couldn't hold.
#[derive(Clone, Debug, Default)]
pub struct AiEditingOutput {
/// The program: header comments, setup, layers, trailer.
pub program: Vec<u8>,
/// Its header comments, up to `%%EndComments`.
pub header: Vec<u8>,
pub warnings: Vec<String>,
}
/// Write `doc` as the editing data of a `.ai` file: every artboard and layer.
pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditingOutput, String> {
let board = doc.artboards.first().ok_or("the document has no artboard")?.rect;
if !(board.is_finite() && board.width() > 0.0 && board.height() > 0.0) {
return Err("the document has no artboard".into());
}
// 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]);
let origin = ruler_origin(doc, to_art);
let mut w = Writer::new(doc, to_art, origin);
w.reserve_swatch_gradients();
for layer in &doc.layers {
w.layer(layer);
}
// The palette first: its gradient and pattern swatches add definitions.
let mut palette = String::new();
w.palette(&mut palette);
let first = to_art.transform_rect_bbox(board);
let art = doc
.layers
.iter()
.filter(|l| l.visible)
.filter_map(|l| l.visual_bounds())
.map(|b| to_art.transform_rect_bbox(b))
.filter(|b| b.is_finite())
.reduce(|a, b| a.union(b))
.unwrap_or(first);
let header = header(doc, o, &w, art, first, origin, scale);
let mut s = header.clone();
s.push_str("%%BeginProlog\n%%EndProlog\n%%BeginSetup\n");
w.definitions(&mut s);
s.push_str(&palette);
artboards(&mut s, doc, to_art, origin);
if !w.texts.is_empty() {
s.push_str(&w.texts.section(doc));
}
s.push_str("%%EndSetup\n");
s.push_str(&w.body);
s.push_str("%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n");
// Lines end in CR LF, as in the files users sent; line breaks inside strings are escaped.
let program = splice(&crlf(&s), &w.payloads);
Ok(AiEditingOutput { program, header: crlf(&header), warnings: w.warnings })
}
/// `s` with each line ending in CR LF.
fn crlf(s: &str) -> Vec<u8> {
s.replace('\n', "\r\n").into_bytes()
}
/// `text` with each payload's mark (`\u{1}`, its index in `payloads`, `\u{1}`) replaced by its
/// bytes. Strings are written with that byte escaped, so a mark is never part of one.
fn splice(text: &[u8], payloads: &[Vec<u8>]) -> Vec<u8> {
let mark = writer::PAYLOAD as u8;
if payloads.is_empty() {
return text.to_vec();
}
let mut out = Vec::with_capacity(text.len() + payloads.iter().map(Vec::len).sum::<usize>());
let mut parts = text.split(|b| *b == mark);
if let Some(first) = parts.next() {
out.extend_from_slice(first);
}
// The parts alternate: an index, then the text up to the next mark.
while let (Some(index), Some(rest)) = (parts.next(), parts.next()) {
let payload = std::str::from_utf8(index).ok().and_then(|i| i.parse::<usize>().ok()).and_then(|i| payloads.get(i));
if let Some(p) = payload {
out.extend_from_slice(p);
}
out.extend_from_slice(rest);
}
out
}
/// `doc` with its geometry effects and group paint baked ([`vectorcraft_effects::bake_document`]),
/// in its symbols' art and its patterns' tile art too (which instances and pattern fills are
/// written as); `None` when nothing changes.
fn baked(doc: &Document) -> Option<Document> {
let baked = vectorcraft_effects::bake_document(doc);
let d = baked.as_ref().unwrap_or(doc);
let needs = vectorcraft_effects::needs_bake;
if !d.symbols.iter().any(|s| needs(&s.art)) && !d.patterns.iter().any(|p| p.art.iter().any(|a| needs(a))) {
return baked;
}
// The symbols' art, then each pattern's art, baked as the layers of a copy of the document.
let mut art = d.clone();
let symbols = d.symbols.iter().map(|s| Arc::new(Node::group(s.art.id, vec![s.art.clone()])));
let patterns = d.patterns.iter().map(|p| Arc::new(Node::group(vectorcraft_doc::NodeId(0), p.art.clone())));
art.layers = symbols.chain(patterns).collect();
// The copy keeps what baking adds to the document (the images color adjustments make).
let Some(mut out) = vectorcraft_effects::bake_document(&art) else { return baked };
let layers = std::mem::replace(&mut out.layers, d.layers.clone());
let mut members = layers.iter().map(|l| l.children().cloned().unwrap_or_default());
for s in out.symbols.iter_mut() {
if let Some(a) = members.next().and_then(|m| m.first().cloned()) {
s.art = a;
}
}
for p in out.patterns.iter_mut() {
if let Some(m) = members.next() {
p.art = m;
}
}
Some(out)
}
/// The editing data of `doc` as a `.ai` file's pages carry it: the program compressed with
/// Zstandard after `%AI24_ZStandard_Data`, its header as the `AIMetaData`, and the version numbers
/// that go with that form; and what it couldn't hold.
pub fn private_data(doc: &Document, o: &AiEditingOptions) -> Result<(PrivateData, Vec<String>), String> {
let out = export_ai_editing(doc, o)?;
let mut data = ZSTD_MARKER.to_vec();
data.extend(ruzstd::encoding::compress_to_vec(out.program.as_slice(), ruzstd::encoding::CompressionLevel::Fastest));
let p = PrivateData {
data,
metadata: out.header,
container_version: CONTAINER_VERSION,
creator_version: CREATOR,
roundtrip_version: ROUNDTRIP_VERSION,
roundtrip_stream_type: ROUNDTRIP_STREAM_TYPE,
};
Ok((p, out.warnings))
}
/// Where art space's origin lies on the canvas (y down, [`CANVAS_SIZE`] points square): the
/// artboards together centered on the canvas's center, on whole points, as the files users sent
/// place them.
fn ruler_origin(doc: &Document, to_art: Affine) -> (f64, f64) {
let all = doc.artboards.iter().map(|ab| to_art.transform_rect_bbox(ab.rect)).filter(|r| r.is_finite()).reduce(|a, b| a.union(b));
let c = all.map_or((0.0, 0.0), |r| (r.center().x, r.center().y));
let mid = canvas_center();
((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`]); `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');
let _ = writeln!(s, "%%Creator: {CREATOR_TEXT}");
let _ = writeln!(s, "%%AI8_CreatorVersion: {CREATOR_VERSION}");
// The user and the organization (§2.1.1).
let _ = writeln!(s, "%%For: {} ()", ps::bytes(doc.metadata.author.as_bytes()));
let _ = writeln!(s, "%%Title: {}", ps::bytes(o.title.as_bytes()));
if let Some(t) = o.created {
let [y, mo, d, h, mi, _] = vectorcraft_doc::metadata::civil(t);
let _ = writeln!(s, "%%CreationDate: ({mo}/{d}/{y:04}) ({h:02}:{mi:02})");
}
let _ = writeln!(s, "%%Canvassize: {CANVAS_SIZE}");
let _ = writeln!(s, "%%BoundingBox: {} {} {} {}", art.x0.floor() as i64, art.y0.floor() as i64, art.x1.ceil() as i64, art.y1.ceil() as i64);
s.push_str("%%HiResBoundingBox: ");
ps::push_nums(&mut s, &[art.x0, art.y0, art.x1, art.y1]);
s.pop();
s.push('\n');
s.push_str("%%DocumentProcessColors: Cyan Magenta Yellow Black\n");
s.push_str("%AI5_FileFormat 14.0\n%AI3_ColorUsage: Color\n");
// Linked files aren't included (their previews are).
s.push_str("%AI7_ImageSettings: 0\n");
let spots = w.spot_swatches();
for (i, (name, _)) in spots.iter().enumerate() {
let _ = writeln!(s, "{} {}", if i == 0 { "%%DocumentCustomColors:" } else { "%%+" }, ps::bytes(name.as_bytes()));
}
for (i, (name, cmyk)) in spots.iter().enumerate() {
s.push_str(if i == 0 { "%%CMYKCustomColor: " } else { "%%+ " });
ps::push_nums(&mut s, &cmyk.map(f64::from));
let _ = writeln!(s, "{}", ps::bytes(name.as_bytes()));
}
// The first artboard, left, bottom, right, top.
let mut board = String::new();
ps::push_nums(&mut board, &[first.x0, first.y0, first.x1, first.y1]);
board.pop();
let _ = writeln!(s, "%AI3_Cropmarks: {board}");
// The canvas's center in art space.
let (cx, cy) = (ps::num(canvas_center() - origin.0), ps::num(origin.1 - canvas_center()));
let _ = writeln!(s, "%AI3_TemplateBox: {cx} {cy} {cx} {cy}");
let _ = writeln!(s, "%AI3_TileBox: {board}");
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));
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}");
let _ = writeln!(s, "%AI5_TargetResolution: {TARGET_RESOLUTION}");
let _ = writeln!(s, "%AI5_NumLayers: {}", doc.layers.len());
// A digit for each layer: 7 shown, 6 hidden.
let views: String = doc.layers.iter().map(|l| if l.visible { '7' } else { '6' }).collect();
if !views.is_empty() {
let _ = writeln!(s, "%AI5_OpenViewLayers: {views}");
}
// Gridlines and subdivisions (§2.1.1, Table 6): in back, lines, in VectorCraft's grid gray.
let (space, subdiv) = (ps::num(doc.grid.spacing), doc.grid.subdivisions);
let gray = ps::num(GRID_GRAY);
let _ = writeln!(s, "%AI7_GridSettings: {space} {subdiv} {space} {subdiv} 1 0 {gray} {gray} {gray} {gray} {gray} {gray}");
s.push_str("%%EndComments\n");
s
}
/// `%AI5_RulerUnits` of `u` (as the reader reads it; units it has no number for are points).
fn ruler_units(u: Unit) -> u8 {
match u {
Unit::Inches => 0,
Unit::Millimeters => 1,
Unit::Picas => 3,
Unit::Centimeters => 4,
Unit::Pixels => 6,
Unit::Points | Unit::FeetInches | Unit::Meters | Unit::Yards | Unit::Feet | Unit::Q | Unit::Ha => 2,
}
}
/// The artboards as the `ArtboardArray` of the `/Document` dictionary: each one's top-left and
/// bottom-right corners in art space (`PositionPoint1`, `PositionPoint2`), its top-left corner on
/// the canvas (`RulerOrigin`, `origin` being art space's origin there), its pixel aspect ratio
/// (`PAR`), `DisplayMark` 0 (as in the files users sent), whether it is the selected one (the
/// active one) and its name; then the index of the active artboard (`CropAreaActive`: the one
/// [`Document::opened_artboard`] names, else the first) and the canvas's size. Every record holds
/// all of these entries. The artboards, the active artboard and the canvas size are written in the
/// `/Recorded` part of the `/Document` dictionary, followed by an empty `/NotRecorded` part.
fn artboards(s: &mut String, doc: &Document, to_art: Affine, origin: (f64, f64)) {
let active = doc.opened_artboard.and_then(|id| doc.artboards.iter().position(|a| a.id == id)).unwrap_or(0);
s.push_str("%AI9_BeginDocumentData\n%_/Document :\n%_/Dictionary :\n%_/Array :\n");
for (i, ab) in doc.artboards.iter().enumerate() {
let r = to_art.transform_rect_bbox(ab.rect);
s.push_str("%_/Dictionary :\n%_");
ps::push_nums(s, &[r.x0, r.y1]);
s.push_str("/RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_");
ps::push_nums(s, &[r.x1, r.y0]);
s.push_str("/RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_");
ps::push_nums(s, &[origin.0 + r.x0, origin.1 - r.y1]);
s.push_str("/RealPoint\n%_ (RulerOrigin) ,\n%_");
let par = ab.video_ruler_pixel_aspect_ratio;
ps::push_num(s, if par.is_finite() && par > 0.0 { par } else { 1.0 });
// Display marks: 1 the center mark, 2 cross hairs, 4 video safe areas.
let mark = u8::from(ab.show_center_mark) | u8::from(ab.show_cross_hairs) << 1 | u8::from(ab.show_video_safe_areas) << 2;
let _ = writeln!(s, " /Real (PAR) ,\n%_{mark} /Int (DisplayMark) ,\n%_{} /Bool (IsArtboardSelected) ,", u8::from(i == active));
let _ = writeln!(s, "%_{} /UnicodeString (Name) ,\n%_; ,", ps::bytes(ab.name.as_bytes()));
}
s.push_str("%_; (ArtboardArray) ,\n");
let _ = writeln!(s, "%_{active} /Int (CropAreaActive) ,");
let _ = writeln!(s, "%_{CANVAS_SIZE} /Int (AIDocumentCanvasSize) ,");
s.push_str("%_; /Recorded ,\n%_/Dictionary : /NotRecorded ,\n%_; /NotRecorded ,\n%_;\n%AI9_EndDocumentData\n");
}
#[cfg(test)]
mod tests;