mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 09:33:54 +00:00
.ai/EPS import: type keeps its paragraphs' tab stops
Key 30 of a paragraph's attributes holds its tab stops: each its position (key 0) and alignment (key 1: 0 left, 1 center, 2 right, 3 decimal). They were not read, so tabs fell on the default stops. At most 1,024 are read a paragraph and 2^20 copied into a story's paragraphs, past which the default stops apply with a warning. The fuzz test feeds hostile paragraph attributes: alignments, auto leading, hyphenation options and tab stops.
This commit is contained in:
@@ -3015,6 +3015,50 @@ fn text_editing_of(document: &str, objects: &str) -> String {
|
||||
|
||||
/// Area Type Options as an area frame's data may keep them: rows, columns, gutters and the first
|
||||
/// baseline, and keys besides, each with a value of any kind, in or out of range.
|
||||
/// A paragraph's attributes as a text document might keep them: an alignment (key 0), auto leading
|
||||
/// (7), hyphenation options (10 to 15) and tab stops (30) of any value or type, the stops a list of
|
||||
/// any length whose entries may be anything.
|
||||
fn arb_paragraph_attributes() -> impl Strategy<Value = String> {
|
||||
let values = prop::sample::select(vec![
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"6",
|
||||
"7",
|
||||
"-1",
|
||||
"2.5",
|
||||
"1e400",
|
||||
"-1e400",
|
||||
"NaN",
|
||||
"inf",
|
||||
"1e9",
|
||||
"(x)",
|
||||
"true",
|
||||
"false",
|
||||
"/nil",
|
||||
"[ 1 ]",
|
||||
"<< /0 1 >>",
|
||||
]);
|
||||
let keys = prop::sample::select(vec!["0", "7", "10", "11", "12", "13", "14", "15"]);
|
||||
let stop = (values.clone(), values.clone(), 0usize..3).prop_map(|(at, align, shape)| match shape {
|
||||
0 => format!("<< /0 {at} /1 {align} >>"),
|
||||
1 => format!("<< /1 {align} >>"),
|
||||
_ => at.to_string(),
|
||||
});
|
||||
let tabs = (prop::collection::vec(stop, 0..40), 0usize..4).prop_map(|(stops, shape)| match shape {
|
||||
0 => format!("/30 << /0 [ {} ] >>", stops.join(" ")),
|
||||
1 => "/30 << /0 7 >>".to_string(),
|
||||
2 => "/30 [ 1 2 ]".to_string(),
|
||||
_ => String::new(),
|
||||
});
|
||||
(prop::collection::vec((keys, values), 0..8), tabs).prop_map(|(kv, tabs)| {
|
||||
let mut attrs: Vec<String> = kv.iter().map(|(k, v)| format!("/{k} {v}")).collect();
|
||||
attrs.push(tabs);
|
||||
attrs.join(" ")
|
||||
})
|
||||
}
|
||||
|
||||
fn arb_area_options() -> impl Strategy<Value = String> {
|
||||
let keys = prop::sample::select(vec!["3", "4", "5", "7", "8", "10", "13", "14"]);
|
||||
let values = prop::sample::select(vec![
|
||||
@@ -3192,6 +3236,18 @@ proptest! {
|
||||
survive(".ai text frames with hostile area options", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?;
|
||||
}
|
||||
|
||||
/// The same text document with hostile paragraph attributes: an alignment, auto leading,
|
||||
/// hyphenation options and tab stops of any value, type or size.
|
||||
#[test]
|
||||
fn text_frames_with_hostile_paragraph_attributes_never_panic(attrs in arb_paragraph_attributes()) {
|
||||
let text = frames_document_text().replace("/5 << /0 2 /1 4 /2 6 >>", &format!("/5 << /1 4 /2 6 {attrs} >>"));
|
||||
let editing = text_editing_of(&text, FRAME_OBJECTS);
|
||||
let bytes = editing_eps(&editing);
|
||||
survive("EPS text frames with hostile paragraph attributes", || vectorcraft_engine::cmd::fileio::load("x.eps", &bytes).ok().map(|l| l.doc))?;
|
||||
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&editing)].concat();
|
||||
survive(".ai text frames with hostile paragraph attributes", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?;
|
||||
}
|
||||
|
||||
/// The same for vertical type and Tate-chu-yoko runs: orientations and baseline directions of
|
||||
/// any value, type or size.
|
||||
#[test]
|
||||
|
||||
@@ -25,7 +25,8 @@ use std::collections::BTreeMap;
|
||||
use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{
|
||||
CharStyle, HyphenationOptions, JustificationOptions, Justify, Node, NodeId, NodeKind, ParaDirection, ParaStyle, TextKind, TextObject, TextRun,
|
||||
CharStyle, HyphenationOptions, JustificationOptions, Justify, Node, NodeId, NodeKind, ParaDirection, ParaStyle, TabAlign, TabStop, TextKind,
|
||||
TextObject, TextRun,
|
||||
};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect};
|
||||
|
||||
@@ -353,8 +354,23 @@ struct Para {
|
||||
/// After First, Before Last, Hyphen Limit, Hyphenation Zone (pt) and Hyphenate Capitalized
|
||||
/// Words. A value that can't be read, or is out of its range, keeps the default.
|
||||
hyphenation: HyphenationOptions,
|
||||
/// Key 30, its list (key 0): the tab stops, each its position from the frame's left edge (key
|
||||
/// 0) and alignment (key 1: 0 left, the default, 1 center, 2 right, 3 decimal, in the order of
|
||||
/// the Tabs panel's buttons), sorted. A stop that can't be read is left out, and so are those
|
||||
/// past the first [`MAX_TABS`]. Leaders aren't read.
|
||||
tabs: Vec<TabStop>,
|
||||
}
|
||||
|
||||
/// Most tab stops read for one paragraph.
|
||||
const MAX_TABS: usize = 1024;
|
||||
/// Largest tab stop position read (in the text document's units), as `text.tabs.set` takes it.
|
||||
const MAX_TAB_POSITION: f64 = 100_000.0;
|
||||
/// Most tab stops a story's paragraphs take in all: a paragraph style's stops are copied into
|
||||
/// every paragraph that has them. The paragraphs past it take the default stops, with a warning.
|
||||
const MAX_STORY_TABS: usize = 1 << 20;
|
||||
/// The warning when [`MAX_STORY_TABS`] leaves tab stops out.
|
||||
pub(crate) const TABS_LEFT_OUT: &str = "tab stops past VectorCraft's limits were left out";
|
||||
|
||||
/// The text of one story, in the units of the text document.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Story {
|
||||
@@ -375,6 +391,20 @@ pub(super) struct Story {
|
||||
/// Vertical type (Type › Type Orientation › Vertical): its lines are columns, from right to
|
||||
/// left. Its layout's lines run down from the anchor, each column further left.
|
||||
vertical: bool,
|
||||
/// Tab stops past [`MAX_STORY_TABS`] were left out of its paragraphs.
|
||||
tabs_left_out: std::cell::Cell<bool>,
|
||||
}
|
||||
|
||||
/// Spend `budget` on the tab stops `tabs`: when there aren't enough left, they're left out (the
|
||||
/// default stops apply). Whether they were.
|
||||
fn spend_tabs(tabs: &mut Vec<TabStop>, budget: &mut usize) -> bool {
|
||||
if tabs.len() <= *budget {
|
||||
*budget -= tabs.len();
|
||||
false
|
||||
} else {
|
||||
tabs.clear();
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -572,7 +602,12 @@ impl Texts {
|
||||
if runs.is_empty() {
|
||||
runs.push((text.chars().count(), self.style(None)));
|
||||
}
|
||||
Some(Story { text, anchor, lines, frames, paras, runs, path_spacing, vertical })
|
||||
let mut budget = MAX_STORY_TABS;
|
||||
let mut tabs_left_out = false;
|
||||
for (_, p) in &mut paras {
|
||||
tabs_left_out |= spend_tabs(&mut p.tabs, &mut budget);
|
||||
}
|
||||
Some(Story { text, anchor, lines, frames, paras, runs, path_spacing, vertical, tabs_left_out: tabs_left_out.into() })
|
||||
}
|
||||
|
||||
/// A paragraph's attributes (`run`: its attributes, key 5, and the paragraph style it is based
|
||||
@@ -616,6 +651,27 @@ impl Texts {
|
||||
},
|
||||
}
|
||||
},
|
||||
tabs: {
|
||||
let list = pick("30").and_then(|t| t.get("0")).map(Val::list).unwrap_or_default();
|
||||
let mut tabs: Vec<TabStop> = list
|
||||
.iter()
|
||||
.take(MAX_TABS)
|
||||
.filter_map(|t| {
|
||||
let position = t.get("0").and_then(Val::num).filter(|p| (0.0..=MAX_TAB_POSITION).contains(p))?;
|
||||
let align = match t.get("1").map(Val::num) {
|
||||
None | Some(Some(0.0)) => TabAlign::Left,
|
||||
Some(Some(1.0)) => TabAlign::Center,
|
||||
Some(Some(2.0)) => TabAlign::Right,
|
||||
Some(Some(3.0)) => TabAlign::Decimal,
|
||||
_ => return None,
|
||||
};
|
||||
Some(TabStop { position, align, leader: String::new(), align_on: '.' })
|
||||
})
|
||||
.collect();
|
||||
tabs.sort_by(|a, b| a.position.total_cmp(&b.position));
|
||||
tabs.dedup_by(|a, b| (a.position - b.position).abs() < 1e-6);
|
||||
tabs
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -966,6 +1022,12 @@ impl Story {
|
||||
&& vectorcraft_text::fit_source_lines(vectorcraft_text::FontDb::global(), t, self.lines.len())
|
||||
}
|
||||
|
||||
/// Were tab stops past [`MAX_STORY_TABS`] left out of its paragraphs (so far: making its text
|
||||
/// object counts them again)?
|
||||
pub(super) fn tabs_left_out(&self) -> bool {
|
||||
self.tabs_left_out.get()
|
||||
}
|
||||
|
||||
/// Area type (`node`, the story's first frame as [`Self::node`] makes it with `template` and
|
||||
/// `to_doc`) whose first line the file lays out higher or lower than it is laid out here, as its
|
||||
/// page draws it: its first baseline is fixed where the file has it. The font's ascent below the
|
||||
@@ -1011,9 +1073,13 @@ impl Story {
|
||||
let mut runs = self.paras.iter();
|
||||
let mut current = runs.next();
|
||||
let mut left = current.map_or(usize::MAX, |r| r.0);
|
||||
let mut budget = MAX_STORY_TABS;
|
||||
for p in text.split('\n') {
|
||||
let para = current.map(|r| r.1.clone()).unwrap_or_default();
|
||||
out.push(para_style(¶, scale));
|
||||
let mut style = current.map_or_else(|| para_style(&Para::default(), scale), |r| para_style(&r.1, scale));
|
||||
if spend_tabs(&mut style.tabs, &mut budget) {
|
||||
self.tabs_left_out.set(true);
|
||||
}
|
||||
out.push(style);
|
||||
// Each paragraph and its ending `\r` count.
|
||||
let mut used = p.chars().count() + 1;
|
||||
while used > 0 {
|
||||
@@ -1054,6 +1120,7 @@ fn para_style(p: &Para, scale: f64) -> ParaStyle {
|
||||
space_after: p.space_after * scale,
|
||||
hyphenate: p.hyphenate,
|
||||
hyphenation: HyphenationOptions { zone: p.hyphenation.zone * scale, ..p.hyphenation.clone() },
|
||||
tabs: p.tabs.iter().map(|t| TabStop { position: t.position * scale, ..t.clone() }).collect(),
|
||||
// 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),
|
||||
@@ -1181,6 +1248,64 @@ mod tests {
|
||||
assert_eq!(own(b"/10 0 /11 99 /12 2.5 /13 -1 /14 -3 /15 1"), d);
|
||||
}
|
||||
|
||||
/// Key 30 holds the paragraph's tab stops: their positions (key 0), sorted, and alignments (key
|
||||
/// 1: 0 left, 1 center, 2 right, 3 decimal), scaled as sizes are. A stop that can't be read is
|
||||
/// left out. A user's price list keeps a right stop at 269.29 and a left one at 272.13.
|
||||
#[test]
|
||||
fn a_paragraph_keeps_its_tab_stops() {
|
||||
let texts = Texts {
|
||||
stories: vec![],
|
||||
fonts: vec![],
|
||||
defaults: Val::Dict(BTreeMap::new()),
|
||||
para_defaults: Val::Dict(BTreeMap::new()),
|
||||
char_sheets: vec![],
|
||||
para_sheets: vec![],
|
||||
frames: vec![],
|
||||
};
|
||||
let tabs = |own: &[u8], scale: f64| {
|
||||
para_style(&texts.para(Some(&read(&[b"/5 << ", own, b" >>"].concat()).unwrap())), scale)
|
||||
.tabs
|
||||
.iter()
|
||||
.map(|t| (t.position, t.align))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(tabs(b"", 1.0), []);
|
||||
assert_eq!(
|
||||
tabs(b"/30 << /0 [ << /0 272.12598 /1 0 >> << /0 269.29135 /1 2 >> ] >>", 1.0),
|
||||
[(269.29135, TabAlign::Right), (272.12598, TabAlign::Left)]
|
||||
);
|
||||
assert_eq!(
|
||||
tabs(b"/30 << /0 [ << /0 10 /1 1 >> << /0 20 /1 3 >> << /0 30 >> ] >>", 0.5),
|
||||
[(5.0, TabAlign::Center), (10.0, TabAlign::Decimal), (15.0, TabAlign::Left)]
|
||||
);
|
||||
// Unreadable: a position out of range or not a number, an unknown alignment.
|
||||
assert_eq!(
|
||||
tabs(b"/30 << /0 [ << /0 -1 >> << /0 1e9 >> << /0 /nil >> << /0 5 /1 7 >> << /0 6 /1 /x >> << /0 7 /1 2 >> ] >>", 1.0),
|
||||
[(7.0, TabAlign::Right)]
|
||||
);
|
||||
}
|
||||
|
||||
/// A story's paragraphs take at most [`MAX_STORY_TABS`] tab stops in all (a paragraph style's
|
||||
/// are copied into each paragraph): those past it take the default stops, and the story says so.
|
||||
#[test]
|
||||
fn a_story_s_tab_stops_are_bounded() {
|
||||
let mut budget = 3;
|
||||
let mut tabs = vec![TabStop { position: 1.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }; 2];
|
||||
assert!(!spend_tabs(&mut tabs, &mut budget) && tabs.len() == 2 && budget == 1);
|
||||
assert!(spend_tabs(&mut tabs, &mut budget) && tabs.is_empty() && budget == 1);
|
||||
// Paragraphs of a style with MAX_TABS stops each, more than the story takes.
|
||||
let stop = TabStop { position: 1.0, align: TabAlign::Left, leader: String::new(), align_on: '.' };
|
||||
let para = Para { tabs: vec![stop; MAX_TABS], ..Para::default() };
|
||||
let count = MAX_STORY_TABS / MAX_TABS + 2;
|
||||
let s = story(&"a\r".repeat(count), &[0.0], ¶);
|
||||
assert!(!s.tabs_left_out());
|
||||
let styles = s.paragraph_styles(&format!("{}a", "a\n".repeat(count - 1)), 1.0);
|
||||
assert_eq!(styles.len(), count);
|
||||
assert!(s.tabs_left_out());
|
||||
assert_eq!(styles.iter().map(|p| p.tabs.len()).sum::<usize>(), MAX_STORY_TABS);
|
||||
assert!(styles.last().is_some_and(|p| p.tabs.is_empty()));
|
||||
}
|
||||
|
||||
/// 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]
|
||||
@@ -1418,6 +1543,7 @@ mod tests {
|
||||
)],
|
||||
path_spacing: 0.0,
|
||||
vertical: false,
|
||||
tabs_left_out: false.into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ pub(super) fn make_slots(
|
||||
to_doc: Affine,
|
||||
warn: &mut Vec<String>,
|
||||
) -> Vec<Option<Made>> {
|
||||
let (mut taller, mut deeper) = (0, 0);
|
||||
let (mut taller, mut deeper, mut tabs_left_out) = (0, 0, false);
|
||||
let made = infos
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -67,6 +67,7 @@ pub(super) fn make_slots(
|
||||
let t = template?;
|
||||
let story = stories.get(k)?.as_ref()?;
|
||||
let mut node = story.node(NodeId(0), info.frame, t, to_doc)?;
|
||||
tabs_left_out |= story.tabs_left_out();
|
||||
let mut region = story.region(t, to_doc);
|
||||
// The first line where the file has it, in the text document's frame (that of a
|
||||
// copied object is its original's, as its lines are).
|
||||
@@ -97,6 +98,9 @@ pub(super) fn make_slots(
|
||||
"area type whose last lines the file sets in frames too short for them here got taller frames ({deeper}): those lines wouldn't show otherwise"
|
||||
));
|
||||
}
|
||||
if tabs_left_out {
|
||||
warn.push(crate::import::ate::TABS_LEFT_OUT.into());
|
||||
}
|
||||
made
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (`.ai` and EPS type from the text document keeps auto leading, hyphenation options and the file's last lines; EPS and `.ai` editing data: a transparency set inside a clipping group stays after its `Q`; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (`.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading, hyphenation options and the file's last lines; EPS and `.ai` editing data: a transparency set inside a clipping group stays after its `Q`; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
|
||||
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public
|
||||
@@ -51,7 +51,7 @@ private editing data. VectorCraft:
|
||||
editing data of a `.ai`, EPS or PDF; other art opens in a group.
|
||||
Type from the text document keeps auto leading as auto, each line taking the paragraph's share
|
||||
(Auto Leading) of the largest type on it ([Line and character spacing](https://helpx.adobe.com/illustrator/using/line-character-spacing.html)),
|
||||
and the paragraph's hyphenation options. Area type of one
|
||||
and the paragraph's hyphenation options and tab stops. Area type of one
|
||||
frame whose last lines the file sets but which don't fit here (the fallback font, or a descent
|
||||
past the frame's bottom) gets a frame taller from its bottom, with a warning.
|
||||
RGB in the PDF page's `ICCBased` spaces keeps the file's numbers in a `.ai` (the `uncalibrated_rgb`
|
||||
@@ -217,6 +217,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-07 | minor | EPS and `.ai` type from the text document keeps its paragraphs' tab stops (positions and alignments; leaders aren't read), at most 1,024 a paragraph and 2^20 a story, past which the default stops apply with a warning. A user's `.ai` (used locally, not committed) whose price lists came in on the default stops now lays them out as its page has them (its type 0.8% from its page, from 1.5%; what differs is hyphenation in Hungarian and type in a font that isn't installed). Scores and estimates unchanged. |
|
||||
| 2026-11-07 | minor | EPS and `.ai` type from the text document: auto leading stays auto at the paragraph's share (it was the first two lines' distance for every line, so 10 pt lines under a 15 pt heading came in 18 pt apart), the paragraph's hyphenation options are read, and area type whose last lines the file sets in too short a frame for them here gets a taller frame. A user's `.ai` (used locally, not committed) whose type differed from its page by 6.6%, so that it opened from its PDF part, now opens from its layers (4.9%; type with tab stops in right-aligned paragraphs still differs, a separate issue) (#6137). Scores and estimates unchanged. |
|
||||
| 2026-11-06 | minor | EPS and `.ai` editing data: `Q` restores the painting state but not the transparency (`Xy`), which the writer only sets when the next object's differs from the last one written. A Screen glow followed by a clipping group set back to Normal no longer leaves the type after the group in Screen: two users' Illustrator 26.5 and 30 EPS posters (used locally, not committed) now show their gold heading and crescent, and both open from their layers (2.5% and 2.7% from the page before, under the limit after). Scores and estimates unchanged. |
|
||||
| 2026-11-06 | minor | EPS and `.ai` editing data: a blend whose spine was never edited, written with its keys and no spine part, comes in as a live blend along the straight lines between its keys' centres (in its record's order) instead of as its drawn steps. Checked on six users' Illustrator EPS files (used locally, not committed): every blend in four of them now comes in live, their renders unchanged; two still have blends the reader can't match to their drawn steps. Scores and estimates unchanged. |
|
||||
|
||||
Reference in New Issue
Block a user