mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
@@ -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() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -665,18 +665,21 @@ fn arb_box() -> impl Strategy<Value = Option<[f64; 4]>> {
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
/// Every page box, rotation, page pick, password and Crop To, through `document.open` and
|
||||
/// `document.pdfInfo` (with a thumbnail).
|
||||
/// Every page box, rotation, `UserUnit`, page pick, password and Crop To, through
|
||||
/// `document.open` and `document.pdfInfo` (with a thumbnail).
|
||||
#[test]
|
||||
fn pdf_import_options_never_panic(
|
||||
boxes in prop::collection::vec(arb_box(), 5),
|
||||
rotate in prop::sample::select(vec![0, 90, 180, 270, 45, -90, 1_000_000_000]),
|
||||
unit in prop::sample::select(vec!["", "10", "2.5", "1", "0", "-10", "0.001", "99999999999999999999", "/Ten", "(10)", "99 0 R", "huge"]),
|
||||
encrypt in any::<bool>(),
|
||||
password in prop::sample::select(vec!["", "pw", "wrong", "\u{0}\u{ff}"]),
|
||||
pages in prop::sample::select(vec!["1", "2", "1-2", "2-1", "0", "-", "1-", "all", "1,1,2", "99999999999999999999"]),
|
||||
crop in prop::sample::select(vec!["bounding", "art", "crop", "trim", "bleed", "media", "nope"]),
|
||||
) {
|
||||
use vectorcraft_testkit::pdf::{PdfPage, pdf};
|
||||
// A `UserUnit` of 1 followed by 308 zeros (a PDF number has no exponent).
|
||||
let unit = if unit == "huge" { format!("1{}", "0".repeat(308)) } else { unit.to_string() };
|
||||
let page = PdfPage {
|
||||
media: boxes[0].unwrap_or([0.0, 0.0, 100.0, 100.0]),
|
||||
crop: boxes[1],
|
||||
@@ -684,6 +687,7 @@ proptest! {
|
||||
trim: boxes[3],
|
||||
art: boxes[4],
|
||||
rotate,
|
||||
entries: if unit.is_empty() { String::new() } else { format!("/UserUnit {unit}") },
|
||||
..PdfPage::new(0.0, 0.0, "0 0 1 rg 20 20 50 50 re f")
|
||||
};
|
||||
let bytes = pdf(&[page.clone(), page], encrypt.then_some("pw"));
|
||||
@@ -697,7 +701,7 @@ proptest! {
|
||||
survive("pdf options", || Some(d)).unwrap();
|
||||
}
|
||||
});
|
||||
r.map_err(|msg| TestCaseError::fail(format!("{pages} {crop} {rotate}: panicked: {msg}")))?;
|
||||
r.map_err(|msg| TestCaseError::fail(format!("{pages} {crop} {rotate} {unit}: panicked: {msg}")))?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2037,7 +2041,7 @@ proptest! {
|
||||
*x = b;
|
||||
}
|
||||
}
|
||||
survive("mutated .ai editing data", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false).0))?;
|
||||
survive("mutated .ai editing data", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2325,9 +2329,13 @@ proptest! {
|
||||
fn ai_hostile_editing_data_never_panics(tokens in prop::collection::vec(arb_ai_token(), 0..80)) {
|
||||
use vectorcraft_testkit::ai;
|
||||
let data = ai::editing_data(200.0, 100.0, &format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n{}\nLB\n", tokens.join(" ")));
|
||||
survive("hostile editing data", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, false).0))?;
|
||||
survive("hostile editing data, type as outlines", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], true, false).0))?;
|
||||
survive("hostile editing data, trusted", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, true).0))?;
|
||||
survive("hostile editing data", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?;
|
||||
survive("hostile editing data, type as outlines", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], true, false, 1.0).0))?;
|
||||
survive("hostile editing data, trusted", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?;
|
||||
// A PDF part read from a user space of 10 points a unit: the layers are read in its units and scaled.
|
||||
survive("hostile editing data in units of 10 points", || {
|
||||
Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(2000.0, 1000.0), vec![], false, false, 10.0).0)
|
||||
})?;
|
||||
let eps = ai::eps(data.as_bytes(), ai::page_ps());
|
||||
survive("hostile editing data in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?;
|
||||
// An EPS whose page is its art's box, not its artboard: what prints outside it is compared too.
|
||||
|
||||
@@ -29,7 +29,7 @@ use std::borrow::Cow;
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, TextKind};
|
||||
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, Scaling, TextKind};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect, Vec2};
|
||||
|
||||
use super::Imported;
|
||||
@@ -870,11 +870,21 @@ struct Frame<'a> {
|
||||
/// file read as its page, put in its text objects → the document and its notes. Without a page
|
||||
/// (a `.ai` saved without its PDF part), its type is made from the file's text document alone and
|
||||
/// nothing is compared. `outlined`: the page's type was read as outlines, so it has none to put in
|
||||
/// the text objects.
|
||||
fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool, trust: bool) -> Result<(Document, Vec<String>), String> {
|
||||
/// the text objects. `unit`: the size in points of a unit of the page's user space (see
|
||||
/// [`layered_ai`]).
|
||||
fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool, trust: bool, unit: f64) -> Result<(Document, Vec<String>), String> {
|
||||
// An EPS (its page is its art) draws a Large Canvas page at full size; a `.ai` keeps its art
|
||||
// space, as its PDF part may (#2031).
|
||||
let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, warnings } = ai::read(data, matches!(page, Page::Art))?;
|
||||
// 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`.
|
||||
let board = artboard.map(|a| to_doc.transform_rect_bbox(a));
|
||||
let small = visible.filter(|v| matches!(page, Page::Artboard) && in_units(board, v, unit)).map(|v| {
|
||||
let mut v = v.clone();
|
||||
scale_document(&mut v, 1.0 / unit);
|
||||
v
|
||||
});
|
||||
let visible = small.as_ref().or(visible);
|
||||
let (mut shown, mut hidden) = (vec![], vec![]);
|
||||
let mut frame = None;
|
||||
if let Some(visible) = visible {
|
||||
@@ -998,9 +1008,35 @@ fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool, tr
|
||||
None => unmark(&mut doc.layers, true, &mut vec![]),
|
||||
}
|
||||
doc.limit_depth_noting(&mut notes);
|
||||
if small.is_some() {
|
||||
scale_document(&mut doc, unit);
|
||||
}
|
||||
Ok((doc, notes))
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// unit (their `UserUnit`).
|
||||
fn in_units(board: Option<Rect>, page: &Document, unit: f64) -> bool {
|
||||
let (Some(board), Some(p)) = (board, page.artboards.first().map(|a| a.rect)) else { return false };
|
||||
let fits = |k: f64| (board.width() - p.width() / k).abs() <= 0.5 && (board.height() - p.height() / k).abs() <= 0.5;
|
||||
unit.is_finite() && unit > 1.0 && fits(unit) && !fits(1.0)
|
||||
}
|
||||
|
||||
/// Draw `doc`'s layers and artboards `k` times as large, from the origin, with their strokes,
|
||||
/// effects and pattern tiles.
|
||||
fn scale_document(doc: &mut Document, k: f64) {
|
||||
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 {
|
||||
Arc::make_mut(layer).transform(a, scaling);
|
||||
}
|
||||
for board in &mut doc.artboards {
|
||||
board.rect = a.transform_rect_bbox(board.rect);
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks (in a node's name, until [`prune_unseen`] has looked) the text objects made from the file's
|
||||
/// text document.
|
||||
const MADE: &str = "\u{1}made";
|
||||
@@ -1121,7 +1157,7 @@ fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document) -> Result
|
||||
/// has one that can't be used, `visible` with a warning that says why.
|
||||
pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported {
|
||||
let Some(data) = editing_copy(ps) else { return visible };
|
||||
let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art, false, false));
|
||||
let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art, false, false, 1.0));
|
||||
match result {
|
||||
Ok((document, mut warnings)) => {
|
||||
for w in &visible.warnings {
|
||||
@@ -1148,12 +1184,16 @@ pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported {
|
||||
/// is `visible`, its pages moved by `place_pages` when the layers can't be read. `outlined`: the
|
||||
/// PDF part's type was read as outlines (a file whose type shows then comes in as its PDF part).
|
||||
/// `trust`: layers that draw differently from the PDF part are used all the same, with a warning.
|
||||
pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outlined: bool, trust: bool) -> (Document, Vec<String>, bool) {
|
||||
/// `unit`: the size in points of a unit of the PDF part's user space, its first page's `UserUnit`,
|
||||
/// by which its art came in scaled. Editing data whose artboard matches the page only in those
|
||||
/// units comes in `unit` times as large, and so does the artboard layout `place_pages` takes from
|
||||
/// it.
|
||||
pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outlined: bool, trust: bool, unit: f64) -> (Document, Vec<String>, bool) {
|
||||
let Ok(data) = ai::decode_private(private) else { return (visible, warnings, false) };
|
||||
if !has_layers(&data) {
|
||||
return (visible, warnings, false);
|
||||
}
|
||||
match build(&data, Some(&visible), Page::Artboard, outlined, trust) {
|
||||
match build(&data, Some(&visible), Page::Artboard, outlined, trust, unit) {
|
||||
Ok((mut done, notes)) => {
|
||||
done.setup.bleed = visible.setup.bleed;
|
||||
let mut all = warnings;
|
||||
@@ -1164,7 +1204,7 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outl
|
||||
let mut all = warnings;
|
||||
all.push(format!("{LAYERS_UNREAD} from its editing data ({why}): they come from its PDF part"));
|
||||
let mut visible = visible;
|
||||
place_pages(&ai::artboards(&data), &mut visible);
|
||||
place_pages(&ai::artboards(&data), &mut visible, unit);
|
||||
(visible, all, false)
|
||||
}
|
||||
}
|
||||
@@ -1173,20 +1213,33 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outl
|
||||
/// The pages of `doc` (a `.ai` read as its PDF part: a page per artboard, in a row) named as
|
||||
/// `boards` (the artboards of its editing data, in art space) name them, and moved, with the art on
|
||||
/// each, to where they are, so the artboards keep their names and layout when the layers can't be
|
||||
/// read (#1068). Left as they are unless there are as many artboards as pages, of the same sizes.
|
||||
fn place_pages(boards: &[(String, Rect)], doc: &mut Document) {
|
||||
/// read (#1068). Left as they are unless there are as many artboards as pages, each the size of its
|
||||
/// page in points, or, when `unit` is above 1, each the size of its page in units of `unit` points:
|
||||
/// then the layout is drawn `unit` times as large.
|
||||
fn place_pages(boards: &[(String, Rect)], doc: &mut Document, unit: f64) {
|
||||
if boards.is_empty() || boards.len() != doc.artboards.len() {
|
||||
return;
|
||||
}
|
||||
if boards.iter().zip(&doc.artboards).any(|((_, b), a)| (b.width() - a.rect.width()).abs() > 0.5 || (b.height() - a.rect.height()).abs() > 0.5) {
|
||||
let differ = |k: f64| {
|
||||
boards
|
||||
.iter()
|
||||
.zip(&doc.artboards)
|
||||
.any(|((_, b), a)| (b.width() - a.rect.width() / k).abs() > 0.5 || (b.height() - a.rect.height() / k).abs() > 0.5)
|
||||
};
|
||||
let k = if !differ(1.0) {
|
||||
1.0
|
||||
} else if unit.is_finite() && unit > 1.0 && !differ(unit) {
|
||||
unit
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
};
|
||||
for ((name, _), a) in boards.iter().zip(&mut doc.artboards) {
|
||||
a.name.clone_from(name);
|
||||
}
|
||||
let [(_, first), _, ..] = boards else { return };
|
||||
// Art space (y up) → the document, the first artboard's top left at the origin, as `read` has it.
|
||||
let to_doc = Affine::new([1.0, 0.0, 0.0, -1.0, -first.x0, first.y1]);
|
||||
// Art space (y up) → the document, the first artboard's top left at the origin, as `read` has it,
|
||||
// `k` points a unit.
|
||||
let to_doc = Affine::new([k, 0.0, 0.0, -k, -first.x0 * k, first.y1 * k]);
|
||||
let pages: Vec<Rect> = doc.artboards.iter().map(|a| a.rect).collect();
|
||||
let moves: Vec<Vec2> = boards.iter().zip(&pages).map(|((_, b), page)| to_doc.transform_rect_bbox(*b).origin() - page.origin()).collect();
|
||||
for layer in &mut doc.layers {
|
||||
@@ -1220,7 +1273,7 @@ fn place_art(layer: &mut Node, pages: &[Rect], moves: &[Vec2]) {
|
||||
/// alone (`private`, the joined `AIPrivateData` streams) → the document and the import's notes.
|
||||
pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec<String>), String> {
|
||||
let data = ai::decode_private(private)?;
|
||||
build(&data, None, Page::Artboard, false, false)
|
||||
build(&data, None, Page::Artboard, false, false, 1.0)
|
||||
}
|
||||
|
||||
/// Does the editing data (or a program in the legacy format) have layers?
|
||||
@@ -1241,7 +1294,7 @@ fn editing_copy(ps: &[u8]) -> Option<Result<Cow<'_, [u8]>, String>> {
|
||||
/// prolog without including it) from its layers alone (`ps`: its program, which has them) → the
|
||||
/// document and the import's notes. `None` when the layers aren't in the program.
|
||||
pub(super) fn program_alone(ps: &[u8]) -> Option<Result<(Document, Vec<String>), String>> {
|
||||
(!ai::has_eps_data(ps) && has_layers(ps)).then(|| build(ps, None, Page::Art, false, false))
|
||||
(!ai::has_eps_data(ps) && has_layers(ps)).then(|| build(ps, None, Page::Art, false, false, 1.0))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -221,11 +221,23 @@ fn pdf_part(x: f64, y: f64) -> Document {
|
||||
d
|
||||
}
|
||||
|
||||
/// The PDF part `d` as read in points from pages of `k` points a unit (their `UserUnit`): its
|
||||
/// pages and art `k` times as large.
|
||||
fn in_points(mut d: Document, k: f64) -> Document {
|
||||
for layer in &mut d.layers {
|
||||
std::sync::Arc::make_mut(layer).transform(vectorcraft_geom::Affine::scale(k), true);
|
||||
}
|
||||
for a in &mut d.artboards {
|
||||
a.rect = a.rect.scale_from_origin(k);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ai_file_gets_its_layers_and_the_art_outside_its_artboard() {
|
||||
// A square on the artboard, and one above it (the artboard is y 0 to 100 here).
|
||||
let art = native(&(layer("Jig", true, &square(10, 10)) + &layer("Parts", true, &square(30, 110)) + &layer("Old", false, &square(60, 60))));
|
||||
let (d, warnings, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec!["a note".into()], false, false);
|
||||
let (d, warnings, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec!["a note".into()], false, false, 1.0);
|
||||
assert_eq!(names(&d), ["Jig", "Parts", "Old"], "{warnings:?}");
|
||||
assert!(read);
|
||||
assert_eq!(warnings, ["a note"], "nothing else to say");
|
||||
@@ -234,16 +246,49 @@ fn an_ai_file_gets_its_layers_and_the_art_outside_its_artboard() {
|
||||
assert!(!d.layers[2].visible);
|
||||
}
|
||||
|
||||
/// A `.ai` whose PDF part was read in points from a page of 10 points a unit (its `UserUnit`), and
|
||||
/// whose editing data has the page in those units: its layers come in at the page's size, ten
|
||||
/// times as large as the editing data has them, with stroke weights and dashes ten times as large.
|
||||
/// Editing data with the page in points comes in as it is.
|
||||
#[test]
|
||||
fn editing_data_in_the_units_of_a_user_unit_page_comes_in_at_the_pages_size() {
|
||||
// A dashed line 2 units wide on a hidden layer, which the page doesn't draw.
|
||||
let line = "0 0 1 0 K\n2 w\n[4 2] 1 d\n10 50 m\n50 50 L\nS";
|
||||
let art = native(&(layer("Jig", true, &square(10, 10)) + &layer("Parts", true, &square(30, 110)) + &layer("Old", false, line)));
|
||||
let page = in_points(pdf_part(10.0, 86.0), 10.0);
|
||||
let first = |d: &Document, l: usize| d.layers[l].children().unwrap()[0].visual_bounds().unwrap();
|
||||
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() < 1e-6);
|
||||
let (d, w, read) = crate::layered_ai(&private(&art), page.clone(), vec![], false, false, 10.0);
|
||||
assert!(read, "{w:?}");
|
||||
assert_eq!(names(&d), ["Jig", "Parts", "Old"]);
|
||||
assert_eq!(d.artboards[0].rect, vectorcraft_geom::Rect::new(0.0, 0.0, 1000.0, 1000.0));
|
||||
// The square on the artboard and the one above it, ten times as large and as far from its corner.
|
||||
assert!(near(first(&d, 0), [100.0, 860.0, 140.0, 900.0]), "{:?}", first(&d, 0));
|
||||
assert!(near(first(&d, 1), [300.0, -140.0, 340.0, -100.0]), "{:?}", first(&d, 1));
|
||||
let stroke = d.layers[2].children().unwrap()[0].appearance.stroke().cloned().unwrap();
|
||||
let dash = stroke.dash.unwrap();
|
||||
assert_eq!((stroke.width, dash.pattern, dash.offset), (20.0, vec![40.0, 20.0], 10.0));
|
||||
// Read as if its page had no `UserUnit`, the editing data is for another page.
|
||||
let (_, w, read) = crate::layered_ai(&private(&art), page.clone(), vec![], false, false, 1.0);
|
||||
assert!(!read && w.iter().any(|w| w.contains("different page")), "{w:?}");
|
||||
// Editing data with the page in points.
|
||||
let points = native(&layer("Jig", true, "0 0 1 0 k\n100 100 m\n140 100 L\n140 140 L\n100 140 L\nf")).replace(" 0 0 100 100", " 0 0 1000 1000");
|
||||
let (d, w, read) = crate::layered_ai(&private(&points), page, vec![], false, false, 10.0);
|
||||
assert!(read, "{w:?}");
|
||||
assert_eq!(d.artboards[0].rect, vectorcraft_geom::Rect::new(0.0, 0.0, 1000.0, 1000.0));
|
||||
assert!(near(first(&d, 0), [100.0, 860.0, 140.0, 900.0]), "{:?}", first(&d, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ai_file_that_cant_be_read_through_its_editing_data_keeps_its_pdf_part() {
|
||||
let pdf = pdf_part(10.0, 86.0);
|
||||
// No editing data this reads.
|
||||
let (d, w, read) = crate::layered_ai(b"nothing", pdf.clone(), vec![], false, false);
|
||||
let (d, w, read) = crate::layered_ai(b"nothing", pdf.clone(), vec![], false, false, 1.0);
|
||||
assert_eq!(d.layers.len(), pdf.layers.len());
|
||||
assert!(w.is_empty() && !read);
|
||||
// Editing data that isn't for this page.
|
||||
let other = native(&layer("Jig", true, &square(10, 10))).replace("%AI3_Cropmarks: 0 0 100 100", "%AI3_Cropmarks: 0 0 50 50");
|
||||
let (d, w, _) = crate::layered_ai(&private(&other), pdf.clone(), vec![], false, false);
|
||||
let (d, w, _) = crate::layered_ai(&private(&other), pdf.clone(), vec![], false, false, 1.0);
|
||||
assert_eq!(names(&d), names(&pdf));
|
||||
assert!(w.iter().any(|w| w.contains("different page")), "{w:?}");
|
||||
}
|
||||
@@ -304,7 +349,7 @@ fn an_ai_file_read_as_its_pdf_part_keeps_its_artboard_layout() {
|
||||
(page.id, page.rect) = (2, vectorcraft_geom::Rect::new(120.0, 0.0, 220.0, 100.0));
|
||||
pdf.artboards.push(page);
|
||||
let squares = |d: &Document| d.layers[0].children().unwrap().iter().map(|n| n.visual_bounds().unwrap().origin()).collect::<Vec<_>>();
|
||||
let (d, w, read) = crate::layered_ai(&private(&unread(&[(0, 0), (0, -150)])), pdf.clone(), vec![], false, false);
|
||||
let (d, w, read) = crate::layered_ai(&private(&unread(&[(0, 0), (0, -150)])), pdf.clone(), vec![], false, false, 1.0);
|
||||
assert!(!read && w.iter().any(|w| w.contains("layers weren't read")), "{w:?}");
|
||||
let rects: Vec<_> = d.artboards.iter().map(|a| a.rect).collect();
|
||||
assert_eq!(rects, [vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0), vectorcraft_geom::Rect::new(0.0, 150.0, 100.0, 250.0)]);
|
||||
@@ -315,10 +360,18 @@ fn an_ai_file_read_as_its_pdf_part_keeps_its_artboard_layout() {
|
||||
assert!((moved[1] - vectorcraft_geom::Point::new(10.0, 160.0)).hypot() < 1e-6, "the second's goes with its page: {moved:?}");
|
||||
// Artboards that don't match the pages leave them in a row.
|
||||
for list in [&[(0, 0)][..], &[(0, 0), (0, -150), (200, 0)]] {
|
||||
let (d, _, _) = crate::layered_ai(&private(&unread(list)), pdf.clone(), vec![], false, false);
|
||||
let (d, _, _) = crate::layered_ai(&private(&unread(list)), pdf.clone(), vec![], false, false, 1.0);
|
||||
assert_eq!(d.artboards[1].rect, pdf.artboards[1].rect, "{list:?}");
|
||||
assert_eq!(d.artboards[1].name, pdf.artboards[1].name, "{list:?}");
|
||||
}
|
||||
// Pages read in points from a user space of 10 points a unit, with artboards in those units:
|
||||
// the same layout, ten times as large.
|
||||
let (d, _, read) = crate::layered_ai(&private(&unread(&[(0, 0), (0, -150)])), in_points(pdf.clone(), 10.0), vec![], false, false, 10.0);
|
||||
assert!(!read);
|
||||
let rects: Vec<_> = d.artboards.iter().map(|a| a.rect).collect();
|
||||
assert_eq!(rects, [vectorcraft_geom::Rect::new(0.0, 0.0, 1000.0, 1000.0), vectorcraft_geom::Rect::new(0.0, 1500.0, 1000.0, 2500.0)]);
|
||||
let moved = squares(&d);
|
||||
assert!((moved[1] - vectorcraft_geom::Point::new(100.0, 1600.0)).hypot() < 1e-6, "the second's art goes with its page: {moved:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -662,7 +715,7 @@ fn type_on_a_later_artboard_of_an_ai_file_goes_to_its_text_object() {
|
||||
let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(178.5, 50.0), "Hi there", style);
|
||||
let (id, layer_id) = (pdf.alloc_id(), pdf.layers[0].id);
|
||||
pdf.insert(Some(layer_id), 1, Node::new(id, NodeKind::Text(Box::new(text)))).unwrap();
|
||||
let (d, w, read) = crate::layered_ai(&private(&art), pdf, vec![], false, false);
|
||||
let (d, w, read) = crate::layered_ai(&private(&art), pdf, vec![], false, false, 1.0);
|
||||
assert!(read, "{w:?}");
|
||||
assert_eq!(d.artboards[1].rect, vectorcraft_geom::Rect::new(0.0, 150.0, 100.0, 250.0));
|
||||
let texts = text_of(&d.layers[0]);
|
||||
@@ -683,7 +736,7 @@ fn a_global_colour_or_a_pattern_fill_keeps_the_sublayers_of_an_ai_file() {
|
||||
let patterned = "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n50 50 m\n70 50 L\n70 70 L\n50 70 L\nf";
|
||||
let ground = layer("Ground", true, &(layer("Lawn", true, yellow) + &layer("Paving", false, patterned)));
|
||||
let art = native(&format!("{dots}{ground}"));
|
||||
let (d, warnings, _) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false);
|
||||
let (d, warnings, _) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0);
|
||||
assert_eq!(names(&d), ["Ground"], "{warnings:?}");
|
||||
let subs: Vec<_> = d.layers[0].children().unwrap().iter().map(|l| (l.name.clone().unwrap_or_default(), kinds(l))).collect();
|
||||
assert_eq!(subs, [("Lawn".to_string(), vec!["path"]), ("Paving".to_string(), vec!["path"])]);
|
||||
@@ -705,9 +758,9 @@ fn a_global_colour_or_a_pattern_fill_keeps_the_sublayers_of_an_ai_file() {
|
||||
fn a_trusted_editing_copy_is_used_where_it_draws_differently_from_the_pdf_part() {
|
||||
let extra = "0 0 1 0 k\n50 50 m\n70 50 L\n70 70 L\n50 70 L\nf";
|
||||
let art = native(&layer("Back", true, &(square(10, 10) + "\n" + extra)));
|
||||
let (d, w, _) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false);
|
||||
let (d, w, _) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0);
|
||||
assert_ne!(names(&d), ["Back"], "untrusted, the PDF part is used: {w:?}");
|
||||
let (d, w, _) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, true);
|
||||
let (d, w, _) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, true, 1.0);
|
||||
assert_eq!(names(&d), ["Back"], "{w:?}");
|
||||
assert_eq!(d.layers[0].children().unwrap().len(), 2);
|
||||
assert!(w.iter().any(|w| w.contains("differs from the page's") && w.contains("all the same")), "{w:?}");
|
||||
@@ -782,12 +835,12 @@ fn type_asked_for_as_outlines_leaves_a_file_whose_type_shows_as_its_pdf_part() {
|
||||
let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
|
||||
// Type that shows: the PDF part's outlines are its type, which the text objects can't hold.
|
||||
let shown = native_with_text(&layer("Art", true, &(square(10, 10) + "\n" + &text_object(0))), &doc);
|
||||
let (d, w, _) = crate::layered_ai(&private(&shown), pdf_part(10.0, 86.0), vec![], true, false);
|
||||
let (d, w, _) = crate::layered_ai(&private(&shown), pdf_part(10.0, 86.0), vec![], true, false, 1.0);
|
||||
assert_eq!(names(&d), ["Layer 1"], "{w:?}");
|
||||
assert!(w.iter().any(|w| w.contains("layers weren't read") && w.contains("outlines")), "{w:?}");
|
||||
// Type only on a hidden layer: the layers come in.
|
||||
let hidden = native_with_text(&(layer("Art", true, &square(10, 10)) + &layer("Notes", false, &text_object(0))), &doc);
|
||||
let (d, w, _) = crate::layered_ai(&private(&hidden), pdf_part(10.0, 86.0), vec![], true, false);
|
||||
let (d, w, _) = crate::layered_ai(&private(&hidden), pdf_part(10.0, 86.0), vec![], true, false, 1.0);
|
||||
assert_eq!(names(&d), ["Art", "Notes"], "{w:?}");
|
||||
}
|
||||
|
||||
|
||||
@@ -453,7 +453,8 @@ fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_p
|
||||
}
|
||||
}
|
||||
doc.limit_depth_noting(&mut warnings);
|
||||
Ok(ImportReport { document: doc, warnings, native: crate::editing::editing_in(&pdf), only_text })
|
||||
let user_unit = picked.first().and_then(|&n| pages.get(n)).map_or(1.0, |page| f64::from(page.user_unit()));
|
||||
Ok(ImportReport { document: doc, warnings, native: crate::editing::editing_in(&pdf), only_text, user_unit })
|
||||
}
|
||||
|
||||
/// The characters of a CID-keyed CFF font embedded without a ToUnicode map (as macOS writes
|
||||
|
||||
@@ -179,6 +179,10 @@ pub struct ImportReport {
|
||||
/// Every page shows only text, over an editor's private data: that data, where it reads,
|
||||
/// is the file's art (a file saved without its PDF part shows a notice there instead).
|
||||
pub only_text: bool,
|
||||
/// The size in points of a unit of the first page read's user space: the `UserUnit` the PDF
|
||||
/// reader applies to that page (1 for a page without one). The page came in at its size in
|
||||
/// points, its numbers times this.
|
||||
pub user_unit: f64,
|
||||
}
|
||||
|
||||
/// Result of an export with non-fatal warnings (features approximated or dropped).
|
||||
|
||||
@@ -16,13 +16,14 @@ use crate::{Choice, CropTo, ImportOptions, PdfError};
|
||||
/// What `info` tells about one page.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PageInfo {
|
||||
/// The page's size in points as viewers show it (its crop box, rotated).
|
||||
/// The page's size in points as viewers show it (its crop box, rotated, times the `UserUnit`
|
||||
/// the PDF reader applies to the page).
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
/// The page's rotation in degrees (0, 90, 180 or 270).
|
||||
pub rotation: u16,
|
||||
/// Every box but Bounding, in PDF user space (y up), clipped to the media box. A missing
|
||||
/// bleed, trim or art box is the crop box.
|
||||
/// Every box but Bounding, in PDF user space (y up), clipped to the media box, times the same
|
||||
/// `UserUnit` (in points, as the size). A missing bleed, trim or art box is the crop box.
|
||||
pub boxes: Vec<(CropTo, Rect)>,
|
||||
}
|
||||
|
||||
@@ -221,7 +222,8 @@ pub fn info(bytes: &[u8], password: Option<&str>) -> Result<PdfInfo, PdfError> {
|
||||
craft_pdf::syntax::page::Rotation::Flipped => 180,
|
||||
craft_pdf::syntax::page::Rotation::FlippedHorizontal => 270,
|
||||
};
|
||||
let boxes = CropTo::ALL.iter().filter(|c| **c != CropTo::Bounding).map(|c| (*c, page_box(page, *c))).collect();
|
||||
let unit = f64::from(page.user_unit());
|
||||
let boxes = CropTo::ALL.iter().filter(|c| **c != CropTo::Bounding).map(|c| (*c, page_box(page, *c).scale_from_origin(unit))).collect();
|
||||
PageInfo { width: w as f64, height: h as f64, rotation, boxes }
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use vectorcraft_doc::{Document, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_testkit::pdf::{PdfPage, pdf};
|
||||
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf, pdf_with};
|
||||
|
||||
use crate::*;
|
||||
|
||||
@@ -210,6 +210,42 @@ fn info_lists_pages_with_their_size_rotation_and_boxes() {
|
||||
assert!(matches!(info(b"not a pdf", None), Err(PdfError::Parse(_))));
|
||||
}
|
||||
|
||||
/// The edges of `r`, for [`near`].
|
||||
fn edges(r: Rect) -> [f64; 4] {
|
||||
[r.x0, r.y0, r.x1, r.y1]
|
||||
}
|
||||
|
||||
/// `info` gives a page's boxes in points, as its size: a page whose `UserUnit` is 10 has them at
|
||||
/// ten times the file's numbers, as the artboard and bleed that Crop To trim makes of them.
|
||||
/// Whatever the page's `UserUnit` entry, its media box has the size `info` gives.
|
||||
#[test]
|
||||
fn info_gives_the_boxes_of_a_user_unit_page_in_points() {
|
||||
// A 5 × 7 inch trim box with an eighth of an inch of bleed round it (0.9 units of 10 points).
|
||||
let page = |entries: &str| PdfPage {
|
||||
trim: Some([0.9, 0.9, 36.9, 51.3]),
|
||||
bleed: Some([0.0, 0.0, 37.8, 52.2]),
|
||||
entries: entries.into(),
|
||||
..PdfPage::new(37.8, 52.2, "0 g 2 2 10 8 re f")
|
||||
};
|
||||
// The `UserUnit` given directly, and as an indirect object.
|
||||
for bytes in [pdf(&[page("/UserUnit 10")], None), pdf_with(&[page(&format!("/UserUnit {} 0 R", first_extra(1)))], &["10"], None)] {
|
||||
let p = &info(&bytes, None).unwrap().pages[0];
|
||||
assert!((p.width - 378.0).abs() < 1e-3 && (p.height - 522.0).abs() < 1e-3, "{p:?}");
|
||||
let b = |c: CropTo| p.boxes.iter().find(|(k, _)| *k == c).map(|(_, r)| edges(*r)).unwrap();
|
||||
assert!(near(b(CropTo::Media), [0.0, 0.0, 378.0, 522.0]) && near(b(CropTo::Trim), [9.0, 9.0, 369.0, 513.0]), "{p:?}");
|
||||
let d = open(&bytes, ImportOptions { crop: CropTo::Trim, ..Default::default() });
|
||||
assert!(near(edges(d.artboards[0].rect), [0.0, 0.0, 360.0, 504.0]), "{:?}", d.artboards[0].rect);
|
||||
assert!(near(d.setup.bleed, [9.0; 4]), "{:?}", d.setup.bleed);
|
||||
}
|
||||
for unit in
|
||||
["", "/UserUnit 1", "/UserUnit 2.5", "/UserUnit 0.5", "/UserUnit 0", "/UserUnit -10", "/UserUnit /Ten", "/UserUnit (10)", "/UserUnit 1000000"]
|
||||
{
|
||||
let p = &info(&pdf(&[page(unit)], None), None).unwrap().pages[0];
|
||||
let media = p.boxes.iter().find(|(k, _)| *k == CropTo::Media).map(|(_, r)| *r).unwrap();
|
||||
assert!((media.width() / p.width - 1.0).abs() < 1e-6 && (media.height() / p.height - 1.0).abs() < 1e-6, "{unit}: {p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_without_area_gets_the_size_viewers_give_it() {
|
||||
let bytes = pdf(&[PdfPage { media: [0.0, 0.0, 0.0, 0.0], ..PdfPage::new(0.0, 0.0, "") }], None);
|
||||
|
||||
+5
-1
@@ -544,7 +544,11 @@ Opening a PDF (or `.ai`) imports every page as an artboard and layer; `document.
|
||||
`cropTo` (`bounding` (the art's bounds), `art`, `crop` (default), `trim`, `bleed`, `media`: the box each artboard gets;
|
||||
with `trim` the bleed box round it becomes the document's bleed, and a PDF-compatible `.ai` or a PDF saved with its editing data opens on its trim boxes for
|
||||
`crop`, its crop box taking the bleed in) and `password` for an encrypted file. `document.pdfInfo` reads a file without opening it: the page count, each page's
|
||||
size and boxes, `needsPassword`, and with `thumbnail: n` a PNG of page n. Imported colours keep their model:
|
||||
size and boxes, `needsPassword`, and with `thumbnail: n` a PNG of page n. A page whose `UserUnit` (PDF 1.6) is above 1
|
||||
opens at its size in points, the file's numbers times the `UserUnit` (one above 75,000 counts as 75,000), and
|
||||
`document.pdfInfo` gives its size and boxes in points too. A `.ai` whose editing data has its artboards in the units of
|
||||
such a page opens from that data at the page's size.
|
||||
Imported colours keep their model:
|
||||
DeviceCMYK and CMYK ICC colours stay CMYK (a file painted mostly in CMYK opens as a CMYK document), DeviceGray is Gray,
|
||||
and Separation and DeviceN inks become spot swatches the art links to at its tint (gradient stops too). CMYK images
|
||||
(DeviceCMYK, or ICC-based with four components) keep their ink amounts: a CMYK JPEG as it is, any other as a CMYK TIFF
|
||||
|
||||
@@ -541,6 +541,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a `<switch>`) wraps them, hidden ones hidden, and a file whose art sits loose under a root `<svg>` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); a `.ai` on its trim boxes with the bleed boxes round them as the document's bleed, as Crop To › Trim opens a PDF (#1266); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs and the images in an `.ai` or EPS file's editing data, an alpha channel coming in as a grayscale opacity mask) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). Overprint is kept from PDF import (opened, the PDF part of an `.ai`, placed) to PDF export: the graphics state's `/OP` and `/op` become the overprint of fills, strokes and type, so a spot colour over a process colour still overprints; a page whose overprint can't be read for certain knocks out with a note, as does `/OPM 0` being read as nonzero overprint (#1388; hand-written fixtures, `pdf/src/tests_import_overprint.rs`). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask.
|
||||
SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the `<style>` rules for its id, its classes and its element name, and the rules whose selector ends in none of these (`*`, `[x]`).
|
||||
A PDF saved with Illustrator's editing data opens through it as a PDF-compatible `.ai` does, with its layers and groups and with art reaching past its page unclipped; such a PDF holds the data in `AIPDFPrivateData` streams (part of #868). A shape a PDF or `.ai` paints with a gradient (a clip path round a shading) opens as one path filled with the gradient, not a clip group round a rectangle, so Shape Builder, Live Paint and Pathfinder work on its outline (#1304).
|
||||
A PDF page whose `UserUnit` (PDF 1.6) is above 1 opens at its size in points, its numbers times the `UserUnit` (one above 75,000 counts as 75,000), and `document.pdfInfo` gives its size and boxes in points too. A `.ai` whose editing data has its artboards in the units of such a page opens from that data at the page's size, with its layers, and when the layers can't be read its pages still take the artboard layout of the data, scaled the same way (part of #1482).
|
||||
Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document or at its path is read as the document opens). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests_links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392).
|
||||
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part; CCITT Group 3 and planar files with extra channels are not read); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546).
|
||||
- DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked; CIRCLE/ARC fitting, CNC preset and VCarve QA remain pending.
|
||||
|
||||
Reference in New Issue
Block a user