mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-07 16:52:47 +00:00
Merge pull request #5176 from dotCooCoo/fix/ai-os19
.ai import: placed files are found by the folder they keep relative to the document
This commit is contained in:
@@ -585,3 +585,44 @@ fn links_of_an_imported_file_are_read_where_they_are_found() {
|
||||
let (_, im) = placed(&s);
|
||||
assert!((im.width, im.height) == (600, 300) && !s.doc().unwrap().doc.images[&im.key].is_proxy(), "{im:?}");
|
||||
}
|
||||
|
||||
/// A placed file of a `.ai` file whose folder moved with the document is found by the folder the
|
||||
/// file keeps relative to the document's (`src/`), and its link takes the size, modification time
|
||||
/// and hash of the file found there. Not found, the link keeps no such details.
|
||||
#[test]
|
||||
fn links_of_an_imported_file_are_found_by_their_relative_folder() {
|
||||
use vectorcraft_testkit::ai;
|
||||
let dir = Folder::new("imported-relative");
|
||||
std::fs::create_dir_all(dir.0.join("src")).unwrap();
|
||||
let pic = dir.file("src/photo.png").replace('\\', "/");
|
||||
let place = "%AI5_BeginPlace\n%%FileType: 1\n[0.1 0 0 -0.1 20 80] 0 0 600 300 4 4 0\n(/no/such/folder/src/photo.png)`\n\
|
||||
%%IncludeFile:/no/such/folder/src/photo.png\n%AI17_Begin_Content_if_version_gt:24 12\n%AI28_BeginPlacedRelativePath\n(src/)\n\
|
||||
%AI28_EndPlacedRelativePath\n%AI17_Alternate_Content\n%AI17_End_Versioned_Content\n%AI26_BeginPlacedObjectPreview\n\
|
||||
2 1 8 32 17 \x7f\x10\x10\x60\x7f\x10\x10\x60\n%AI26_EndPlacedObjectPreview\n~\n%AI5_EndPlace\n";
|
||||
let doc_path = dir.file("poster.ai");
|
||||
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", place));
|
||||
write(&doc_path, &ai::ai(data.as_bytes(), "0.56 0.56 0.87 rg 20 50 60 30 re f"));
|
||||
let link = |s: &Session| {
|
||||
let mut found = vec![];
|
||||
s.doc().unwrap().doc.visit_images(|_, im| found.push(im.clone()));
|
||||
let [im] = &found[..] else { panic!("one image: {found:?}") };
|
||||
(im.link.clone().unwrap(), s.doc().unwrap().doc.images[&im.key].is_proxy())
|
||||
};
|
||||
let mut s = session();
|
||||
|
||||
let r = open(&mut s, &doc_path);
|
||||
assert_eq!(r["missingLinks"][0]["name"].as_str(), Some("photo.png"), "{r}");
|
||||
let (l, proxy) = link(&s);
|
||||
assert_eq!((l.path.as_str(), l.relative.as_deref()), ("/no/such/folder/src/photo.png", Some("src/photo.png")));
|
||||
assert!(proxy && l.size.is_none() && l.modified.is_none() && l.hash.is_none(), "{l:?}");
|
||||
|
||||
let bytes = png(600, 300, RED);
|
||||
write(&pic, &bytes);
|
||||
let r = open(&mut s, &doc_path);
|
||||
assert_eq!(&r["missingLinks"], &json!([]), "{r}");
|
||||
let (l, proxy) = link(&s);
|
||||
assert!(!proxy, "the file's pixels, not the preview");
|
||||
assert_eq!(l.path.replace('\\', "/"), pic);
|
||||
assert_eq!((l.size, l.hash.clone()), (Some(bytes.len() as u64), Some(vectorcraft_doc::links::hash_bytes(&bytes))));
|
||||
assert!(l.modified.is_some(), "{l:?}");
|
||||
}
|
||||
|
||||
@@ -3617,6 +3617,12 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
|
||||
"\n%AI26_EndPlacedObjectPreview\n",
|
||||
"[1 0 0 -1 0 0] 0 0 2 1 4 4 0 (a.png) `",
|
||||
"[1e308 0 0 1e308 0 0] 0 0 -1e308 1e308 4 4 0 (a.png) `",
|
||||
"\n%AI28_BeginPlacedRelativePath\n",
|
||||
"\n%AI28_BeginPlacedRelativePath\n(src/)\n%AI28_EndPlacedRelativePath\n",
|
||||
"\n%AI28_BeginPlacedRelativePath\n(..\\\\..\\\\art\\\\)\n%AI28_EndPlacedRelativePath\n",
|
||||
"\n%AI28_BeginPlacedRelativePath\n(C:\\\\x\\001)\n%AI28_EndPlacedRelativePath\n",
|
||||
"\n%_%AI28_BeginPlacedRelativePath\n%_(src/)\n%_%AI28_EndPlacedRelativePath\n",
|
||||
"\n%AI28_EndPlacedRelativePath\n",
|
||||
"`",
|
||||
"~",
|
||||
"\n%AI5_EndPlace\n",
|
||||
|
||||
@@ -44,6 +44,8 @@ const PREVIEW: (&[u8], &[u8]) = (b"%AI26_BeginPlacedObjectPreview", b"%AI26_EndP
|
||||
const PLACE: [&[u8]; 2] = [b"%AI5_BeginPlace", b"%AI5_EndPlace"];
|
||||
/// A path's note comments, also read on `%_` lines.
|
||||
const NOTES: [&[u8]; 3] = [b"%AI3_Note:", b"%%Note:", b"%AI5_NoteMore:"];
|
||||
/// The markers around a placed file's relative folder, which a hidden one has on `%_` lines too.
|
||||
const RELATIVE: [&[u8]; 2] = [b"%AI28_BeginPlacedRelativePath", b"%AI28_EndPlacedRelativePath"];
|
||||
/// A binary section skipped whole: (begin, end) markers.
|
||||
const DOCUMENT: (&[u8], &[u8]) = (b"%%BeginDocument", b"%%EndDocument");
|
||||
|
||||
@@ -148,7 +150,7 @@ impl<'a> Lexer<'a> {
|
||||
}
|
||||
// So does a hidden placed file: its `~` ends what `%_%AI5_BeginPlace` begins. And a
|
||||
// hidden path's note.
|
||||
if prefixed && PLACE.iter().chain(&NOTES).any(|m| line.starts_with(m)) {
|
||||
if prefixed && PLACE.iter().chain(&NOTES).chain(&RELATIVE).any(|m| line.starts_with(m)) {
|
||||
return Some(Token { tok: Tok::Comment(line.get(1..).unwrap_or_default()), hidden: true });
|
||||
}
|
||||
// A comment later in a line.
|
||||
|
||||
@@ -532,8 +532,10 @@ impl<'a> Reader<'a> {
|
||||
/// shows the preview the data keeps of it, as a linked image whose file is missing does. One
|
||||
/// without a preview, a path or a matrix isn't read.
|
||||
pub(super) fn end_place(&mut self, hidden: bool) -> Result<(), String> {
|
||||
let (m, b, path, png, w, h) = match self.place.take() {
|
||||
Some(Place { matrix: Some(m), bounds: Some(b), path, preview: Some((png, w, h)) }) if !path.is_empty() => (m, b, path, png, w, h),
|
||||
let (m, b, path, png, w, h, folder) = match self.place.take() {
|
||||
Some(Place { matrix: Some(m), bounds: Some(b), path, preview: Some((png, w, h)), folder, .. }) if !path.is_empty() => {
|
||||
(m, b, path, png, w, h, folder)
|
||||
}
|
||||
other => {
|
||||
let boxed = other.as_ref().and_then(|p| Some((p.matrix?, p.bounds?))).filter(|(_, b)| sane(*b) && b.area() > 0.0);
|
||||
let mut have_bounds = false;
|
||||
@@ -595,10 +597,9 @@ impl<'a> Reader<'a> {
|
||||
{
|
||||
self.placed_elsewhere.push((NodeId(self.doc.peek_next_id()), shift));
|
||||
}
|
||||
self.add_image(
|
||||
ImageObject { key, width: w, height: h, xf, link: Some(LinkInfo::new(path)), placement: Default::default(), pixels: None },
|
||||
hidden,
|
||||
)
|
||||
let mut link = LinkInfo::new(path);
|
||||
link.relative = folder.and_then(|f| relative_path(&f, link.name()));
|
||||
self.add_image(ImageObject { key, width: w, height: h, xf, link: Some(link), placement: Default::default(), pixels: None }, hidden)
|
||||
}
|
||||
|
||||
pub(super) fn warn(&mut self, w: &str) {
|
||||
@@ -707,6 +708,18 @@ pub(super) fn preview_png(data: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
|
||||
Some((png, width, height))
|
||||
}
|
||||
|
||||
/// A placed file's path relative to the document's folder (`/` separators): its relative `folder`
|
||||
/// (`src/`, `..\art`) followed by its file `name`. `None` when the folder is empty, absolute, or
|
||||
/// holds control characters, or when there is no name.
|
||||
pub(super) fn relative_path(folder: &str, name: &str) -> Option<String> {
|
||||
let folder = folder.replace('\\', "/");
|
||||
let absolute = folder.starts_with('/') || folder.as_bytes().get(1) == Some(&b':') || folder.contains("://");
|
||||
if folder.is_empty() || absolute || name.is_empty() || folder.chars().chain(name.chars()).any(char::is_control) {
|
||||
return None;
|
||||
}
|
||||
Some(format!("{}/{name}", folder.trim_end_matches('/')))
|
||||
}
|
||||
|
||||
pub(super) fn sane(r: Rect) -> bool {
|
||||
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_SIDE)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::art::{four, sane, two};
|
||||
use super::types::{FLIP, Kind, MAX_HEX, MAX_NOTE, MAX_PATTERNS, MAX_STACK, MAX_SUBREADERS, PatternHead, Place, Raster, SKIPPED, VERSIONED_UNENDED};
|
||||
use super::types::{
|
||||
FLIP, Kind, MAX_HEX, MAX_NOTE, MAX_PATTERNS, MAX_PLACED_FOLDER, MAX_STACK, MAX_SUBREADERS, PatternHead, Place, Raster, SKIPPED, VERSIONED_UNENDED,
|
||||
};
|
||||
use super::{Arc, Node, PatternDef, Reader, Rect, Tok, V, marker};
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
@@ -148,6 +150,23 @@ impl<'a> Reader<'a> {
|
||||
if self.place.is_some() {
|
||||
self.end_place(hidden)?;
|
||||
}
|
||||
} else if m == "BeginPlacedRelativePath" {
|
||||
let at = self.stack.len();
|
||||
if let Some(p) = self.place.as_mut() {
|
||||
p.folder_at = Some(at);
|
||||
}
|
||||
} else if m == "EndPlacedRelativePath" {
|
||||
// The folder is the string between the markers, taken off the stack.
|
||||
let base = self.frames.last().map_or(0, |f| f.base);
|
||||
if let Some(at) = self.place.as_mut().and_then(|p| p.folder_at.take()).filter(|at| (base..=self.stack.len()).contains(at)) {
|
||||
let vals = self.stack.split_off(at);
|
||||
if let Some(p) = self.place.as_mut() {
|
||||
p.folder = vals
|
||||
.iter()
|
||||
.find_map(|v| if let V::Str(s) = v { Some(super::obj::text(s)) } else { None })
|
||||
.filter(|f| f.len() <= MAX_PLACED_FOLDER);
|
||||
}
|
||||
}
|
||||
} else if self.frames.is_empty()
|
||||
&& let Some(v) = line.strip_prefix("%HiResBoundingBox:")
|
||||
{
|
||||
|
||||
@@ -237,8 +237,16 @@ pub(super) struct Place {
|
||||
pub(super) path: String,
|
||||
/// The preview as a PNG, and its size in pixels.
|
||||
pub(super) preview: Option<(Vec<u8>, u32, u32)>,
|
||||
/// The file's folder relative to the document's (`src/`), from the string between
|
||||
/// `%AI28_BeginPlacedRelativePath` and its end.
|
||||
pub(super) folder: Option<String>,
|
||||
/// The operand stack's height at `%AI28_BeginPlacedRelativePath`, until its end.
|
||||
pub(super) folder_at: Option<usize>,
|
||||
}
|
||||
|
||||
/// The longest relative folder of a placed file read (bytes).
|
||||
pub(super) const MAX_PLACED_FOLDER: usize = 4096;
|
||||
|
||||
/// What the open result says of placed files: they show the preview the file keeps of them.
|
||||
pub(super) const LINKED_PREVIEWS: &str =
|
||||
"linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel";
|
||||
|
||||
@@ -1445,6 +1445,51 @@ fn a_placed_file_on_commented_lines_is_read() {
|
||||
assert_eq!((img.get_pixel(0, 0).0, img.get_pixel(1, 0).0), ([255, 0, 0, 255], [0, 127, 0, 128]), "its preview");
|
||||
}
|
||||
|
||||
/// [`placed`]'s file whose relative folder section holds `folder` (written as it is, parentheses
|
||||
/// included) instead of `()`.
|
||||
fn placed_in(folder: &str, path: &str, preview: &[u8]) -> Vec<u8> {
|
||||
let data = placed(true, "[5 0 0 -5 0 200]", path, Some(preview), "");
|
||||
let (at, len) = (data.windows(4).position(|w| w == b"\r()\r").unwrap(), 4);
|
||||
let mut out = data[..at].to_vec();
|
||||
out.extend_from_slice(format!("\r{folder}\r").as_bytes());
|
||||
out.extend_from_slice(&data[at + len..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// The relative path of the one linked image of `data`.
|
||||
fn relative_of(data: &[u8]) -> Option<String> {
|
||||
let r = read(data).unwrap();
|
||||
let a = art(&r.doc);
|
||||
let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) };
|
||||
assert_eq!(im.link.as_ref().map(|l| l.path.as_str()), Some("/old/place/src/photo.png"));
|
||||
im.link.as_ref().and_then(|l| l.relative.clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_placed_file_keeps_its_folder_relative_to_the_document() {
|
||||
let preview = [&b"2 1 12 32 17 "[..], &[255, 255, 0, 0, 128, 0, 64, 0, 9, 9, 9, 9]].concat();
|
||||
let path = "/old/place/src/photo.png";
|
||||
assert_eq!(relative_of(&placed_in("(src/)", path, &preview)).as_deref(), Some("src/photo.png"));
|
||||
assert_eq!(relative_of(&placed_in("(../art)", path, &preview)).as_deref(), Some("../art/photo.png"));
|
||||
assert_eq!(relative_of(&placed_in(r"(..\\art\\)", path, &preview)).as_deref(), Some("../art/photo.png"));
|
||||
assert_eq!(relative_of(&placed_in("( art /)", path, &preview)).as_deref(), Some(" art /photo.png"), "spaces are part of names");
|
||||
// On `%_` lines too.
|
||||
assert_eq!(relative_of(&hide_placed(&placed_in("(src/)", path, &preview))).as_deref(), Some("src/photo.png"));
|
||||
// No folder, an absolute one, one too long or not a string: the path alone, and the file still
|
||||
// opens.
|
||||
let long = format!("({})", "a/".repeat(3000));
|
||||
for folder in ["()", "(/elsewhere/)", "(C:\\\\art\\\\)", long.as_str(), "12", "/src"] {
|
||||
assert_eq!(relative_of(&placed_in(folder, path, &preview)), None, "{folder}");
|
||||
}
|
||||
// A section that doesn't end leaves the folder unread.
|
||||
let data = placed_in("(src/)", path, &preview);
|
||||
let end = data.windows(27).position(|w| w == b"%AI28_EndPlacedRelativePath").unwrap();
|
||||
let mut cut = data[..end].to_vec();
|
||||
cut.extend_from_slice(b"%AI28_Other");
|
||||
cut.extend_from_slice(&data[end + 27..]);
|
||||
assert_eq!(relative_of(&cut), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_placed_file_ended_by_comment_stays_hidden() {
|
||||
let mut data = hide_placed(&placed(true, "[5 0 0 -5 0 200]", "/pics/photo.png", None, ""));
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1091,6 +1091,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type keeps its drawn art, with its type hidden (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).
|
||||
In a `.ai` opened from its editing data, Convert to Shape on a whole path or type object opens as a live Rectangle, Rounded Rectangle or Ellipse effect with its Absolute or Relative size, its corner radius, its place among the object's effects and whether it is hidden. A relative size in the file is added to each side. A relative Ellipse, or a setting VectorCraft doesn't read, keeps the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).
|
||||
PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); in PDF 1.3 to 1.5 files and PDF/X files such a page is written in points.
|
||||
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, at its path, or in the folder the editing data names relative to the document's, such as `src/`, is read as the document opens, and its link takes that file's size, modification time and hash). 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).
|
||||
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).
|
||||
In a `.ai` or EPS opened from its editing data, and in a legacy `.ai`, each anchor is smooth or a corner as the case of the segment operator ending on it says (`l`, `c`, `v`, `y` smooth; `L`, `C`, `V`, `Y` corner), and a closed path's first anchor takes the case of the segment back to it; points and handles stay where they are. Paths, compound paths, clipping paths, guide paths, area type frames and type-on-a-path paths all keep it (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/legacy_ai/tests.rs`).
|
||||
`.ai` and EPS editing-data import keeps template layers, and opens canvas and artboard ruler guides as ruler guides on their layers; guide objects stay editable guide paths (#779; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).
|
||||
|
||||
Reference in New Issue
Block a user