mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 16:33:22 -06:00
.ai import: paths keep their notes and Show Center
Squashed from #5129 onto main.
This commit is contained in:
@@ -42,6 +42,8 @@ pub(super) struct Token<'a> {
|
||||
const PREVIEW: (&[u8], &[u8]) = (b"%AI26_BeginPlacedObjectPreview", b"%AI26_EndPlacedObjectPreview");
|
||||
/// A placed file's markers, which a hidden one has on `%_` lines.
|
||||
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:"];
|
||||
/// A binary section skipped whole: (begin, end) markers.
|
||||
const DOCUMENT: (&[u8], &[u8]) = (b"%%BeginDocument", b"%%EndDocument");
|
||||
|
||||
@@ -144,8 +146,9 @@ impl<'a> Lexer<'a> {
|
||||
if start {
|
||||
return Some(Token { tok: Tok::Comment(line.get(1..).unwrap_or_default()), hidden: false });
|
||||
}
|
||||
// So does a hidden placed file: its `~` ends what `%_%AI5_BeginPlace` begins.
|
||||
if prefixed && PLACE.iter().any(|m| line.starts_with(m)) {
|
||||
// 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)) {
|
||||
return Some(Token { tok: Tok::Comment(line.get(1..).unwrap_or_default()), hidden: true });
|
||||
}
|
||||
// A comment later in a line.
|
||||
|
||||
@@ -233,6 +233,11 @@ impl<'a> Reader<'a> {
|
||||
.into();
|
||||
copy
|
||||
});
|
||||
// The note and Show Center are the path's, not its painted copy's.
|
||||
node.edit_attrs(|a| {
|
||||
a.note = gs.note.clone().unwrap_or_default();
|
||||
a.show_center = gs.show_center;
|
||||
});
|
||||
let unread_bounds = unread.then(|| node.visual_bounds()).flatten();
|
||||
let unread_id = node.id;
|
||||
self.add(node, hidden)?;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::art::{four, sane, two};
|
||||
use super::types::{FLIP, Kind, MAX_HEX, MAX_PATTERNS, MAX_STACK, MAX_SUBREADERS, PatternHead, Place, Raster, SKIPPED, VERSIONED_UNENDED};
|
||||
use super::types::{FLIP, Kind, MAX_HEX, MAX_NOTE, MAX_PATTERNS, MAX_STACK, MAX_SUBREADERS, PatternHead, Place, Raster, SKIPPED, VERSIONED_UNENDED};
|
||||
use super::{Arc, Node, PatternDef, Reader, Rect, Tok, V, marker};
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
@@ -49,6 +49,9 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if self.note_comment(c) {
|
||||
return Ok(());
|
||||
}
|
||||
let m = marker(line);
|
||||
// A pattern's definition (one inside another's isn't read).
|
||||
if m == "BeginPattern" && self.pattern.is_none() {
|
||||
@@ -157,6 +160,44 @@ impl<'a> Reader<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `%AI3_Note:` (or `%%Note:`) sets the note of the paths that follow, an empty one clearing it,
|
||||
/// and `%AI5_NoteMore:` adds a line to it. The text is kept as written, up to [`MAX_NOTE`]
|
||||
/// characters.
|
||||
fn note_comment(&mut self, c: &[u8]) -> bool {
|
||||
let (text, more) = if let Some(t) = c.strip_prefix(b"AI3_Note:").or_else(|| c.strip_prefix(b"%Note:")) {
|
||||
(t, false)
|
||||
} else if let Some(t) = c.strip_prefix(b"AI5_NoteMore:") {
|
||||
(t, true)
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
// Only as much of the line as the note can keep is decoded; a UTF-8 character the cut
|
||||
// splits is left out.
|
||||
let mut head = text.get(..text.len().min(4 * MAX_NOTE)).unwrap_or(text);
|
||||
if head.len() < text.len()
|
||||
&& let Err(e) = std::str::from_utf8(head)
|
||||
&& e.error_len().is_none()
|
||||
{
|
||||
head = head.get(..e.valid_up_to()).unwrap_or(head);
|
||||
}
|
||||
let text = super::obj::text(head);
|
||||
match (&mut self.gs.note, more) {
|
||||
(Some(note), true) => {
|
||||
note.push('\n');
|
||||
note.push_str(&text);
|
||||
}
|
||||
// A continuation with no note to continue.
|
||||
(None, true) => return true,
|
||||
(note, false) => *note = Some(text),
|
||||
}
|
||||
if let Some(note) = self.gs.note.as_mut()
|
||||
&& let Some((at, _)) = note.char_indices().nth(MAX_NOTE)
|
||||
{
|
||||
note.truncate(at);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Skip to the comment with marker `end` (sections of the same kind nested in it count, and
|
||||
/// versioned content in an alternate).
|
||||
fn skip_to(&mut self, end: &str) -> Result<(), String> {
|
||||
|
||||
@@ -158,6 +158,13 @@ impl<'a> Reader<'a> {
|
||||
"O" => self.gs.overprint_fill = Self::nums(&vals).last() == Some(&1.0),
|
||||
"R" => self.gs.overprint_stroke = Self::nums(&vals).last() == Some(&1.0),
|
||||
"XR" => self.gs.rule = if Self::nums(&vals).last() == Some(&1.0) { FillRule::EvenOdd } else { FillRule::NonZero },
|
||||
// Show Center (§12): 1 shows the center point of the paths that follow, and 0 leaves
|
||||
// them at their default, which hides it on an imported path.
|
||||
"Ap" => match Self::nums(&vals).last().copied() {
|
||||
Some(1.0) => self.gs.show_center = Some(true),
|
||||
Some(0.0) => self.gs.show_center = None,
|
||||
_ => {}
|
||||
},
|
||||
"w" => {
|
||||
if let Some(v) = Self::nums(&vals).last().filter(|v| v.is_finite() && **v >= 0.0) {
|
||||
self.gs.width = *v;
|
||||
|
||||
@@ -40,6 +40,9 @@ pub(crate) const FLIP: Affine = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
|
||||
/// Most patterns read (others can't fill art).
|
||||
pub(super) const MAX_PATTERNS: usize = 4096;
|
||||
|
||||
/// Most characters of a path's note read (the version 7 specification allows 254, §5.3.1).
|
||||
pub(super) const MAX_NOTE: usize = 1024;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct GState {
|
||||
pub(crate) fill: Paint,
|
||||
@@ -59,6 +62,11 @@ pub(crate) struct GState {
|
||||
pub(crate) overprint_stroke: bool,
|
||||
pub(super) locked: bool,
|
||||
pub(super) hidden: bool,
|
||||
/// The note (`%AI3_Note:`) the paths that follow get, until another replaces it (`None`: no
|
||||
/// note comment yet; empty: none).
|
||||
pub(super) note: Option<String>,
|
||||
/// Show Center (`1 Ap`) for the paths that follow (`None`: the path's default).
|
||||
pub(super) show_center: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for GState {
|
||||
@@ -82,6 +90,8 @@ impl Default for GState {
|
||||
overprint_stroke: false,
|
||||
locked: false,
|
||||
hidden: false,
|
||||
note: None,
|
||||
show_center: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,7 +232,6 @@ pub(super) const LINKED_PREVIEWS: &str =
|
||||
pub(super) const IGNORED: &[&str] = &[
|
||||
"Ae",
|
||||
"AE",
|
||||
"Ap",
|
||||
"As",
|
||||
"Xd",
|
||||
"Xr",
|
||||
|
||||
@@ -118,6 +118,109 @@ fn groups_names_hidden_and_locked() {
|
||||
assert!(!hidden.visible);
|
||||
}
|
||||
|
||||
/// A node's Attributes panel note (empty: none).
|
||||
fn note(n: &Node) -> String {
|
||||
n.extras().attrs.as_deref().map(|a| a.note.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The notes of the first layer's objects, in order.
|
||||
fn notes(doc: &Document) -> Vec<String> {
|
||||
art(doc).iter().map(|n| note(n)).collect()
|
||||
}
|
||||
|
||||
/// A note comment names the paths after it until another one replaces it, and an empty one clears
|
||||
/// it (§5.3.1: `%AI3_Note: <note>`).
|
||||
#[test]
|
||||
fn a_note_comment_is_the_note_of_the_paths_after_it() {
|
||||
let [a, b, c, d] = [0.0, 20.0, 40.0, 60.0].map(|x| rect_path(x, 0.0, x + 10.0, 10.0));
|
||||
let body = format!("%AI3_Note:hello\n{a}f\n{b}f\n%AI3_Note:\n{c}f\n%AI3_Note:world\n{d}f\n");
|
||||
assert_eq!(notes(&read_ok(&layer(&body)).doc), ["hello", "hello", "", "world"]);
|
||||
// `%%Note:` sets it too.
|
||||
assert_eq!(notes(&read_ok(&layer(&format!("%%Note:old style\n{a}f\n"))).doc), ["old style"]);
|
||||
// A path without a note has no attributes.
|
||||
assert!(art(&read_ok(&layer(&format!("{a}f\n"))).doc)[0].extras().attrs.is_none());
|
||||
}
|
||||
|
||||
/// `%AI5_NoteMore:` adds a line to the note; the text is kept as written, and its bytes are read as
|
||||
/// UTF-8, else Windows-1252.
|
||||
#[test]
|
||||
fn a_note_continues_on_more_lines_and_keeps_its_text() {
|
||||
let a = rect_path(0.0, 0.0, 10.0, 10.0);
|
||||
let body = format!("%AI3_Note: two (words) caf@ \n%AI5_NoteMore:more\n{a}f\n");
|
||||
let data = stream(&layer(&body)).replace('@', "\u{1}").into_bytes();
|
||||
let data: Vec<u8> = data.into_iter().map(|b| if b == 1 { 0xE9 } else { b }).collect();
|
||||
assert_eq!(notes(&read(&data).unwrap().doc), [" two (words) café \nmore"]);
|
||||
// A continuation of an empty note is its second line; one with no note before it is ignored.
|
||||
assert_eq!(notes(&read_ok(&layer(&format!("%AI3_Note:\n%AI5_NoteMore:x\n{a}f\n"))).doc), ["\nx"]);
|
||||
assert_eq!(notes(&read_ok(&layer(&format!("%AI5_NoteMore:x\n{a}f\n"))).doc), [""]);
|
||||
// A note too long is cut at 1,024 characters, however many lines it has.
|
||||
let long = format!("%AI3_Note:{}\n{}{a}f\n", "x".repeat(5000), "%AI5_NoteMore:yy\n".repeat(3000));
|
||||
assert_eq!(notes(&read_ok(&layer(&long)).doc)[0].chars().count(), 1024);
|
||||
let long = format!("%AI3_Note:x\n{}{a}f\n", "%AI5_NoteMore:yy\n".repeat(3000));
|
||||
assert_eq!(notes(&read_ok(&layer(&long)).doc)[0].chars().count(), 1024);
|
||||
// A long UTF-8 line stays UTF-8 where it is cut.
|
||||
let long = format!("%AI3_Note:{}\n{a}f\n", "é".repeat(3000));
|
||||
assert_eq!(notes(&read_ok(&layer(&long)).doc), ["é".repeat(1024)]);
|
||||
// A note on a `%_` line is read too.
|
||||
assert_eq!(notes(&read_ok(&layer(&format!("%_%AI3_Note:hidden\n{a}f\n"))).doc), ["hidden"]);
|
||||
}
|
||||
|
||||
/// A note before a group or a compound path isn't theirs: their paths keep their own.
|
||||
#[test]
|
||||
fn a_note_before_a_group_or_compound_path_is_not_theirs() {
|
||||
let [a, b] = [0.0, 20.0].map(|x| rect_path(x, 0.0, x + 10.0, 10.0));
|
||||
for (open, close) in [("u", "U"), ("*u", "*U")] {
|
||||
let body = format!("%AI3_Note:outer\n0 Ae\n{open}\n%AI3_Note:inner\n{a}f\n{b}f\n{close}\n");
|
||||
let doc = read_ok(&layer(&body)).doc;
|
||||
let top = &art(&doc)[0];
|
||||
assert_eq!(note(top), "", "{open}");
|
||||
let kids: Vec<String> = top.children().unwrap().iter().map(|k| note(k)).collect();
|
||||
assert_eq!(kids, ["inner", "inner"], "{open}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A clipping path's note is its own, not that of the copy that paints its fill.
|
||||
#[test]
|
||||
fn a_clipping_paths_note_is_not_copied_to_its_painted_fill() {
|
||||
let body =
|
||||
format!("0 Ae\nq\n{RED}%AI3_Note:clip\n{}W\nf\n%AI3_Note:\n{}f\nQ\n", rect_path(5.0, 5.0, 25.0, 25.0), rect_path(0.0, 0.0, 30.0, 30.0));
|
||||
let doc = read_ok(&layer(&body)).doc;
|
||||
fn walk(nodes: &[Arc<Node>], out: &mut Vec<(bool, String)>) {
|
||||
for n in nodes {
|
||||
out.push((matches!(n.kind, NodeKind::Path { clipping: true, .. }), note(n)));
|
||||
walk(n.children().map(Vec::as_slice).unwrap_or_default(), out);
|
||||
}
|
||||
}
|
||||
let mut all = Vec::new();
|
||||
walk(&art(&doc), &mut all);
|
||||
let noted: Vec<_> = all.iter().filter(|(_, n)| n == "clip").collect();
|
||||
assert_eq!(noted, [&(true, "clip".to_string())], "{all:?}");
|
||||
assert!(all.len() >= 3, "the painted copy is there: {all:?}");
|
||||
}
|
||||
|
||||
/// `1 Ap` shows the center point of the paths after it until `0 Ap` (§12, showCenter), which leaves
|
||||
/// them at an imported path's default: hidden.
|
||||
#[test]
|
||||
fn ap_shows_the_center_of_the_paths_after_it() {
|
||||
let [a, b, c] = [0.0, 20.0, 40.0].map(|x| rect_path(x, 0.0, x + 10.0, 10.0));
|
||||
let ellipse = "50 0 m\n50 5.5 45.5 10 40 10 c\n34.5 10 30 5.5 30 0 c\n30 -5.5 34.5 -10 40 -10 c\n45.5 -10 50 -5.5 50 0 c\n";
|
||||
let body = format!("1 Ap\n{a}f\n{b}f\n0 Ap\n{c}f\n{ellipse}f\n2 Ap\n{a}f\n");
|
||||
let doc = read_ok(&layer(&body)).doc;
|
||||
let shown: Vec<_> = art(&doc).iter().map(|n| (n.extras().attrs.as_deref().and_then(|a| a.show_center), n.shows_center())).collect();
|
||||
assert_eq!(shown, [(Some(true), true), (Some(true), true), (None, false), (None, false), (None, false)]);
|
||||
}
|
||||
|
||||
/// What a skipped section sets doesn't reach the art after it.
|
||||
#[test]
|
||||
fn a_note_or_ap_in_a_skipped_section_does_not_reach_the_art_after_it() {
|
||||
let a = rect_path(0.0, 0.0, 10.0, 10.0);
|
||||
let body = format!("%AI3_BeginBrushPattern\n1 Ap\n%AI3_Note:brush\n{a}f\n%AI3_EndBrushPattern\n{}", layer(&format!("{a}f\n")));
|
||||
let doc = read_ok(&body).doc;
|
||||
let n = &art(&doc)[0];
|
||||
assert_eq!(note(n), "");
|
||||
assert!(!n.shows_center());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_colours_and_strokes() {
|
||||
let art_ = format!(
|
||||
|
||||
@@ -257,6 +257,22 @@ fn a_version_7_palette_keeps_its_swatches_without_names() {
|
||||
assert_eq!(d.swatch("Gray K=0").unwrap().paint.color(), Some(Color::gray(0.0)), "1 g is white");
|
||||
}
|
||||
|
||||
/// A version 8 file's paths keep their notes and Show Center.
|
||||
#[test]
|
||||
fn a_legacy_files_paths_keep_their_notes_and_show_center() {
|
||||
let body = format!(
|
||||
"%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Notes) Ln\n0 A\n%AI3_Note:first\n%AI5_NoteMore:second line\n1 Ap\n0 0 0 1 k\n{}\n\
|
||||
%AI3_Note:\n0 Ap\n{}\nLB\n%AI5_EndLayer--\n",
|
||||
tri(10),
|
||||
tri(40)
|
||||
);
|
||||
let r = import(&ai(AI8, "", &body)).unwrap();
|
||||
let kids = r.document.layers[0].children().unwrap();
|
||||
let attrs: Vec<_> = kids.iter().map(|n| n.extras().attrs.as_deref().map(|a| (a.note.clone(), a.show_center))).collect();
|
||||
assert_eq!(attrs, [Some(("first\nsecond line".into(), Some(true))), None], "{:?}", r.warnings);
|
||||
assert!(kids[0].shows_center() && !kids[1].shows_center());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbalanced_and_runaway_groups_are_read_safely() {
|
||||
// Ends without a beginning end nothing; groups left open hold the rest.
|
||||
|
||||
Reference in New Issue
Block a user