PDF/.ai import: a .ai whose page has a UserUnit opens with its layers at the page's size

A PDF page's UserUnit (PDF 1.6) sets the size of its user space unit
in multiples of 1/72 inch. The PDF reader has applied it since #1894, so
a page with /UserUnit 10 and a 36 × 50.4 media box opens at 360 × 504
pt. A .ai whose editing data has its artboards in the page's own units
then no longer matched its PDF part. It opened from the PDF part,
without its layers, its hidden objects and the art outside its
artboards, and with its artboards in a row instead of where the
editing data puts them. document.pdfInfo gave such a page's size in
points and its boxes in the file's numbers.

Editing data whose first artboard has the page's size in the page's
units, and not in points, is now compared with the PDF part scaled
down by the UserUnit. The document read from it is then scaled up by
the UserUnit, with its stroke weights, dashes, effects and pattern
tiles. When the layers can't be read, the artboard layout taken from
the editing data is scaled the same way. document.pdfInfo gives the
boxes in points, as it gives the size.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-16 03:36:20 +07:00
committed by bflatastic
parent dc179c3671
commit 16b8aeb6b7
12 changed files with 232 additions and 45 deletions
+5 -4
View File
@@ -153,14 +153,15 @@ pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
/// A PDF-compatible `.ai`, or a PDF saved with the editing data, read through the editor's own copy
/// of its art, which it carries: its layers and hidden objects as they were, and the art outside
/// its artboard (its PDF part has only what is on the artboards), as an EPS of the editor is read.
/// Anything else as it was.
fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warnings: Vec<String>) -> (Document, Vec<String>) {
/// Anything else as it was. `unit`: the size in points of a unit of the PDF part's user space (its
/// first page's `UserUnit`).
fn through_editing_data(bytes: &[u8], opts: &LoadOptions, doc: Document, warnings: Vec<String>, unit: f64) -> (Document, Vec<String>) {
if opts.pages.is_some() || !opts.layers || !opts.editing_data {
return (doc, warnings);
}
let Some(private) = vectorcraft_pdf::illustrator_data(bytes, opts.password.as_deref()) else { return (doc, warnings) };
let outlined = opts.text_as == vectorcraft_pdf::TextAs::Outlines;
let (doc, mut warnings, read) = vectorcraft_eps::layered_ai(&private, doc, warnings, outlined, opts.trust_editing_data);
let (doc, mut warnings, read) = vectorcraft_eps::layered_ai(&private, doc, warnings, outlined, opts.trust_editing_data, unit);
// The layers weren't read (or couldn't be): the notes of reading the PDF part still hold.
if !read {
return (doc, warnings);
@@ -228,7 +229,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
// Pages of text alone: the editing copy alone where it reads, as for a file
// saved without its PDF part (#1387: else the page, which is the art).
let alone = r.only_text.then(|| from_editing_data_alone(bytes, opts)).flatten().and_then(|read| read.ok());
Ok(alone.unwrap_or_else(|| through_editing_data(bytes, opts, r.document, r.warnings)))
Ok(alone.unwrap_or_else(|| through_editing_data(bytes, opts, r.document, r.warnings, r.user_unit)))
}
// Saved without its PDF part: the editing copy is all the file has.
Err(e) if e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string() => {
+1 -1
View File
@@ -160,7 +160,7 @@ pub fn specs() -> Vec<CommandSpec> {
"PDF Info",
[],
None,
"{path | name? | dataBase64 (a file's path, or its bytes), password?, thumbnail?: page (1-based), thumbnailSize?: 160 (px, longest side), cropTo?: crop (the thumbnail's box)} → {pages, needsPassword, wrongPassword?, pageInfo: [{width, height (pt, as shown), rotation, boxes: {media, crop, bleed, trim, art: [x0, y0, x1, y1] (PDF space, pt)}}], thumbnail?: PNG dataBase64}; an encrypted PDF without its password → {pages: 0, needsPassword: true}",
"{path | name? | dataBase64 (a file's path, or its bytes), password?, thumbnail?: page (1-based), thumbnailSize?: 160 (px, longest side), cropTo?: crop (the thumbnail's box)} → {pages, needsPassword, wrongPassword?, pageInfo: [{width, height (pt, as shown), rotation, boxes: {media, crop, bleed, trim, art: [x0, y0, x1, y1] (PDF space, pt)}}], thumbnail?: PNG dataBase64}; a page whose UserUnit (PDF 1.6) is above 1 gives its size and boxes in pt, its numbers times the UserUnit (one above 75,000 counts as 75,000); an encrypted PDF without its password → {pages: 0, needsPassword: true}",
always,
pdfimport::pdf_info
),
@@ -282,6 +282,30 @@ fn type_asked_for_as_outlines_opens_a_file_whose_type_shows_from_its_pdf_part()
assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
}
/// A `.ai` saved in units of 10 points: its PDF part's page is 200 × 100 with `/UserUnit 10`, and
/// its editing data has the artboard and a red rectangle in the same units. It opens at 2000 × 1000
/// pt, from its editing data and from its PDF part alone, with the rectangle ten times as large,
/// and `document.pdfInfo` gives the page in points (#1482).
#[test]
fn an_ai_file_with_a_user_unit_opens_at_its_size_in_points() {
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &format!("0 1 1 0 1 0 0 Xa\n{}f\n", ai::rect(20.0, 20.0, 60.0, 50.0))));
let file = ai::ai_with(&compressed(data.as_bytes()), "1 0 0 rg 20 20 40 30 re f", "/UserUnit 10");
let near = |r: vectorcraft_geom::Rect, want: [f64; 4]| [r.x0, r.y0, r.x1, r.y1].iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01);
let mut s = Session::new();
for (params, layers) in [(json!({}), ["Art"]), (json!({"editingData": false}), ["Page 1"])] {
let r = open(&mut s, "large.ai", &file, params.clone()).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(names(&doc.layers), layers, "{params}: {r}");
assert!(near(doc.artboards[0].rect, [0.0, 0.0, 2000.0, 1000.0]), "{params}: {:?}", doc.artboards[0].rect);
let art = doc.art_bounds().unwrap();
assert!(near(art, [200.0, 500.0, 600.0, 800.0]), "{params}: {art:?}");
}
let v = s.execute("document.pdfInfo", &json!({"dataBase64": vectorcraft_format::base64_encode(&file)})).unwrap();
let page = &v["pageInfo"][0];
assert_eq!((page["width"].as_f64(), page["height"].as_f64()), (Some(2000.0), Some(1000.0)), "{v}");
assert_eq!(page["boxes"]["media"], json!([0.0, 0.0, 2000.0, 1000.0]), "{v}");
}
/// A `.ai` of a 200 × 100 pt artboard with a 10 mm bleed: its PDF part's page is the artboard
/// (its trim box) grown by the bleed, and a red square reaches 20 pt into the bleed, on the page
/// as in the editing data. `key`: `AIPrivateData`, or `AIPDFPrivateData` for a PDF saved with its