mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-07 13:23:22 +00:00
Merge pull request #5783 from dotCooCoo/fix/ai-os22-g8
.ai import: area type with linked areas opens as threaded frames
This commit is contained in:
@@ -3753,6 +3753,47 @@ proptest! {
|
||||
}
|
||||
}
|
||||
|
||||
/// Area type in the version 7 form (§15.5.3) whose story flows through two linked areas: a small
|
||||
/// one, and one a quarter turn round, each area's characters after its `TP`.
|
||||
const LINKED_AREAS: &str = "1 To\n1 0 0 1 120 90 0 Tp\n120 60 m\n120 90 L\n180 90 L\n180 60 L\n120 60 L\nn\nTP\n0 Tr\n0 g\n/_Helvetica 9 Tf\n\
|
||||
(one two three four five six seven eight nine ten ) Tx\n0 1 -1 0 190 10 0 Tp\n130 10 m\n130 55 L\n190 55 L\n190 10 L\n130 10 L\nn\nTP\n\
|
||||
(eleven twelve thirteen fourteen fifteen) Tx\n(\\r) TX\nTO\n";
|
||||
|
||||
/// [`NAMED_PROLOG_AI`] with `text` on its layer.
|
||||
fn linked_areas_ai(text: &str) -> String {
|
||||
NAMED_PROLOG_AI.replace("u\n0.5 g\n", &format!("{text}u\n0.5 g\n"))
|
||||
}
|
||||
|
||||
/// The fixture, as it is, opens with its area type as two threaded frames, the story flowing from
|
||||
/// the first into the second.
|
||||
#[test]
|
||||
fn the_linked_areas_fixture_opens_as_threaded_frames() {
|
||||
let l = vectorcraft_engine::cmd::fileio::load("x.ai", linked_areas_ai(LINKED_AREAS).as_bytes()).unwrap();
|
||||
let [thread] = &l.doc.text_threads[..] else { panic!("one thread: {:?} {:?}", l.doc.text_threads, l.warnings) };
|
||||
let texts: Vec<String> = thread
|
||||
.iter()
|
||||
.map(|id| match &l.doc.node(*id).unwrap().kind {
|
||||
vectorcraft_doc::NodeKind::Text(t) => t.plain_text(),
|
||||
k => panic!("type: {k:?}"),
|
||||
})
|
||||
.collect();
|
||||
let [first, second] = texts.as_slice() else { panic!("two frames: {texts:?}") };
|
||||
assert!(!first.is_empty() && !second.is_empty(), "the story flows on into the second frame: {texts:?}");
|
||||
assert_eq!(format!("{first}{second}"), "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen");
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
/// That `.ai` with its linked areas damaged (their text paths, frames and characters): read, or
|
||||
/// refused.
|
||||
#[test]
|
||||
fn ps_form_ai_linked_areas_never_panic(cut in 0usize..400, edits in prop::collection::vec((0usize..400, prop::sample::select(vec!['0', '1', '-', '.', ' ', '\n', '(', ')', '\\', 'T', 'p', 'P', 'O', 'o', 'x', 'X', 'm', 'L', 'n'])), 0..12)) {
|
||||
let text = linked_areas_ai(&mutate_text(LINKED_AREAS, cut, &edits));
|
||||
survive("PostScript-form .ai with damaged linked areas", || vectorcraft_engine::cmd::fileio::load("x.ai", text.as_bytes()).ok().map(|l| l.doc))?;
|
||||
}
|
||||
}
|
||||
|
||||
/// An operand of a layer's options (`Lb`): a flag, a colour index or component, a percentage, or
|
||||
/// anything else.
|
||||
fn arb_layer_operand() -> impl Strategy<Value = String> {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Type in the legacy format (version 7 specification §15): text objects `type To` … `TO`, their
|
||||
//! text path (`a b c d tx ty startPt Tp`, an area's frame or the path type runs along, `TP`), the
|
||||
//! modal character and paragraph attributes (`Tr Tf Ts Tz Tt TA Tl Ta Ti`, and §15.4.7 and §22's
|
||||
//! letter and word spacing `TC TW`, hanging punctuation `Tq` and hyphenation `Th`), the paint style, and
|
||||
//! the characters (`Tx`, `Tj`, overflow `TX`; `\r` ends a paragraph). Final-form operators (the
|
||||
//! text paths (`a b c d tx ty startPt Tp`, an area's frame, one for each linked area, or the path
|
||||
//! type runs along, `TP`), the modal character and paragraph attributes
|
||||
//! (`Tr Tf Ts Tz Tt TA Tl Ta Ti`, and §15.4.7 and §22's letter and word spacing `TC TW`, hanging
|
||||
//! punctuation `Tq` and hyphenation `Th`), the paint style, and the characters (`Tx`, `Tj`,
|
||||
//! overflow `TX`; `\r` ends a paragraph). Final-form operators (the
|
||||
//! computed positions, §15.1) are skipped: the type is laid out again. Strings are decoded by the
|
||||
//! file's `TE` encoding, else as Windows-1252; a re-encoded font `/_name` is the font its
|
||||
//! `%AI3_BeginEncoding` names (else `name`).
|
||||
@@ -14,27 +15,46 @@ use vectorcraft_geom::AnchorKind;
|
||||
|
||||
use super::super::obj::V;
|
||||
use super::art::path_data;
|
||||
use super::{FLIP, GState, Reader};
|
||||
use super::{FLIP, GState, HashSet, Node, NodeId, Reader};
|
||||
use crate::latin;
|
||||
|
||||
/// Most characters of type read in one file.
|
||||
const MAX_CHARS: usize = 1 << 22;
|
||||
/// Largest type size and indent read (points).
|
||||
const MAX_SIZE: f64 = 1e4;
|
||||
/// Most text paths read in one text object (its linked areas); the frames of others are left out,
|
||||
/// with a warning.
|
||||
const MAX_TEXT_PATHS: usize = 4096;
|
||||
/// Most frames of linked areas times characters of their story: the story is laid out again
|
||||
/// through its frames when the file opens. The frames of the areas past it are left out, with a
|
||||
/// warning.
|
||||
const MAX_THREAD_WORK: usize = MAX_CHARS;
|
||||
/// The warning when [`MAX_TEXT_PATHS`] or [`MAX_THREAD_WORK`] leaves frames of linked areas out.
|
||||
const LINKED_AREAS_LEFT_OUT: &str = "linked type areas past VectorCraft's limits were left out";
|
||||
|
||||
/// A text path (`Tp` … `TP`).
|
||||
#[derive(Debug, Default)]
|
||||
struct TextPath {
|
||||
/// `Tp`'s matrix: text space (y up) → art space.
|
||||
matrix: Option<Affine>,
|
||||
/// Where type on a path starts: a segment's index plus how far along it.
|
||||
start: f64,
|
||||
/// The frame or path, in art space, and the kind of the anchor each of its segments ends on.
|
||||
path: Option<(BezPath, Vec<AnchorKind>)>,
|
||||
}
|
||||
|
||||
/// A text object being read.
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct LegacyText {
|
||||
/// `To`'s type: 0 point type, 1 area type, 2 type on a path.
|
||||
kind: i64,
|
||||
/// `Tp`'s matrix: text space (y up) → art space.
|
||||
matrix: Option<Affine>,
|
||||
/// Where type on a path starts: a segment's index plus how far along it.
|
||||
start: f64,
|
||||
/// Its text paths in file order: area type has one for each linked area (§15.5.3).
|
||||
paths: Vec<TextPath>,
|
||||
/// Between `Tp` and `TP`: the path read is the text path.
|
||||
in_path: bool,
|
||||
/// The frame or path, in art space, and the kind of the anchor each of its segments ends on.
|
||||
path: Option<(BezPath, Vec<AnchorKind>)>,
|
||||
/// It has more than [`MAX_TEXT_PATHS`] text paths: the paths read past them are left out.
|
||||
past_limit: bool,
|
||||
/// The story: the characters after every text path, in file order.
|
||||
runs: Vec<TextRun>,
|
||||
/// Each paragraph's attributes, as they were at its first character.
|
||||
paras: Vec<ParaStyle>,
|
||||
@@ -144,27 +164,28 @@ impl Reader<'_> {
|
||||
6 => (nums::<6>(vals), None),
|
||||
_ => (None, None),
|
||||
};
|
||||
if let Some(m) = m {
|
||||
let m = Affine::new(m);
|
||||
let matrix = m.map(Affine::new).filter(|m| {
|
||||
let det = m.determinant();
|
||||
if det.is_finite() && det.abs() > 1e-12 {
|
||||
t.matrix = Some(m);
|
||||
}
|
||||
det.is_finite() && det.abs() > 1e-12
|
||||
});
|
||||
let start = start.filter(|s| s.is_finite() && *s >= 0.0).unwrap_or(0.0);
|
||||
t.past_limit |= t.paths.len() >= MAX_TEXT_PATHS;
|
||||
if !t.past_limit {
|
||||
t.paths.push(TextPath { matrix, start, path: None });
|
||||
}
|
||||
t.start = start.filter(|s| s.is_finite() && *s >= 0.0).unwrap_or(0.0);
|
||||
t.in_path = true;
|
||||
}
|
||||
|
||||
/// The path painted (or `TP`, ending the text path): the text object's frame or path.
|
||||
/// The path painted (or `TP`, ending the text path): the text path's frame or path.
|
||||
pub(super) fn text_path(&mut self) {
|
||||
let path = std::mem::take(&mut self.path);
|
||||
let kinds = std::mem::take(&mut self.path_kinds);
|
||||
self.end_path_quietly();
|
||||
if let Some(t) = self.text.as_mut()
|
||||
&& t.path.is_none()
|
||||
if let Some(p) = self.text.as_mut().filter(|t| !t.past_limit).and_then(|t| t.paths.last_mut())
|
||||
&& p.path.is_none()
|
||||
&& !path.elements().is_empty()
|
||||
{
|
||||
t.path = Some((path, kinds));
|
||||
p.path = Some((path, kinds));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,35 +379,92 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `TO`: the text object ends.
|
||||
/// `TO`: the text object ends. Area type with several linked areas becomes one frame for each
|
||||
/// area, threaded in file order: the first frame holds the story, and the flow from area to
|
||||
/// area (final-form, §15.1) is laid out again. The frames are added last to first, so the
|
||||
/// object's dictionary that follows names the first one ([`Reader::each_linked_frame`]).
|
||||
/// Point type is read at its first text path; area type and type on a path at the first one
|
||||
/// that has a frame or path (else at the first one, as point type).
|
||||
pub(super) fn end_text(&mut self, hidden: bool) -> Result<(), String> {
|
||||
let Some(t) = self.text.take() else { return Ok(()) };
|
||||
if t.runs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let m = t.matrix.unwrap_or(Affine::IDENTITY);
|
||||
let to_doc = self.space();
|
||||
// Art space → text space (y down).
|
||||
let to_text = FLIP * m.inverse();
|
||||
let kind = match (t.kind, &t.path) {
|
||||
(1, Some((p, kinds))) => TextKind::Area { frame: path_data(&(to_text * p.clone()), kinds) },
|
||||
(2, Some((p, kinds))) => {
|
||||
TextKind::OnPath { path: path_data(&(to_text * p.clone()), kinds), start: start_fraction(p, t.start), end: None }
|
||||
}
|
||||
_ => TextKind::Point,
|
||||
let chars: usize = t.runs.iter().map(|r| r.text.chars().count()).sum();
|
||||
let most = (MAX_THREAD_WORK / chars.max(1)).max(1);
|
||||
let all_areas = t.paths.iter().filter(|p| p.path.is_some()).count();
|
||||
let areas: Vec<&TextPath> = t.paths.iter().filter(|p| p.path.is_some()).take(most).collect();
|
||||
let placed: Vec<(Affine, TextKind)> = match t.kind {
|
||||
1 if areas.len() > 1 => areas.into_iter().map(|p| placed(1, Some(p))).collect(),
|
||||
0 => vec![placed(0, t.paths.first())],
|
||||
kind => vec![placed(kind, areas.first().copied().or(t.paths.first()))],
|
||||
};
|
||||
let mut obj = TextObject::point(kurbo::Point::ZERO, "", CharStyle::default());
|
||||
obj.kind = kind;
|
||||
obj.xf = to_doc * m * FLIP;
|
||||
obj.runs = t.runs;
|
||||
if t.kind == 1 && (t.past_limit || all_areas > placed.len()) {
|
||||
self.warn(LINKED_AREAS_LEFT_OUT);
|
||||
}
|
||||
let mut paras = t.paras;
|
||||
if paras.is_empty() {
|
||||
paras.push(self.type_state.para.clone());
|
||||
}
|
||||
obj.set_paragraph_styles(paras);
|
||||
let node = self.new_node(NodeKind::Text(Box::new(obj)), &t.gs);
|
||||
self.add(node, t.hidden || hidden)
|
||||
let last_para = paras.last().cloned().unwrap_or_default();
|
||||
let last_style = t.runs.last().map(|r| r.style.clone()).unwrap_or_default();
|
||||
let mut story = Some((t.runs, paras));
|
||||
let mut nodes = Vec::with_capacity(placed.len());
|
||||
for (m, kind) in placed {
|
||||
let mut obj = TextObject::point(kurbo::Point::ZERO, "", last_style.clone());
|
||||
obj.kind = kind;
|
||||
obj.xf = to_doc * m * FLIP;
|
||||
match story.take() {
|
||||
Some((runs, paras)) => {
|
||||
obj.runs = runs;
|
||||
obj.set_paragraph_styles(paras);
|
||||
}
|
||||
None => obj.set_paragraph_styles(vec![last_para.clone()]),
|
||||
}
|
||||
nodes.push(self.new_node(NodeKind::Text(Box::new(obj)), &t.gs));
|
||||
}
|
||||
let frames: Vec<NodeId> = nodes.iter().map(|n| n.id).collect();
|
||||
for node in nodes.into_iter().rev() {
|
||||
self.add(node, t.hidden || hidden)?;
|
||||
}
|
||||
if frames.len() > 1 {
|
||||
self.doc.text_threads.push(frames);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call `f` on the other frames of the threaded text object just read, whose first frame is
|
||||
/// the last object ([`Reader::end_text`]): the opacity mask and text wrap of the object's
|
||||
/// dictionary apply to every frame.
|
||||
pub(super) fn each_linked_frame(&mut self, mut f: impl FnMut(&mut Node)) {
|
||||
if !self.after_object {
|
||||
return;
|
||||
}
|
||||
let Some(first) = self.frames.last().and_then(|fr| fr.children.last()).map(|n| n.id) else { return };
|
||||
let Some(thread) = self.doc.text_threads.last().filter(|t| t.first() == Some(&first)) else { return };
|
||||
let rest: HashSet<NodeId> = thread.iter().skip(1).copied().collect();
|
||||
let Some(fr) = self.frames.last_mut() else { return };
|
||||
for n in fr.children.iter_mut().rev().skip(1).take(rest.len()).filter(|n| rest.contains(&n.id)) {
|
||||
f(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A text object of type `kind` on text path `p` (none: at the origin): the matrix from text space
|
||||
/// (y up) to art space, and the kind of type with its frame or path in text space (y down).
|
||||
fn placed(kind: i64, p: Option<&TextPath>) -> (Affine, TextKind) {
|
||||
let m = p.and_then(|p| p.matrix).unwrap_or(Affine::IDENTITY);
|
||||
// Art space → text space (y down).
|
||||
let to_text = FLIP * m.inverse();
|
||||
let kind = match (kind, p.and_then(|p| p.path.as_ref().map(|path| (path, p.start)))) {
|
||||
(1, Some(((path, kinds), _))) => TextKind::Area { frame: path_data(&(to_text * path.clone()), kinds) },
|
||||
(2, Some(((path, kinds), start))) => {
|
||||
TextKind::OnPath { path: path_data(&(to_text * path.clone()), kinds), start: start_fraction(path, start), end: None }
|
||||
}
|
||||
_ => TextKind::Point,
|
||||
};
|
||||
(m, kind)
|
||||
}
|
||||
|
||||
/// Where type on `path` starts (`index.fraction`: a segment and how far along it, §15.5.2), as a
|
||||
|
||||
@@ -530,7 +530,8 @@ impl<'a> Reader<'a> {
|
||||
let value =
|
||||
OpacityMask { art: art.clone(), clip: on("Clipping"), invert: on("Inverted"), disabled: on("Disabled"), linked: on("Linked") };
|
||||
if let Some(n) = self.last_object().filter(|n| !matches!(n.kind, NodeKind::Layer { .. })) {
|
||||
n.extras_mut().mask = Some(Box::new(value));
|
||||
n.extras_mut().mask = Some(Box::new(value.clone()));
|
||||
self.each_linked_frame(|n| n.extras_mut().mask = Some(Box::new(value.clone())));
|
||||
} else if !on("Disabled") {
|
||||
self.unreadable("opacity masks without an owning object", false);
|
||||
}
|
||||
@@ -553,6 +554,7 @@ impl<'a> Reader<'a> {
|
||||
&& let Some(n) = self.last_object().filter(|n| !matches!(n.kind, NodeKind::Layer { .. }))
|
||||
{
|
||||
n.extras_mut().wrap = Some(wrap);
|
||||
self.each_linked_frame(|n| n.extras_mut().wrap = Some(wrap));
|
||||
}
|
||||
// The object's XML id, which the variable library's bindings name it by.
|
||||
if let Some(uid) = o.obj("AI10_ArtUID").and_then(Obj::content_text).filter(|u| !u.is_empty())
|
||||
|
||||
@@ -2807,6 +2807,188 @@ fn one_long_run_of_legacy_type_stops_at_the_allowance() {
|
||||
assert_eq!(texts[0].plain_text().chars().count(), ALLOWANCE);
|
||||
}
|
||||
|
||||
/// A legacy-format file whose layer holds `art`, read.
|
||||
fn legacy_read(art: &str) -> Structure {
|
||||
match read(type_stream7(art).as_bytes()) {
|
||||
Ok(r) => r,
|
||||
Err(e) => panic!("{e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The text objects of the first layer, with their ids, top to bottom: a threaded text object's
|
||||
/// frames in file order.
|
||||
fn art_frames(doc: &Document) -> Vec<(vectorcraft_doc::NodeId, vectorcraft_doc::TextObject)> {
|
||||
art(doc).iter().rev().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some((n.id, (**t).clone())) } else { None }).collect()
|
||||
}
|
||||
|
||||
/// An area's frame in the document.
|
||||
fn frame_in_doc(t: &vectorcraft_doc::TextObject) -> Rect {
|
||||
let vectorcraft_doc::TextKind::Area { frame } = &t.kind else { panic!("area type: {:?}", t.kind) };
|
||||
kurbo::Shape::bounding_box(&(t.xf * frame.to_bezpath()))
|
||||
}
|
||||
|
||||
/// Do two matrices match?
|
||||
fn same_matrix(a: Affine, b: Affine) -> bool {
|
||||
a.as_coeffs().iter().zip(b.as_coeffs()).all(|(p, q)| (p - q).abs() < 1e-9)
|
||||
}
|
||||
|
||||
/// Area type with linked areas (§15.5.3): each area's `Tp` … `TP` with its own matrix and frame,
|
||||
/// the characters after each, and overflow text. It opens as one frame for each area, threaded in
|
||||
/// file order, with the story in the first.
|
||||
#[test]
|
||||
fn legacy_area_type_with_linked_areas_is_threaded_frames() {
|
||||
let text = format!(
|
||||
"1 To\n1 0 0 1 0 40 0 Tp\n{}n\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n(Linked ) Tx\n\
|
||||
0 1 -1 0 150 20 0 Tp\n{}n\nTP\n(areas\\r) Tx\n(and more) TX\n(\\r) TX\nTO\n",
|
||||
rect_path(0.0, 0.0, 50.0, 40.0),
|
||||
rect_path(110.0, 20.0, 150.0, 80.0)
|
||||
);
|
||||
let r = legacy_read(&text);
|
||||
let frames = art_frames(&r.doc);
|
||||
let [(a, first), (b, second)] = frames.as_slice() else { panic!("two frames: {frames:?} {:?}", r.warnings) };
|
||||
assert_eq!(r.doc.text_threads, [vec![*a, *b]], "threaded in file order");
|
||||
let in_doc = |x0, y0, x1, y1| r.to_doc.transform_rect_bbox(Rect::new(x0, y0, x1, y1));
|
||||
assert!(near(frame_in_doc(first), in_doc(0.0, 0.0, 50.0, 40.0)), "{:?}", frame_in_doc(first));
|
||||
assert!(near(frame_in_doc(second), in_doc(110.0, 20.0, 150.0, 80.0)), "{:?}", frame_in_doc(second));
|
||||
// Each frame keeps its own area's matrix: the second's type runs up the page.
|
||||
let placed = |m: [f64; 6]| r.to_doc * Affine::new(m) * Affine::FLIP_Y;
|
||||
assert!(same_matrix(first.xf, placed([1.0, 0.0, 0.0, 1.0, 0.0, 40.0])), "{:?}", first.xf);
|
||||
assert!(same_matrix(second.xf, placed([0.0, 1.0, -1.0, 0.0, 150.0, 20.0])), "{:?}", second.xf);
|
||||
// The story: the characters after every area and the overflow text, in the first frame.
|
||||
assert_eq!(first.plain_text(), "Linked areas\nand more");
|
||||
assert_eq!(first.paragraph_styles().len(), 2);
|
||||
assert_eq!(second.plain_text(), "");
|
||||
}
|
||||
|
||||
/// A linked area without a path is left out; a single area with a path is area type in no thread.
|
||||
#[test]
|
||||
fn legacy_area_type_leaves_out_a_linked_area_without_a_path() {
|
||||
let area = |m: &str, path: Option<Rect>, chars: &str| {
|
||||
let path = path.map(|p| format!("{}n\n", rect_path(p.x0, p.y0, p.x1, p.y1))).unwrap_or_default();
|
||||
format!("{m} Tp\n{path}TP\n{chars}")
|
||||
};
|
||||
let style = "0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n";
|
||||
let three = format!(
|
||||
"1 To\n{}{}{}TO\n",
|
||||
area("1 0 0 1 0 40 0", Some(Rect::new(0.0, 0.0, 50.0, 40.0)), &format!("{style}(One ) Tx\n")),
|
||||
area("1 0 0 1 60 40 0", None, "(two ) Tx\n"),
|
||||
area("1 0 0 1 110 40 0", Some(Rect::new(110.0, 0.0, 150.0, 40.0)), "(three) Tx\n"),
|
||||
);
|
||||
let r = legacy_read(&three);
|
||||
let frames = art_frames(&r.doc);
|
||||
let [(a, first), (b, second)] = frames.as_slice() else { panic!("two frames: {frames:?} {:?}", r.warnings) };
|
||||
assert_eq!(r.doc.text_threads, [vec![*a, *b]]);
|
||||
assert_eq!((first.plain_text().as_str(), second.plain_text().as_str()), ("One two three", ""));
|
||||
let one = format!(
|
||||
"1 To\n{}{}TO\n",
|
||||
area("1 0 0 1 60 40 0", None, &format!("{style}(x) Tx\n")),
|
||||
area("1 0 0 1 0 40 0", Some(Rect::new(0.0, 0.0, 50.0, 40.0)), "(y) Tx\n")
|
||||
);
|
||||
let r = legacy_read(&one);
|
||||
let frames = art_frames(&r.doc);
|
||||
let [(_, only)] = frames.as_slice() else { panic!("one frame: {frames:?}") };
|
||||
assert!(r.doc.text_threads.is_empty());
|
||||
assert_eq!(only.plain_text(), "xy");
|
||||
// The frame's own matrix, not the pathless area's.
|
||||
assert!(same_matrix(only.xf, r.to_doc * Affine::translate((0.0, 40.0)) * Affine::FLIP_Y), "{:?}", only.xf);
|
||||
}
|
||||
|
||||
/// One text object reads at most 4,096 linked areas: the paths of the rest are left out, not read as
|
||||
/// art, and their characters stay in the story.
|
||||
#[test]
|
||||
fn legacy_area_type_reads_up_to_its_limit_of_linked_areas() {
|
||||
const LIMIT: usize = 4096;
|
||||
let area = format!("1 0 0 1 0 0 0 Tp\n{}n\nTP\n", rect_path(0.0, 0.0, 50.0, 40.0));
|
||||
let text = format!("1 To\n{}0 Tr\n(a) Tx\n{area}(b) Tx\nTO\n", area.repeat(LIMIT));
|
||||
let r = legacy_read(&text);
|
||||
let frames = art_frames(&r.doc);
|
||||
assert_eq!(frames.len(), LIMIT);
|
||||
assert_eq!(art(&r.doc).len(), LIMIT, "no art paths");
|
||||
assert_eq!(r.doc.text_threads.iter().map(Vec::len).collect::<Vec<_>>(), [LIMIT]);
|
||||
assert_eq!(frames[0].1.plain_text(), "ab");
|
||||
assert_eq!(r.warnings, [LINKED_AREAS_LEFT_OUT]);
|
||||
}
|
||||
|
||||
/// The warning when linked areas are left out.
|
||||
const LINKED_AREAS_LEFT_OUT: &str = "linked type areas past VectorCraft's limits were left out";
|
||||
|
||||
/// The frames of linked areas times the characters of their story are kept to the reader's
|
||||
/// allowance of characters: a story of more than half of it gets one frame, and of a third, two.
|
||||
#[test]
|
||||
fn legacy_area_type_keeps_its_frames_times_characters_to_the_allowance() {
|
||||
const ALLOWANCE: usize = 1 << 22;
|
||||
let area = |x: f64| format!("1 0 0 1 {x} 0 0 Tp\n{}n\nTP\n", rect_path(x, 0.0, x + 50.0, 40.0));
|
||||
for (chars, kept) in [(ALLOWANCE / 2 + 1, 1), (ALLOWANCE / 3, 3), (ALLOWANCE / 3 + 1, 2)] {
|
||||
let text = format!("1 To\n{}0 Tr\n({}) Tx\n{}{}TO\n", area(0.0), "a".repeat(chars), area(60.0), area(120.0));
|
||||
let r = legacy_read(&text);
|
||||
let frames = art_frames(&r.doc);
|
||||
assert_eq!(frames.len(), kept, "{chars}");
|
||||
assert_eq!(r.doc.text_threads.len(), usize::from(kept > 1), "{chars}");
|
||||
assert_eq!(frames[0].1.plain_text().len(), chars);
|
||||
assert_eq!(r.warnings.iter().any(|w| w == LINKED_AREAS_LEFT_OUT), kept < 3, "{chars}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Point type and type on a path have one text path (§15.2): one with more is read at its first.
|
||||
#[test]
|
||||
fn legacy_point_and_path_type_with_several_text_paths_use_the_first() {
|
||||
let point = "0 To\n1 0 0 1 10 50 0 Tp\nTP\n0 Tr\n(a) Tx\n1 0 0 1 90 20 0 Tp\nTP\n(b) Tx\nTO\n";
|
||||
let r = legacy_read(point);
|
||||
let frames = art_frames(&r.doc);
|
||||
let [(_, only)] = frames.as_slice() else { panic!("one object: {frames:?}") };
|
||||
assert!(matches!(only.kind, vectorcraft_doc::TextKind::Point) && r.doc.text_threads.is_empty());
|
||||
assert_eq!(only.plain_text(), "ab");
|
||||
assert!(same_matrix(only.xf, r.to_doc * Affine::translate((10.0, 50.0)) * Affine::FLIP_Y), "the first text path's matrix: {:?}", only.xf);
|
||||
let on_path = "2 To\n1 0 0 1 0 0 0.5 Tp\n0 0 m\n100 0 L\nN\nTP\n0 Tr\n(a) Tx\n1 0 0 1 0 0 0 Tp\n0 50 m\n100 50 L\nN\nTP\n(b) Tx\nTO\n";
|
||||
let r = legacy_read(on_path);
|
||||
let frames = art_frames(&r.doc);
|
||||
let [(_, only)] = frames.as_slice() else { panic!("one object: {frames:?}") };
|
||||
let vectorcraft_doc::TextKind::OnPath { path, start, .. } = &only.kind else { panic!("{:?}", only.kind) };
|
||||
assert!((start - 0.5).abs() < 1e-9, "the first text path's start: {start}");
|
||||
let along = kurbo::Shape::bounding_box(&(only.xf * path.to_bezpath()));
|
||||
assert!((along.y0 - r.to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, 100.0, 0.0)).y0).abs() < 1e-9, "{along:?}");
|
||||
assert!(same_matrix(only.xf, r.to_doc * Affine::FLIP_Y), "{:?}", only.xf);
|
||||
// Type on a path whose first text path has no path: the first one with a path, its matrix and
|
||||
// start included.
|
||||
let later = "2 To\n1 0 0 1 30 30 0.25 Tp\nTP\n0 Tr\n(a) Tx\n1 0 0 1 5 0 0.5 Tp\n0 50 m\n100 50 L\nN\nTP\n(b) Tx\nTO\n";
|
||||
let r = legacy_read(later);
|
||||
let frames = art_frames(&r.doc);
|
||||
let [(_, only)] = frames.as_slice() else { panic!("one object: {frames:?}") };
|
||||
let vectorcraft_doc::TextKind::OnPath { start, .. } = &only.kind else { panic!("{:?}", only.kind) };
|
||||
assert!((start - 0.5).abs() < 1e-9, "{start}");
|
||||
assert!(same_matrix(only.xf, r.to_doc * Affine::translate((5.0, 0.0)) * Affine::FLIP_Y), "{:?}", only.xf);
|
||||
// Area type with no frame at all: point type at its first text path.
|
||||
let frameless = "1 To\n1 0 0 1 10 50 0 Tp\nTP\n0 Tr\n(a) Tx\n1 0 0 1 90 20 0 Tp\nTP\n(b) Tx\nTO\n";
|
||||
let r = legacy_read(frameless);
|
||||
let frames = art_frames(&r.doc);
|
||||
let [(_, only)] = frames.as_slice() else { panic!("one object: {frames:?}") };
|
||||
assert!(matches!(only.kind, vectorcraft_doc::TextKind::Point), "{:?}", only.kind);
|
||||
assert!(same_matrix(only.xf, r.to_doc * Affine::translate((10.0, 50.0)) * Affine::FLIP_Y), "{:?}", only.xf);
|
||||
}
|
||||
|
||||
/// The dictionary that follows a threaded text object (version 9 and later): its opacity mask and
|
||||
/// text wrap go on every frame, and its name on the first frame, which holds the story.
|
||||
#[test]
|
||||
fn the_dictionary_after_linked_area_type_applies_to_its_frames() {
|
||||
let dict = "%_/ArtDictionary :\n%_(Story) /String (AIArtName) ,\n%_12.5 /Real (AI11TextWrapOffset) ,\n\
|
||||
%_0 /Bool (AI11TextWrapInvert) ,\n%_/Mask :\n%_1 /Bool (Linked) ,\n%_0 /Bool (Disabled) ,\n%_X=\n\
|
||||
%_0 0 0 1 k\n%_0 0 m\n%_5 0 L\n%_5 5 L\n%_0 5 L\n%_0 0 L\n%_f\n%_X+\n%_ (Art) ,\n%_1 /Bool (Clipping) ,\n\
|
||||
%_0 /Bool (Inverted) ,\n%_; (AI9_Mask) ,\n%_;\n";
|
||||
let area = |x: f64| format!("1 0 0 1 {x} 40 0 Tp\n{}n\nTP\n", rect_path(x, 0.0, x + 50.0, 40.0));
|
||||
let text = format!("1 To\n{}0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n(a) Tx\n{}{}(b) Tx\nTO\n{dict}", area(0.0), area(60.0), area(120.0));
|
||||
let r = read_ok(&layer(&text));
|
||||
let ids: Vec<_> = art_frames(&r.doc).into_iter().map(|(id, _)| id).collect();
|
||||
assert_eq!(r.doc.text_threads, std::slice::from_ref(&ids), "{:?}", r.warnings);
|
||||
let nodes: Vec<&Node> = ids.iter().filter_map(|id| r.doc.node(*id)).collect();
|
||||
assert_eq!(nodes.len(), 3);
|
||||
for n in &nodes {
|
||||
assert!(n.extras().mask.is_some(), "every frame is masked: {:?}", n.id);
|
||||
assert_eq!(wrap_of(n), Some(vectorcraft_doc::TextWrap { offset: 12.5, invert: false }));
|
||||
}
|
||||
let names: Vec<_> = nodes.iter().map(|n| n.name.as_deref()).collect();
|
||||
assert_eq!(names, [Some("Story"), None, None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_it_doesnt_read_is_an_error_where_it_shows() {
|
||||
let e = read(stream(&layer("1 2 frobnicate\n")).as_bytes()).unwrap_err();
|
||||
|
||||
@@ -120,6 +120,9 @@ private editing data. VectorCraft:
|
||||
without the prolog opens since #1027, its legacy type (`To` … `TO`) since #868, with its paragraphs' letter
|
||||
and word spacing (`TC`, `TW`), hanging punctuation (`Tq`) and hyphenation (`Th`). Its patterns in
|
||||
the version 7 form (a list of `@` and `&` layers, `_ &` for the tile's background) open as pattern swatches,
|
||||
with a fill's offset, scale, rotation, reflection and shear applied before its matrix.
|
||||
Its area type with several linked areas opens as threaded frames, one for each area with its own matrix,
|
||||
and the story flows through them.
|
||||
with a fill's offset, scale, rotation, reflection and shear applied before its matrix. Its linked images in
|
||||
the version 7 form (`(path) modified XG`, then `XF` with no samples) come in as linked images of their files,
|
||||
showing a placeholder where the image goes until the file is relinked in the Links panel.
|
||||
|
||||
Reference in New Issue
Block a user