Merge pull request #4547 from ham9zah/fix/ai-versioned-content

Import: dimension labels from Illustrator files come in once, right to left
This commit is contained in:
BFlat
2026-10-29 19:11:05 +07:00
committed by GitHub
10 changed files with 199 additions and 28 deletions
+5
View File
@@ -92,6 +92,11 @@ impl<'a> Lexer<'a> {
self.pos
}
/// Look for the next token at `pos` again (the end of the data at most).
pub fn seek(&mut self, pos: usize) {
self.pos = pos.min(self.src.len());
}
/// The data from `from` to `to` (empty where that isn't in it).
pub fn slice(&self, from: usize, to: usize) -> &'a [u8] {
self.src.get(from..to).unwrap_or_default()
+26 -7
View File
@@ -69,19 +69,25 @@ pub(super) fn palette(data: &[u8]) -> Palette {
let mut cell: Option<Cell> = None;
// Inside a gradient's `Bb` … `BB`.
let mut in_gradient = false;
// Inside versioned content's alternate, which repeats what came before it.
let mut alternate = false;
// How deep in versioned content, and the depth of the block whose later alternatives (which
// repeat its first) are being skipped, nested blocks in them included.
let (mut depth, mut skipping) = (0usize, None::<usize>);
while let Some(t) = lex.next_token() {
if let Tok::Comment(c) = t.tok {
let line = String::from_utf8_lossy(c);
if line.contains("_Alternate_Content") {
alternate = true;
} else if line.contains("_End_Versioned_Content") || line.contains("_Begin_Content_if_version") {
alternate = false;
if line.contains("_Begin_Content_if_version") {
depth = depth.saturating_add(1);
} else if line.contains("_Alternate_Content") {
skipping = skipping.or(Some(depth));
} else if line.contains("_End_Versioned_Content") {
if skipping == Some(depth) {
skipping = None;
}
depth = depth.saturating_sub(1);
}
continue;
}
if alternate {
if skipping.is_some() {
continue;
}
let w = match t.tok {
@@ -211,6 +217,19 @@ mod tests {
assert_eq!(p.swatches.len(), 1);
}
/// Three alternatives, and versioned content nested in a later one: still one swatch.
#[test]
fn versioned_swatches_with_more_alternatives_are_read_once() {
let p = palette(
b"0 0 Pb\n%AI17_Begin_Content_if_version_gt:24 15\n0 1 1 0 (Red ink) 0 x\n(Red ink)\nPc\n\
%AI17_Alternate_Content_if_version_gt:24 10\n0 1 1 0 (Red ink) 0 x\n(Red ink)\nPc\n\
%AI17_Begin_Content_if_version_gt:23 1\n0 0 0 1 k\n(Black)\nPc\n%AI17_Alternate_Content\n%AI17_End_Versioned_Content\n\
0 0 1 0 k\n(Yellow)\nPc\n\
%AI17_Alternate_Content\n0 1 1 0 (Red ink) 0 x\n(Red ink)\nPc\n%AI17_End_Versioned_Content\n0 0 0 1 k\n(After)\nPc\nPB\n",
);
assert_eq!(p.swatches.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(), ["Red ink", "After"]);
}
#[test]
fn what_it_doesnt_know_leaves_the_other_swatches() {
let p = palette(b"0 0 Pb\n1 2 3 Zz\n(Odd)\nPc\n(no paint) Pc\n0 0 0 1 k\n(Black)\nPc\nPB\n");
+1 -1
View File
@@ -79,7 +79,7 @@ pub(crate) use document::{slot_frame, slot_of};
pub use types::IMAGE_TOO_LARGE;
use types::{BLEND_BUDGET, FLIP, Frame, GState, Kind, PatternHead, Place, Raster};
#[cfg(test)]
pub use types::{NON_NATIVE_ART, NON_NATIVE_ART_UNREAD};
pub use types::{NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, VERSIONED_UNENDED};
pub(super) use types::{blend_mode, blend_name};
#[path = "legacy_text.rs"]
+13 -1
View File
@@ -1,5 +1,5 @@
use super::art::{four, sane, two};
use super::types::{FLIP, Kind, MAX_HEX, MAX_PATTERNS, MAX_STACK, PatternHead, Place, Raster, SKIPPED};
use super::types::{FLIP, Kind, MAX_HEX, MAX_PATTERNS, MAX_STACK, PatternHead, Place, Raster, SKIPPED, VERSIONED_UNENDED};
use super::{Arc, Node, PatternDef, Reader, Rect, Tok, V, marker};
impl<'a> Reader<'a> {
@@ -69,6 +69,18 @@ impl<'a> Reader<'a> {
if m == "BeginEncoding" {
self.type_state.encoding_comment(line);
}
// Versioned content holds alternatives of the same art, the newest first: the first is
// read, and the next one (`%AI17_Alternate_Content`, or `…_if_version_gt:` for a version
// in between) starts what is skipped, up to the end of the block, nested blocks included.
if m.starts_with("Alternate_Content") {
let at = self.lex.offset();
if self.skip_to("End_Versioned_Content").is_err() {
// A block that doesn't end: the rest of the file is read rather than lost.
self.lex.seek(at);
self.warn(VERSIONED_UNENDED);
}
return Ok(());
}
if let Some((_, end)) = SKIPPED.iter().find(|(b, _)| *b == m) {
return self.skip_to(end);
}
+4 -1
View File
@@ -244,9 +244,12 @@ pub(super) const SKIPPED: &[(&str, &str)] = &[
("BeginSymbolList", "EndSymbolList"),
("BeginTextDocument", "EndTextDocument"),
("BeginEncoding", "EndEncoding"),
("Alternate_Content", "End_Versioned_Content"),
];
/// The note for versioned content whose `%AI17_End_Versioned_Content` is missing: every
/// alternative after the first is read too, so some art may come in twice.
pub const VERSIONED_UNENDED: &str = "versioned content that doesn't end was read whole: some art may come in twice";
/// The note for a `/ForeignObject` (non-native art, the content of a placed PDF, which the editor
/// shows but doesn't edit) whose PDF can't be read.
pub const NON_NATIVE_ART_UNREAD: &str = "non-native art (a placed PDF's content) that VectorCraft can't read is left out";
+60
View File
@@ -953,6 +953,66 @@ fn sections_it_skips() {
assert_eq!(art(&doc).len(), 1);
}
/// A filled square at x = `10 × i` (5 pt wide), to tell the alternatives of versioned content apart.
fn alt_square(i: u32) -> String {
let x = f64::from(i) * 10.0;
rect_path(x, 0.0, x + 5.0, 5.0) + "f\n"
}
/// The left edges of the layer's art, in order: which [`alt_square`]s came in.
fn squares(doc: &Document) -> Vec<f64> {
art(doc).iter().filter_map(|n| n.geometric_bounds()).map(|b| b.x0.round()).collect()
}
/// Versioned content holds alternatives of the same art for newer and older versions (newer
/// files write a Dimension tool group, then the same group again for a version in between, then
/// plain art): only the first alternative is read, so the art comes in once, however many
/// alternatives follow and whatever versioned content is nested in them.
#[test]
fn versioned_content_reads_one_alternative() {
let (begin, between, alternate, end) = (
"%AI17_Begin_Content_if_version_gt:24 11\n",
"%AI17_Alternate_Content_if_version_gt:24 10\n",
"%AI17_Alternate_Content\n",
"%AI17_End_Versioned_Content\n",
);
let (a, b, c) = (alt_square(1), alt_square(2), alt_square(3));
let cases = [
("one alternative", format!("{begin}{a}{end}"), vec![10.0]),
("two alternatives", format!("{begin}{a}{alternate}{b}{end}"), vec![10.0]),
("three alternatives", format!("{begin}{a}{between}{b}{alternate}{c}{end}"), vec![10.0]),
// Blocks nested in the first alternative read their own first; those in a later one aren't read.
(
"nested",
format!(
"{begin}{a}{begin}{b}{alternate}{c}{end}{between}{}{begin}{}{alternate}{}{end}{alternate}{}{end}",
alt_square(4),
alt_square(5),
alt_square(6),
alt_square(7)
),
vec![10.0, 20.0],
),
// Art after the block is read as usual.
("art after it", format!("{begin}{a}{between}{b}{alternate}{c}{end}{}", alt_square(8)), vec![10.0, 80.0]),
];
for (case, body, want) in cases {
let s = read_ok(&layer(&body));
assert_eq!(squares(&s.doc), want, "{case}");
assert!(!s.warnings.iter().any(|w| w == read::VERSIONED_UNENDED), "{case}: {:?}", s.warnings);
}
}
/// Versioned content whose end is missing is read whole, with a note, rather than losing the rest
/// of the file.
#[test]
fn versioned_content_that_does_not_end_is_read_with_a_note() {
let body = format!("%AI17_Begin_Content_if_version_gt:24 11\n{}%AI17_Alternate_Content_if_version_gt:24 10\n{}", alt_square(1), alt_square(2));
let s = read_ok(&layer(&body));
assert_eq!(squares(&s.doc), [10.0, 20.0]);
assert!(s.warnings.iter().any(|w| w == read::VERSIONED_UNENDED), "{:?}", s.warnings);
}
/// Non-native art (a placed PDF's content) keeps its PDF as ASCII85 in comment lines after
/// `/Data ,`. ASCII85 has `_`, so a line may start with `%_`, which otherwise reads as hidden
/// tokens, and a `(` in it would swallow the rest of the layer (a letterhead saved by Illustrator
+49 -3
View File
@@ -24,7 +24,7 @@ use std::collections::BTreeMap;
use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{CharStyle, Justify, Node, NodeId, NodeKind, ParaStyle, TextKind, TextObject, TextRun};
use vectorcraft_doc::{CharStyle, Justify, Node, NodeId, NodeKind, ParaDirection, ParaStyle, TextKind, TextObject, TextRun};
use vectorcraft_geom::{Affine, PathData, Point, Rect};
/// Longest story read (bytes).
@@ -618,7 +618,7 @@ impl Story {
let r = match self.frames.first()? {
FrameKind::Area(s) => bounds(s)?,
FrameKind::OnPath { segments, .. } => bounds(segments)?.inflate(size * 1.5, size * 1.5),
FrameKind::Point(_) => {
FrameKind::Point(m) => {
let longest = self.text.split(['\r', '\x03']).map(|l| l.chars().count()).max().unwrap_or(0) as f64;
let reach = longest * size + size;
let points: Vec<Point> = self.line_starts(template).into_iter().map(|(_, (x, y))| to_doc * Point::new(x, y)).collect();
@@ -626,7 +626,19 @@ impl Story {
let (x, y) = self.place(template);
to_doc * Point::new(x, y)
});
points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p)).inflate(reach, size * 1.5)
// Along the baseline as far as a line reaches, across it a line and a half: turned
// with the type (a dimension's label turned a quarter runs down the page).
// (The canvas is y down, the art y up: see [`to_art`].)
let along = to_doc * Point::new(m[0], -m[1]) - to_doc * Point::ZERO;
let along = if along.hypot() > 1e-9 && along.is_finite() { along / along.hypot() } else { kurbo::Vec2::new(1.0, 0.0) };
let across = kurbo::Vec2::new(-along.y, along.x);
let mut r = Rect::from_points(first, first);
for p in points.iter().chain([&first]) {
for (t, n) in [(-reach, -1.5), (-reach, 1.5), (reach, -1.5), (reach, 1.5)] {
r = r.union_pt(*p + along * t + across * (n * size));
}
}
r
}
};
r.is_finite().then_some(r)
@@ -790,6 +802,9 @@ fn para_style(p: &Para, scale: f64) -> ParaStyle {
space_before: p.space_before * scale,
space_after: p.space_after * scale,
hyphenate: p.hyphenate,
// A right-to-left paragraph lays its text out from the right whatever letters it has
// (" 120.5 mm " reads "mm 120.5"); without the key, from its first strong letter.
direction: p.rtl.then_some(ParaDirection::RightToLeft),
..ParaStyle::default()
}
}
@@ -838,6 +853,37 @@ mod tests {
assert_eq!(justify(b"/0 1 /33 0"), Justify::Right);
}
/// Where point type lies runs along its baseline: a label turned a quarter (as a vertical
/// dimension's is) lies in a tall box, not a wide one.
#[test]
fn point_type_lies_along_its_baseline() {
let para =
Texts { stories: vec![], fonts: vec![], defaults: Val::Dict(BTreeMap::new()), para_defaults: Val::Dict(BTreeMap::new()), frames: vec![] }
.para(None);
let mut s = story(" 80 mm ", &[0.0], &para);
let size = |s: &Story| s.region((0.0, 0.0), Affine::IDENTITY).map(|r| (r.width(), r.height())).unwrap();
let (w, h) = size(&s);
assert!(w > 3.0 * h, "level: {w} × {h}");
// Turned −90° on the canvas (y down): the baseline runs down the page.
s.frames = vec![FrameKind::Point([0.0, 1.0, -1.0, 0.0, 0.0, 0.0])];
let (w, h) = size(&s);
assert!(h > 3.0 * w, "turned: {w} × {h}");
}
/// A right-to-left paragraph is laid out right to left even when it has no right-to-left
/// letters (a dimension's label " 120.5 mm " reads "mm 120.5"); a left-to-right one, or one
/// that doesn't say, goes by its letters.
#[test]
fn right_to_left_paragraphs_set_their_direction() {
let texts =
Texts { stories: vec![], fonts: vec![], defaults: Val::Dict(BTreeMap::new()), para_defaults: read(b"/33 1").unwrap(), frames: vec![] };
let direction = |own: &[u8]| para_style(&texts.para(Some(&read(own).unwrap())), 1.0).direction;
assert_eq!(direction(b"/0 2"), Some(ParaDirection::RightToLeft), "from the document's defaults");
assert_eq!(direction(b"/0 2 /33 0"), None, "the paragraph's own");
let plain = Texts { para_defaults: Val::Dict(BTreeMap::new()), ..texts };
assert_eq!(para_style(&plain.para(None), 1.0).direction, None);
}
#[test]
fn values_read_nested_dictionaries_lists_and_utf16_strings() {
let v = read(b" /1 << /0 [ 1 2.5 -3 ] /2 (\xfe\xff\x00h\x00i) /3 true /99 /Name >> /0 (a\\)b\\n)").unwrap();
+15 -15
View File
@@ -1,5 +1,5 @@
use super::layers::closed_where_ends_meet;
use super::slots::{Content, SlotInfo, assign, covered, extent, join_pieces, letters, same_type};
use super::slots::{Content, SlotInfo, assign, covered, extent, join_pieces, letters, same_type, words};
use super::{Affine, Arc, Node, NodeId, NodeKind, Point, Rect, Story, TextKind};
/// Match page objects to the nearest slot whose story describes them, including hidden slots.
@@ -103,28 +103,28 @@ pub(super) fn plan(
// with its words? (A copied object's lines are where its original's are, so they don't say.)
let hidden_type = |object: &[Arc<Node>]| {
let Some(r) = extent(object) else { return false };
let words: Vec<String> =
object.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(letters(&t.plain_text())) } else { None }).collect();
let texts: Vec<Vec<String>> =
object.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(words(&t.plain_text())) } else { None }).collect();
hidden_at.iter().any(|(k, place)| {
place.intersect(r).area() >= 0.5 * r.area().max(1e-9)
&& lines
.get(*k)
.and_then(Option::as_ref)
.is_some_and(|l| !words.is_empty() && words.iter().all(|w| l.iter().any(|(text, _)| letters(text).contains(w.as_str()))))
&& lines.get(*k).and_then(Option::as_ref).is_some_and(|l| {
!texts.is_empty()
&& texts.iter().all(|ws| !ws.is_empty() && ws.iter().all(|w| l.iter().any(|(text, _)| letters(text).contains(w.as_str()))))
})
})
};
// The letters of each slot's story, in lower case.
let spelled: Vec<String> = stories.iter().map(|s| s.as_ref().map(|s| letters(s.text()).to_ascii_lowercase()).unwrap_or_default()).collect();
// For each slot: is its made type where `object` is, with the letters of one of `object`'s texts in
// its story? All are false unless such slots have the letters of every text between them.
// For each slot: is its made type where `object` is, with the words of one of `object`'s texts in
// its story? All are false unless such slots have the words of every text between them.
let spelling = |object: &[Arc<Node>], content: &[Content]| -> Vec<bool> {
let r = extent(object);
let words: Vec<String> = object
let texts: Vec<Vec<String>> = object
.iter()
.filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(letters(&t.plain_text()).to_ascii_lowercase()) } else { None })
.filter(|w| !w.is_empty())
.filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(words(&t.plain_text().to_ascii_lowercase())) } else { None })
.filter(|ws| !ws.is_empty())
.collect();
let has = |k: usize| spelled.get(k).is_some_and(|s| words.is_empty() || words.iter().any(|w| s.contains(w.as_str())));
let has = |k: usize| spelled.get(k).is_some_and(|s| texts.is_empty() || texts.iter().any(|ws| ws.iter().all(|w| s.contains(w.as_str()))));
let near: Vec<bool> = content
.iter()
.enumerate()
@@ -133,7 +133,7 @@ pub(super) fn plan(
_ => false,
})
.collect();
let every = words.iter().all(|w| near.iter().zip(&spelled).any(|(n, s)| *n && s.contains(w.as_str())));
let every = texts.iter().flatten().all(|w| near.iter().zip(&spelled).any(|(n, s)| *n && s.contains(w.as_str())));
if every { near } else { vec![] }
};
let mut spare: Vec<Vec<Arc<Node>>> = vec![];
@@ -150,7 +150,7 @@ pub(super) fn plan(
Some(c) => *c = Content::Page(object.clone()),
None => {}
},
// What lies where made type is and has that type's letters is that type as the page draws it.
// What lies where made type is and has that type's words is that type as the page draws it.
None if covered(object, &content, spells) => {
let r = extent(object).unwrap_or(Rect::ZERO);
for (k, c) in content.iter_mut().enumerate() {
+7
View File
@@ -323,6 +323,13 @@ pub(super) fn letters(s: &str) -> String {
s.chars().filter(char::is_ascii_alphanumeric).collect()
}
/// The [`letters`] of each word of `s`, the empty ones left out. Type is compared word by word:
/// the page draws a right-to-left paragraph's words in the order they show (" 120.5 mm " as
/// "mm 120.5"), its text object keeps them in the order they are typed.
pub(super) fn words(s: &str) -> Vec<String> {
s.split_whitespace().map(letters).filter(|w| !w.is_empty()).collect()
}
/// Does `n` hold type that is where `story` lays one of its lines out: the line, or a piece of it
/// (the page splits a line where its kerning or its styles change), on that line's baseline and
/// after its start? How far from where the line starts it is, if so.
+19
View File
@@ -779,6 +779,25 @@ fn type_of_the_page_with_the_letters_of_made_type_where_it_lies_is_that_type() {
}
}
/// A right-to-left paragraph's page type has its words in the order they show, not the order they
/// are typed (a dimension's label " 120.5 mm " is drawn "mm 120.5"): it is still that text
/// object's type as the page draws it, not a second copy of it with the nearest text object.
#[test]
fn type_of_the_page_with_the_words_of_made_type_in_another_order_is_that_type() {
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
let art = native_with_text(&layer("Art", true, &text_object(0)), &doc);
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 58.5 moveto (there Hi) show";
let r = import(&eps(page, &art)).unwrap();
let mut all = vec![];
r.document.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
all.push(t.plain_text());
}
});
assert_eq!(all, ["Hi there"], "{:?}", r.warnings);
assert!(!r.warnings.iter().any(|w| w.contains("with another's")), "{:?}", r.warnings);
}
/// Page type of no text object that lies where the made type of two text objects is, with the
/// letters of one of them in each of its pieces, is the type of both as the page draws it.
#[test]