Part of #868: legacy .ai runs as a PostScript program, with a prolog of its own

The file had an empty prolog, so a PostScript interpreter stopped at its first operator: it
couldn't be printed, placed or previewed by anything that runs PostScript. The prolog's
procedure set (VectorCraft_Legacy, written from the PostScript Language Reference and the
version 7 specification) now gives every operator the writer emits its meaning: paths and their
painting with separate fill and stroke colours and overprints, the fill rule, compound paths
painted as one path, clipping groups and masks, guides that don't print, layers that are hidden
or don't print marking nothing, and images drawn from the hex samples on the comment lines after
XI. Gradients fill with the colour set before them (the first stop's), and the type and encoding
operators take their operands and mark nothing.

VectorCraft opens a file with that prolog from its program, as before: its page has flat
gradients and no type.

Every file the writer's tests write is now run through import_with(bytes, false) and must draw
what the document it was written from draws: its paths, fills, strokes and images in the same
places and colours, with type left out (type the writer outlines is compared as its glyphs'
outlines), gradients as their first stop, layers that don't print left out and hidden objects
shown when the options write them visible. A new test prints the sample with every layer shown.
The engine's flattened-image test also opens the file as PostScript.
This commit is contained in:
Dan Green
2026-10-16 01:36:25 +07:00
committed by bflatastic
parent 06221726fb
commit 7844f46c65
5 changed files with 239 additions and 16 deletions
+10
View File
@@ -250,6 +250,16 @@ fn a_transparent_image_is_flattened_with_the_art_under_it_which_stays() {
}
assert_eq!(colour_at(d, 20.0, 30.0), [255, 0, 0], "the square outside the image");
assert_eq!(colour_at(d, 80.0, 30.0), [255, 255, 255], "nothing beyond the image");
// Run as a PostScript program (the file's prolog draws it), it prints the same: two images
// and the square, on a page that is the art's box (10 pt in from the artboard's top left).
s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"], "editingData": false})).unwrap();
let page = &s.doc().unwrap().doc;
assert_eq!(images(page).0, 2);
let printed = [colour_at(page, 30.0, 20.0), colour_at(page, 50.0, 20.0)];
for (a, b) in before.iter().zip(&printed) {
assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(*b) <= 3), "{before:?} {printed:?}");
}
assert_eq!(colour_at(page, 10.0, 20.0), [255, 0, 0], "the square outside the image");
}
#[test]
+18 -1
View File
@@ -18,7 +18,9 @@
//! `%%Creator`) and these operators: Adobe Illustrator File Format Specification, version 7.0
//! (1998), listed by PRONOM at https://www.nationalarchives.gov.uk/PRONOM/fmt/423. A file in that
//! format whose program has its layers (`%AI5_BeginLayer`) comes in through them, as `native` reads
//! an Illustrator EPS's editing copy (#1027).
//! an Illustrator EPS's editing copy (#1027). One VectorCraft wrote (its prolog the procedure set
//! [`crate::legacy_ai::PROCSET`], which prints its gradients flat and leaves its type out) comes in
//! from those layers alone, without its page.
//!
//! The page is the file's `%%BoundingBox` (`%%HiResBoundingBox` when it has one), the first page
//! of a PostScript file without one. A file whose program can't be read (an operator the
@@ -92,6 +94,9 @@ struct Dsc {
/// In the legacy Illustrator format: its header has `%AI…` comments or names the app as the
/// creator (see the module docs for the specification).
illustrator: bool,
/// Its prolog is the one VectorCraft's legacy `.ai` writer includes
/// ([`crate::legacy_ai::PROCSET`] in `%%DocumentSuppliedResources`).
own_legacy: bool,
}
/// The four numbers of a bounding box comment's value, when it has them and they make a box.
@@ -126,6 +131,9 @@ impl Dsc {
if header && (line.starts_with("%AI") || line.starts_with("%%AI") || creator) {
d.illustrator = true;
}
if header && value("%%DocumentSuppliedResources:").is_some_and(|v| v.strip_prefix("procset ") == Some(crate::legacy_ai::PROCSET)) {
d.own_legacy = true;
}
if let Some(v) = value("%%HiResBoundingBox:") {
if header || atend {
hires = box_of(v).or(hires);
@@ -185,6 +193,15 @@ pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String>
return Err("this is not a PostScript file".into());
}
let dsc = Dsc::read(ps);
// A legacy `.ai` VectorCraft wrote: its prolog prints the art plainly (gradients flat, no
// type), and its program has all of it, which is read as the format (as before the file
// printed).
if editing_data
&& dsc.own_legacy
&& let Some(Ok((document, warnings))) = native::program_alone(ps)
{
return Ok(Imported { document, warnings, preview: false });
}
let [llx, lly, urx, ury] = dsc.bbox.unwrap_or(LETTER);
let frame = Rect::new(0.0, 0.0, urx - llx, ury - lly);
let mut doc = Document::new(frame.width(), frame.height());
+83 -10
View File
@@ -51,10 +51,9 @@
//! Windows-1252, vertical type, inline graphics, gradient or pattern fills) is written as its
//! outlines, and all type with [`LegacyAiOptions::outline_text`].
//!
//! The prolog defines the one procedure the script calls that isn't an operator of the format,
//! the page trailer's `annotatepage` ([`PROLOG`]); the format's operators are left to the app that
//! reads the file, so PostScript printers can't print it, as the `.ai` files other apps write in
//! this format.
//! The prolog is a procedure set of the writer's own ([`PROLOG`]) that gives the operators their
//! meaning in PostScript, so the file also prints and previews as a PostScript program (its
//! gradients flat, without its type, which apps that read the format draw).
use std::fmt::Write;
use std::sync::Arc;
@@ -198,7 +197,7 @@ pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result<LegacyAiO
let _ = writeln!(s, "%AI5_ArtSize: {} {}", ps::num(width), ps::num(height));
s.push_str("%AI5_RulerUnits: 2\n");
let _ = writeln!(s, "%AI5_NumLayers: {layers}");
s.push_str("%%DocumentSuppliedResources: procset VectorCraft_Legacy 1 0\n");
let _ = writeln!(s, "%%DocumentSuppliedResources: procset {PROCSET}");
s.push_str("%%EndComments\n%%BeginProlog\n");
s.push_str(PROLOG);
s.push_str("%%EndProlog\n%%BeginSetup\n");
@@ -242,15 +241,89 @@ fn fonts(s: &mut String, fonts: &[(String, bool)]) {
}
}
/// The prolog's procedure set, as `%%DocumentSuppliedResources` names it: a file that has it is
/// read from its program when its layers are asked for ([`crate::import_with`]).
pub(crate) const PROCSET: &str = "VectorCraft_Legacy 1 0";
/// The prolog: a procedure set of this writer's own (`VectorCraft_Legacy 1 0`), in a dictionary
/// (`VCLegacy`) the setup begins and the trailer ends (§1.2: the prolog holds definitions only,
/// the setup activates them, the trailer deactivates them). The page trailer of the
/// specification's script (§3, Script Setup: `gsave annotatepage grestore showpage`) calls
/// `annotatepage`, which marks nothing here.
/// the setup activates them, the trailer deactivates them). It gives each operator the script
/// uses its meaning in PostScript, so that the file prints and previews as a PostScript program,
/// written from the PostScript Language Reference and this format's specification (no other
/// application's procedure set): paths and their painting with the fill and stroke colours,
/// compound paths painted as one path, clipping groups, guides that don't print, layers that are
/// hidden or don't print marking nothing, and images drawn from the samples on the comment lines
/// that follow `XI`. Gradients fill with the colour set before them (the writer sets the first
/// stop's), and type marks nothing: apps that read the format draw both. The page trailer's
/// `annotatepage` (§3, Script Setup: `gsave annotatepage grestore showpage`) marks nothing.
const PROLOG: &str = "%%BeginResource: procset VectorCraft_Legacy 1 0
/VCLegacy 8 dict def
/VCLegacy 96 dict def
VCLegacy begin
/annotatepage {} bind def
/bd {bind def} bind def
% State: the fill and stroke colours (procedures that set them), their overprints, the fill rule,
% a clip to make at the next painting operator, a compound path being built, a guide being
% drawn, whether the layer prints, and what the compound path is painted with.
/_fc {0 setgray} def /_sc {0 setgray} def
/_fo false def /_so false def /_eo false def /_w false def /_cp false def /_g false def
/_on true def /_rf false def /_rs false def
% Painting: a fill and a stroke that keep the path; the path painted as _rf and _rs say (after
% the clip W asked for), then ended.
/_fill {gsave _fc _fo setoverprint _eo {eofill} {fill} ifelse grestore} bd
/_stroke {gsave _sc _so setoverprint stroke grestore} bd
/_draw {_w {_eo {eoclip} {clip} ifelse /_w false def} if
_on _g not and {_rf {_fill} if _rs {_stroke} if} if newpath} bd
% close? fill? stroke? _r: paints now, or at the end of the compound path being built.
/_r {/_rs exch def /_rf exch def {closepath} if _cp not {_draw} if} bd
% Path construction (section 5.3.2) and painting (5.3.3).
/m {moveto} bd /l {lineto} bd /L {lineto} bd /c {curveto} bd /C {curveto} bd
/N {false false false _r} bd /n {true false false _r} bd
/F {false true false _r} bd /f {true true false _r} bd
/S {false false true _r} bd /s {true false true _r} bd
/B {false true true _r} bd /b {true true true _r} bd
/XR {1 eq /_eo exch def} bd
% Compound paths (5.3.5): one path, painted at its end.
/*u {/_cp true def} bd /*U {/_cp false def _draw} bd
% Groups, clipping groups and masks (5.6), locking (5.1), guides (6.1).
/u {} bd /U {} bd /q {gsave} bd /Q {grestore} bd
/H {} bd /h {closepath} bd /W {/_w true def} bd
/A {pop} bd
/* {pop _g {newpath /_g false def} {/_g true def} ifelse} bd
% Colours (5.4): fill and stroke colours, overprints; a custom colour as its CMYK equivalent at
% its tint (tint 0 is the full colour).
/g {/setgray load 2 array astore cvx /_fc exch def} bd
/G {/setgray load 2 array astore cvx /_sc exch def} bd
/k {/setcmykcolor load 5 array astore cvx /_fc exch def} bd
/K {/setcmykcolor load 5 array astore cvx /_sc exch def} bd
/Xa {/setrgbcolor load 4 array astore cvx /_fc exch def} bd
/XA {/setrgbcolor load 4 array astore cvx /_sc exch def} bd
/_tint {1 exch sub /_t exch def pop 4 {_t mul 4 1 roll} repeat} bd
/x {_tint k} bd /X {_tint K} bd
/O {1 eq /_fo exch def} bd /R {1 eq /_so exch def} bd
/w {setlinewidth} bd /J {setlinecap} bd /j {setlinejoin} bd /M {setmiterlimit} bd /d {setdash} bd
% Gradients (3.5): definitions are skipped; an instance fills with the colour set before it
% (the writer sets its first stop's), and BB strokes the path as its flag says.
/Bn {pop} bd /Bd {pop pop pop} bd /BD {cleartomark} bd
/Bb {gsave} bd /Bg {12 {pop} repeat} bd /Bh {pop pop pop pop} bd
/BB {grestore _cp {0 gt {/_rs true def} if}
{dup 2 eq {closepath} if 0 gt _on and {_stroke} if newpath} ifelse} bd
% Layers (9): a layer that is hidden or doesn't print marks nothing.
/Lb {6 {pop} repeat exch pop exch pop 1 eq exch 1 eq and /_on exch def} bd
/Ln {pop} bd /LB {/_on true def} bd
% Images (8): the samples, in hex on the comment lines that follow XI, read a line at a time.
/_l 256 string def /_s 128 string def
/_row {{currentfile _l readline not {exit} if dup length 1 gt {exit} if pop} loop
dup length 1 gt {dup length 1 sub 1 exch getinterval /ASCIIHexDecode filter _s readstring pop} {pop ()} ifelse} bd
/XI {pop pop pop pop /_ic exch def /_ib exch def /_ih exch def /_iw exch def pop pop pop pop
aload pop neg 6 array astore [1 0 0 -1 0 0] matrix concatmatrix
_on {gsave concat _iw _ih _ib [1 0 0 1 0 0] /_row load false _ic 1 eq {1} {_ic} ifelse colorimage grestore} {pop} ifelse} bd
% Type (15) is set by the app that reads the file: its operators take their operands and mark
% nothing; encodings (3.3) are skipped.
/TE {cleartomark} bd /TZ {cleartomark} bd
/To {pop} bd /Tp {7 {pop} repeat} bd /TP {} bd /TO {} bd
/Tr {pop} bd /Tf {pop pop pop pop} bd /Ts {pop} bd /Tz {pop pop} bd /Tt {pop} bd /TA {pop} bd
/Tl {pop pop} bd /Ta {pop} bd /Ti {pop pop pop} bd /Tx {pop} bd
% The page trailer (3): annotatepage marks nothing.
/annotatepage {} bd
end
%%EndResource
";
+122 -4
View File
@@ -76,8 +76,104 @@ fn write(doc: &Document) -> LegacyAiOutput {
write_with(doc, LegacyAiOptions::default())
}
/// `doc` written with options `o`. Every file the tests write is also run as a PostScript program
/// ([`prints_as`]) and must draw what `doc` draws.
fn write_with(doc: &Document, o: LegacyAiOptions) -> LegacyAiOutput {
export_legacy_ai(doc, &LegacyAiOptions { title: "Sample".into(), created: Some(0), ..o }).unwrap()
let o = LegacyAiOptions { title: "Sample".into(), created: Some(0), ..o };
let out = export_legacy_ai(doc, &o).unwrap();
prints_as(&out.bytes, doc, &o);
out
}
/// `doc` as the prolog prints the file written from it with options `o`: without its layers that
/// don't print, its type as its glyphs' outlines where the writer writes them (all type with
/// [`LegacyAiOptions::outline_text`], else the type 8-bit text can't hold) and left out where it
/// writes text objects, its
/// hidden objects shown when `o` writes them visible, and its gradients filled with their first
/// stop's colour.
fn as_printed(doc: &Document, o: &LegacyAiOptions) -> Document {
// A run's glyphs, laid out, as one path with the run's fill (the renderer draws no type).
fn outlined(t: &vectorcraft_doc::TextObject) -> Node {
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let runs = t.runs.iter().enumerate().map(|(i, run)| {
let mut bp = kurbo::BezPath::new();
for g in layout.glyphs.iter().filter(|g| g.run == i) {
bp.extend(g.outline.iter());
}
let path = PathData::from_bezpath(&bp).transformed(t.xf);
Arc::new(Node::path(vectorcraft_doc::NodeId(0), path, Appearance::basic(run.style.fill.clone(), Paint::None, 0.0)))
});
Node::group(vectorcraft_doc::NodeId(0), runs.collect())
}
fn plain(nodes: &mut Vec<Arc<Node>>, o: &LegacyAiOptions) {
nodes.retain(|n| match &n.kind {
NodeKind::Text(t) => o.outline_text || !writable(t),
NodeKind::Layer { printable, .. } => *printable,
_ => true,
});
for n in nodes {
if let NodeKind::Text(t) = &n.kind {
*n = Arc::new(outlined(t));
}
let n = Arc::make_mut(n);
if o.hidden == HiddenObjects::Visible && !matches!(n.kind, NodeKind::Layer { .. }) {
n.visible = true;
}
for item in &mut n.appearance.items {
if let AppearanceItem::Fill(f) = item
&& let Paint::Gradient(g) = &f.paint
{
f.paint = g.gradient.stops.first().map_or(Paint::None, |s| Paint::solid(s.color));
}
}
if let Some(c) = n.children_mut() {
plain(c, o);
}
}
}
let mut d = doc.clone();
plain(&mut d.layers, o);
d
}
/// The legacy `.ai` `bytes` run as a PostScript program (VectorCraft's PostScript reader, with the
/// file's prolog) draws as `doc`, written with options `o` (its first artboard the file's), prints
/// ([`as_printed`]): the same art in the same places, in the same colours.
fn prints_as(bytes: &[u8], doc: &Document, o: &LegacyAiOptions) {
let text = String::from_utf8_lossy(bytes);
let nums = |key: &str| -> Vec<f64> {
let line = text.lines().find_map(|l| l.strip_prefix(key)).unwrap_or_else(|| panic!("no {key}"));
line.split_whitespace().map(|v| v.parse().unwrap()).collect()
};
// The art's box (the page the program draws on) and the artboard's top, in art space (y up).
let [llx, lly, urx, ury] = nums("%%HiResBoundingBox:")[..] else { panic!() };
let top = nums("%AI3_TileBox:")[3];
let board = doc.artboards[0].rect;
let there = Rect::new(board.x0 + llx, board.y0 + top - ury, board.x0 + urx, board.y0 + top - lly);
let scale = 300.0 / (urx - llx).max(ury - lly).max(1.0);
let want = vectorcraft_render::Renderer::new().render_region(&as_printed(doc, o), there, scale, true);
let drew = match crate::import_with(bytes, false) {
Ok(page) => {
assert!(!page.preview, "read as its program");
vectorcraft_render::Renderer::new().render_region(&page.document, Rect::new(0.0, 0.0, urx - llx, ury - lly), scale, true)
}
// A page that marks nothing (type alone): blank.
Err(e) if e.contains("draws nothing") => {
vectorcraft_render::Renderer::new().render_region(&Document::new(1.0, 1.0), Rect::new(0.0, 0.0, urx - llx, ury - lly), scale, true)
}
Err(e) => panic!("the file doesn't run as PostScript: {e}"),
};
assert_eq!((drew.width, drew.height), (want.width, want.height));
let differ = drew
.pixels
.as_chunks::<4>()
.0
.iter()
.zip(want.pixels.as_chunks::<4>().0)
.filter(|(a, b)| a.iter().zip(*b).any(|(x, y)| x.abs_diff(*y) > 48))
.count();
let share = differ as f64 / f64::from(want.width * want.height).max(1.0);
assert!(share < 0.002, "the program draws {:.2}% of the page differently", share * 100.0);
}
fn children(n: &Node) -> &[Arc<Node>] {
@@ -107,10 +203,12 @@ fn writes_the_documented_operators() {
] {
assert!(text.contains(needle), "missing {needle:?} in\n{text}");
}
// The prolog defines the page trailer's `annotatepage` (§3), in the dictionary the setup begins.
// The prolog is the writer's own procedure set, named in the header, in the dictionary the
// setup begins; it defines the page trailer's `annotatepage` (§3).
let prolog = text.split("%%BeginProlog\n").nth(1).and_then(|t| t.split("%%EndProlog\n").next()).unwrap();
assert!(prolog.contains("/VCLegacy 8 dict def\nVCLegacy begin\n/annotatepage {} bind def\nend\n"), "{prolog}");
assert!(text.contains("%%DocumentSuppliedResources: procset VectorCraft_Legacy 1 0\n"), "{text}");
assert!(prolog.starts_with(&format!("%%BeginResource: procset {PROCSET}\n/VCLegacy ")), "{prolog}");
assert!(prolog.ends_with("end\n%%EndResource\n") && prolog.contains("\n/annotatepage {} bd\n"), "{prolog}");
assert!(text.contains(&format!("%%DocumentSuppliedResources: procset {PROCSET}\n")), "{text}");
// The hidden layer is written hidden: visible 0.
assert!(text.contains("%AI5_BeginLayer\n0 1 1 1 0 0 1 "), "{text}");
// Art space is y up from the artboard's bottom-left: the red square (10..40 down from the top)
@@ -287,6 +385,26 @@ fn art_stays_put_on_an_artboard_a_fraction_of_a_point_tall() {
assert!((b.x0 - 10.0).abs() < 1e-3 && (b.y0 - 20.0).abs() < 1e-3 && (b.y1 - 40.0).abs() < 1e-3, "{b:?}");
}
/// The file prints as the document with every layer shown: run as a PostScript program
/// ([`write_with`] checks it), its paths, fills, strokes, compound path, clipping group and image
/// draw where and as the document has them (its gradient filled with its first stop's colour, as
/// the prolog fills gradients). Read for its layers, it is read from its program, which has the
/// gradient.
#[test]
fn prints_as_the_document() {
let mut doc = sample();
if let Some(l) = doc.layers.get_mut(1) {
Arc::make_mut(l).visible = true;
}
let out = write(&doc);
let read = crate::import(&out.bytes).unwrap().document;
let mut gradients = 0;
read.walk(|n| {
gradients += n.appearance.items.iter().filter(|i| matches!(i, AppearanceItem::Fill(f) if matches!(f.paint, Paint::Gradient(_)))).count()
});
assert_eq!((read.layers.len(), gradients), (2, 1));
}
#[test]
fn needs_an_artboard() {
let mut doc = Document::new(100.0, 100.0);
File diff suppressed because one or more lines are too long