mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-08 01:20:59 +00:00
Save As .ai: place the editing data's art on its artboard and carry it on every page
The editing data wrote art space with its origin at the first artboard's bottom-left corner, its artboard records lacked keys a reader requires, and its %%Creator line made the reader measure %AI3_TemplateBox on an 8640-point canvas. Other apps opened the art 3,871.5 points away from its artboard. Art space is now ruler space, as in the .ai files users sent: y up from the first artboard's top-left corner. The artboards together are centered on the 16383-point canvas. Each artboard's record gives its top-left corner there (RulerOrigin), its pixel aspect ratio (PAR), DisplayMark and whether it is selected, and %AI3_TemplateBox holds the canvas's center. %%Creator names the format's app and holds no version number. %AI3_Cropmarks and %AI3_TileBox give the first artboard, %AI5_ArtSize is 14400 14400, and the /Document dictionary gives the canvas size. A file with several artboards carried the data on its first page only, and other apps opened it as a PDF, page by page. Every page now refers to the one data dictionary, as the files users sent do. The format details come from users' own .ai files, static analysis of Illustrator's program and the user's manual testing in Illustrator.
This commit is contained in:
@@ -282,7 +282,7 @@ fn overflow_warning(cmd: &str, doc: &Document, p: &Value) -> Result<Option<Strin
|
||||
|
||||
/// [`encode`] the pages of `doc`, carrying `native()` as the editing data (a `.ai` file: the
|
||||
/// native document with its save options, whose pages may be left blank), and `private()`, the
|
||||
/// editing copy of the art for other apps with what it couldn't hold, on the first page.
|
||||
/// editing copy of the art for other apps with what it couldn't hold, on every page.
|
||||
pub(super) fn encode_carrying(
|
||||
cmd: &str,
|
||||
doc: &Document,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The editing copy of the art that a PDF-compatible `.ai` file carries in its first page's
|
||||
//! 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.
|
||||
@@ -8,16 +8,18 @@
|
||||
//! 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: `%%For` (the document's author), `%%Canvassize`, `%%BoundingBox`,
|
||||
//! - 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 `%AI5_ArtSize`, `%AI5_RulerUnits`, `%AI9_ColorModel` (1 RGB, 2 CMYK),
|
||||
//! `%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_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: `0 Bn` (no gradient definitions, §3), the color swatches (`%AI5_BeginPalette`,
|
||||
//! `0 0 Pb`, the Registration swatch, each swatch's color 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, on `%_` lines.
|
||||
//! dictionary 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 0 Lb` (14 operands; a color that isn't one of the preset layer colors has the
|
||||
//! index -1), `(name) Ln`, the layer's name as an XML id in an `/ArtDictionary`, its art and
|
||||
@@ -30,8 +32,10 @@
|
||||
//! an `/ArtDictionary` whose `AI10_ArtUID` is the name as an XML id. Guides are `(N) *`, the
|
||||
//! path, `(N) *` (`(n) *` when closed).
|
||||
//!
|
||||
//! Lines end in CR LF. Strings are written in UTF-8. Art space has y up, its origin at the first artboard's bottom-left
|
||||
//! corner. What the form above can't hold is written as plain filled and stroked paths, each such
|
||||
//! 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 as its outlines, gradients as their average color, pattern fills as their
|
||||
//! tiles cut to the shape, clipping groups and clipping layers as their art cut to the clipping
|
||||
//! path (the clipping path's fill cut too, its stroke not), images as their outlines filled with
|
||||
@@ -67,6 +71,8 @@ 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;
|
||||
/// `%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.
|
||||
@@ -76,6 +82,13 @@ 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`. Apps that read the editing data take the file's version from this line while they
|
||||
/// read the header, and only a creator that names the format's app gets the version that measures
|
||||
/// `%AI3_TemplateBox` on a canvas of [`CANVAS_SIZE`] points; any other creator places the art
|
||||
/// 3,871.5 points off its artboard. The text holds no version number (a number in it would be
|
||||
/// read as the format's version).
|
||||
const CREATOR_TEXT: &str = "VectorCraft, editing data for Illustrator"; // brand-ok: the creator the format's version is read from
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -107,13 +120,14 @@ 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);
|
||||
// The document is y down; art space y up from the first artboard's bottom-left corner.
|
||||
let to_art = Affine::new([1.0, 0.0, 0.0, -1.0, -board.x0, board.y1]);
|
||||
// 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 mut w = Writer::new(doc, to_art);
|
||||
for layer in &doc.layers {
|
||||
w.layer(layer);
|
||||
}
|
||||
let (width, height) = (board.width(), board.height());
|
||||
let first = to_art.transform_rect_bbox(board);
|
||||
let art = doc
|
||||
.layers
|
||||
.iter()
|
||||
@@ -122,13 +136,14 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditi
|
||||
.map(|b| to_art.transform_rect_bbox(b))
|
||||
.filter(|b| b.is_finite())
|
||||
.reduce(|a, b| a.union(b))
|
||||
.unwrap_or(Rect::new(0.0, 0.0, width, height));
|
||||
let header = header(doc, o, &w, art, (width, height));
|
||||
.unwrap_or(first);
|
||||
let origin = ruler_origin(doc, to_art);
|
||||
let header = header(doc, o, &w, art, first, origin);
|
||||
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");
|
||||
w.palette(&mut s);
|
||||
artboards(&mut s, doc, to_art);
|
||||
artboards(&mut s, doc, to_art, origin);
|
||||
s.push_str("%%EndSetup\n");
|
||||
s.push_str(&w.body);
|
||||
s.push_str("%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n");
|
||||
@@ -164,7 +179,7 @@ fn baked(doc: &Document) -> Option<Document> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The editing data of `doc` as a `.ai` file's first page carries it: the program compressed with
|
||||
/// 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> {
|
||||
@@ -182,13 +197,28 @@ pub fn private_data(doc: &Document, o: &AiEditingOptions) -> Result<(PrivateData
|
||||
Ok((p, out.warnings))
|
||||
}
|
||||
|
||||
/// The header comments, through `%%EndComments`. `art`: the visible art's bounds, `size` the first
|
||||
/// artboard's, in art space.
|
||||
fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, (width, height): (f64, f64)) -> String {
|
||||
/// 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())
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let mut s = String::new();
|
||||
s.push_str(PS_HEADER);
|
||||
s.push('\n');
|
||||
let _ = writeln!(s, "%%Creator: VectorCraft {}", env!("CARGO_PKG_VERSION"));
|
||||
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()));
|
||||
@@ -216,13 +246,17 @@ fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, (width, h
|
||||
ps::push_nums(&mut s, &cmyk.map(f64::from));
|
||||
let _ = writeln!(s, "{}", ps::bytes(name.as_bytes()));
|
||||
}
|
||||
let (w2, h2) = (ps::num(width), ps::num(height));
|
||||
let _ = writeln!(s, "%AI3_Cropmarks: 0 0 {w2} {h2}");
|
||||
let (cx, cy) = (ps::num(width / 2.0), ps::num(height / 2.0));
|
||||
// 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: 0 0 {w2} {h2}");
|
||||
let _ = writeln!(s, "%AI3_TileBox: {board}");
|
||||
s.push_str("%AI3_DocumentPreview: None\n");
|
||||
let _ = writeln!(s, "%AI5_ArtSize: {w2} {h2}");
|
||||
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 model = if doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { 2 } else { 1 };
|
||||
@@ -256,19 +290,30 @@ fn ruler_units(u: Unit) -> u8 {
|
||||
}
|
||||
|
||||
/// The artboards as the `ArtboardArray` of the `/Document` dictionary: each one's top-left and
|
||||
/// bottom-right corners in art space (`PositionPoint1`, `PositionPoint2`) and its name.
|
||||
fn artboards(s: &mut String, doc: &Document, to_art: Affine) {
|
||||
/// 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
|
||||
/// first) and its name; then the canvas's size. Apps that read the editing data drop a record that
|
||||
/// lacks any of the first five.
|
||||
fn artboards(s: &mut String, doc: &Document, to_art: Affine, origin: (f64, f64)) {
|
||||
s.push_str("%AI9_BeginDocumentData\n%_/Document :\n%_/Dictionary :\n%_/Array :\n");
|
||||
for ab in &doc.artboards {
|
||||
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");
|
||||
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 });
|
||||
let _ = writeln!(s, " /Real (PAR) ,\n%_0 /Int (DisplayMark) ,\n%_{} /Bool (IsArtboardSelected) ,", u8::from(i == 0));
|
||||
let _ = writeln!(s, "%_{} /UnicodeString (Name) ,\n%_; ,", ps::bytes(ab.name.as_bytes()));
|
||||
}
|
||||
s.push_str("%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n%AI9_EndDocumentData\n");
|
||||
s.push_str("%_; (ArtboardArray) ,\n");
|
||||
let _ = writeln!(s, "%_{CANVAS_SIZE} /Int (AIDocumentCanvasSize) ,");
|
||||
s.push_str("%_; /NotRecorded ,\n%_;\n%AI9_EndDocumentData\n");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -290,8 +290,8 @@ fn the_data_is_zstandard_compressed_with_the_header_as_metadata() {
|
||||
"%%AI8_CreatorVersion: 30.7.0\n%%For: () ()\n%%Title: (Sample)\n",
|
||||
"%%Canvassize: 16383\n",
|
||||
"%AI5_FileFormat 14.0\n%AI3_ColorUsage: Color\n%AI7_ImageSettings: 0\n%%DocumentCustomColors: (Gold)\n%%+ (Sky)\n%%+ (Mint)\n",
|
||||
"%AI3_Cropmarks: 0 0 200 100\n",
|
||||
"%AI5_ArtSize: 200 100\n",
|
||||
"%AI3_Cropmarks: 0 -100 200 0\n%AI3_TemplateBox: 225.5 -50.5 225.5 -50.5\n%AI3_TileBox: 0 -100 200 0\n",
|
||||
"%AI5_ArtSize: 14400 14400\n",
|
||||
"%AI9_ColorModel: 1\n%AI5_ArtFlags: 0 0 0 1 0 0 1 0 0\n%AI5_TargetResolution: 800\n%AI5_NumLayers: 2\n",
|
||||
"%AI5_OpenViewLayers: 77\n%AI7_GridSettings: 72 8 72 8 1 0 0.7843 0.7843 0.7843 0.7843 0.7843 0.7843\n%%EndComments\n",
|
||||
] {
|
||||
@@ -300,6 +300,48 @@ fn the_data_is_zstandard_compressed_with_the_header_as_metadata() {
|
||||
assert_eq!((p.container_version, p.creator_version, p.roundtrip_version, p.roundtrip_stream_type), (12, 30, 24, 2));
|
||||
}
|
||||
|
||||
/// Art space is ruler space: y up from the first artboard's top-left corner. The artboards
|
||||
/// together sit centered on the canvas: each one's top-left corner on the canvas (y down) is its
|
||||
/// `RulerOrigin`, and the template box holds the canvas's center in art space.
|
||||
#[test]
|
||||
fn art_lies_on_its_artboard_in_ruler_space() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let mut second = doc.artboards[0].clone();
|
||||
(second.id, second.name, second.rect) = (2, "Back".into(), Rect::new(250.0, 0.0, 450.0, 100.0));
|
||||
doc.artboards.push(second);
|
||||
let rect = Node::path(
|
||||
doc.alloc_id(),
|
||||
shapes::rectangle(Rect::new(10.0, 20.0, 30.0, 40.0)),
|
||||
Appearance::basic(solid(Color::gray(0.5)), Paint::None, 0.0),
|
||||
);
|
||||
let layer = doc.layers[0].id;
|
||||
doc.insert(Some(layer), usize::MAX, rect).unwrap();
|
||||
let text = program(&doc);
|
||||
for line in [
|
||||
"%%BoundingBox: 10 -40 30 -20\n",
|
||||
"%AI3_Cropmarks: 0 -100 200 0\n",
|
||||
"%AI3_TemplateBox: 225.5 -50.5 225.5 -50.5\n",
|
||||
"\n10 -20 m\n",
|
||||
"%_0 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_200 -100 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_7966 8141 /RealPoint\n%_ (RulerOrigin) ,\n%_1 /Real (PAR) ,\n%_0 /Int (DisplayMark) ,\n%_1 /Bool (IsArtboardSelected) ,\n",
|
||||
"%_250 0 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_450 -100 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_8216 8141 /RealPoint\n%_ (RulerOrigin) ,\n%_1 /Real (PAR) ,\n%_0 /Int (DisplayMark) ,\n%_0 /Bool (IsArtboardSelected) ,\n",
|
||||
"%_16383 /Int (AIDocumentCanvasSize) ,\n",
|
||||
] {
|
||||
assert!(text.contains(line), "{line}\n{text}");
|
||||
}
|
||||
// The creator names the format's app, before the canvas size and the template box, and holds
|
||||
// no version number another app could read as the format's version.
|
||||
let creator = text.lines().find_map(|l| l.strip_prefix("%%Creator: ")).unwrap();
|
||||
assert!(creator.contains("Illustrator"), "{creator}");
|
||||
assert!(!creator.contains("88") && !creator.chars().any(|c| c.is_ascii_digit()), "{creator}");
|
||||
assert!(text.find("%%Creator: ").unwrap() < text.find("%%Canvassize: 16383").unwrap());
|
||||
assert!(text.find("%%Canvassize: 16383").unwrap() < text.find("%AI3_TemplateBox").unwrap());
|
||||
// The reader puts the art back where it was, on its artboard.
|
||||
let read = read_back(&doc);
|
||||
assert_eq!(read.artboards.iter().map(|a| a.rect).collect::<Vec<_>>(), doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>());
|
||||
let bounds = read.layers.iter().filter_map(|l| l.visual_bounds()).reduce(|a, b| a.union(b)).unwrap();
|
||||
assert_eq!(bounds, Rect::new(10.0, 20.0, 30.0, 40.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pdf_carries_the_data_on_its_first_page() {
|
||||
let doc = sample();
|
||||
|
||||
@@ -105,7 +105,7 @@ pub struct PdfOptions {
|
||||
pub modified: Option<i64>,
|
||||
/// The native document (`.vectorcraft` bytes) Preserve Editing embeds.
|
||||
pub native: Option<Vec<u8>>,
|
||||
/// The editing copy of the art for the first page's `PieceInfo`, as a `.ai` file carries it.
|
||||
/// The editing copy of the art for every page's `PieceInfo`, as a `.ai` file carries it.
|
||||
pub private_data: Option<PrivateData>,
|
||||
/// The pages drawn small, in page order: what Embed Page Thumbnails embeds (the writer
|
||||
/// doesn't draw; [`page_areas`] says what each page shows).
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! An editor's private data on the first page: a page-piece dictionary (ISO 32000-1 §14.5,
|
||||
//! An editor's private data on every page: a page-piece dictionary (ISO 32000-1 §14.5,
|
||||
//! `PieceInfo`) holding the editing copy of the art that `.ai` files carry, laid out as in the
|
||||
//! `.ai` files users sent, the form [`crate::illustrator_data`] reads.
|
||||
//!
|
||||
//! - The page dictionary gets `/PieceInfo << /Illustrator n 0 R >>` and its own `/LastModified`
|
||||
//! (which §14.5 requires with `PieceInfo`).
|
||||
//! - Every page dictionary gets `/PieceInfo << /Illustrator n 0 R >>`, the same object `n` on
|
||||
//! each page, and its own `/LastModified` (which §14.5 requires with `PieceInfo`). A file with
|
||||
//! several pages whose pages do not all refer to the one data dictionary opens in other apps as
|
||||
//! a PDF, without the editing data.
|
||||
//! - Object `n` is the data dictionary: `<< /LastModified (D:…) /Private m 0 R >>`.
|
||||
//! - Object `m` holds `AIMetaData` (a stream of the data's header comments), `AIPrivateData1` …
|
||||
//! `AIPrivateDataN` (the data in streams of [`PRIVATE_CHUNK`] bytes, the last one shorter),
|
||||
@@ -35,12 +37,15 @@ pub struct PrivateData {
|
||||
pub roundtrip_stream_type: u32,
|
||||
}
|
||||
|
||||
/// `pdf` with `p` in its first page's `PieceInfo`, dated `modified` (Unix seconds, UTC).
|
||||
/// `pdf` with `p` in every page's `PieceInfo`, dated `modified` (Unix seconds, UTC).
|
||||
pub(crate) fn add(pdf: Vec<u8>, p: &PrivateData, modified: i64) -> Result<Vec<u8>, PdfError> {
|
||||
let failed = |why: &str| PdfError::Write(format!("can't add the editing data for other apps: {why}"));
|
||||
let Parsed { xref, pages, .. } = linearize::parse(&pdf).map_err(failed)?;
|
||||
let first = *pages.first().ok_or_else(|| failed("a page can't be read"))?;
|
||||
let (dict, _) = xref.dict(&pdf, first).ok_or_else(|| failed("a page can't be read"))?;
|
||||
if pages.is_empty() {
|
||||
return Err(failed("a page can't be read"));
|
||||
}
|
||||
let dicts = pages.iter().map(|&n| xref.dict(&pdf, n).map(|(at, _)| at)).collect::<Option<Vec<usize>>>();
|
||||
let dicts = dicts.ok_or_else(|| failed("a page can't be read"))?;
|
||||
let mut patch = Patch::new(&xref);
|
||||
let metadata = patch.add_object(stream(&p.metadata));
|
||||
let blocks: Vec<u32> = p.data.chunks(PRIVATE_CHUNK).map(|c| patch.add_object(stream(c))).collect();
|
||||
@@ -60,7 +65,9 @@ pub(crate) fn add(pdf: Vec<u8>, p: &PrivateData, modified: i64) -> Result<Vec<u8
|
||||
let private = patch.add_object(private.into_bytes());
|
||||
let date = pdf_date(modified);
|
||||
let owner = patch.add_object(format!("<< /LastModified {date} /Private {private} 0 R >>").into_bytes());
|
||||
patch.replace(dict, dict, format!("/PieceInfo << /{OWNER} {owner} 0 R >> /LastModified {date} ").into_bytes());
|
||||
for dict in dicts {
|
||||
patch.replace(dict, dict, format!("/PieceInfo << /{OWNER} {owner} 0 R >> /LastModified {date} ").into_bytes());
|
||||
}
|
||||
patch.apply(&pdf, &xref)
|
||||
}
|
||||
|
||||
@@ -112,6 +119,30 @@ mod tests {
|
||||
assert!(text.contains(&format!("<< /Length {PRIVATE_CHUNK} >>")) && text.contains("<< /Length 10 >>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_page_refers_to_the_one_data_dictionary() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let mut second = doc.artboards[0].clone();
|
||||
(second.id, second.name, second.rect) = (2, "Back".into(), vectorcraft_geom::Rect::new(240.0, 0.0, 440.0, 100.0));
|
||||
doc.artboards.push(second);
|
||||
let opts = PdfOptions { private_data: Some(data(10)), ..PdfOptions::uncompressed() };
|
||||
let pdf = export(&doc, &opts).unwrap();
|
||||
let Parsed { xref, pages, .. } = linearize::parse(&pdf).unwrap();
|
||||
assert_eq!(pages.len(), 2);
|
||||
let owners: Vec<String> = pages
|
||||
.iter()
|
||||
.map(|&n| {
|
||||
let (at, end) = xref.dict(&pdf, n).unwrap();
|
||||
let page = String::from_utf8_lossy(&pdf[at..end]).into_owned();
|
||||
assert!(page.contains("/LastModified (D:"), "{page}");
|
||||
let i = page.find("/PieceInfo << /Illustrator ").expect("every page carries the data");
|
||||
page[i..].split(">>").next().unwrap().to_string()
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(owners[0], owners[1]);
|
||||
assert_eq!(String::from_utf8_lossy(&pdf).matches("/AIPrivateData1 ").count(), 1, "the data is written once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_the_data_the_page_has_no_piece_info() {
|
||||
let pdf = export(&Document::new(200.0, 100.0), &PdfOptions::uncompressed()).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user