.ai import: area type flows around its text wrap objects

Fixes #2763. An object whose editing-data dictionary holds the text wrap
options keeps them (offset in points, 6 pt by default, clamped to
±1000 pt; Invert Wrap), and the objects after the text in a version 7
wraparound group (`*w` … `*W`) are wrap objects at the default options.
Area type below a wrap object in its layer flows around it before the
layers are compared with the page, and threaded type flows on into its
next frames. A Large Canvas file's offset is scaled with its art.

The wrap resolver moves into `vectorcraft_doc::text::resolve_wrap` for
the import and the edit-time refresh, and builds each wrap object's
outline once per layer. A file whose wrap shapes would copy more than
2,000,000 anchors is not read from its editing data.

Docs: docs/file-format-parity.md.
This commit is contained in:
dotCooCoo
2026-11-02 12:04:05 +07:00
committed by bflatastic
parent 100730d4d3
commit d4f5004580
14 changed files with 702 additions and 99 deletions
+2
View File
@@ -12,6 +12,7 @@ mod object;
mod para_style;
mod runs;
mod tabs_lists;
mod wrap;
pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape};
use char_style::yes;
@@ -22,6 +23,7 @@ pub use para_style::{
};
pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef};
pub use tabs_lists::{Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, NumberStyle, ParaList, TabAlign, TabStop};
pub use wrap::{resolve_wrap, wrap_work};
/// A text object. `runs` split into paragraphs at `\n`.
///
+142
View File
@@ -0,0 +1,142 @@
//! Object → Text Wrap: area type flows around the wrap objects stacked above it in the same layer.
use std::sync::Arc;
use vectorcraft_geom::{PathData, shapes};
use super::{TextKind, WrapShape};
use crate::{Document, Node, NodeId, NodeKind};
/// Outline of a wrap object in document coordinates: its paths, or its bounds for other art.
fn outline(n: &Node, out: &mut PathData) {
match &n.kind {
NodeKind::Path { path, guide: false, .. } => {
out.subpaths.extend(path.subpaths.iter().filter(|sp| sp.closed || sp.anchors.len() > 2).cloned())
}
NodeKind::Compound { children, .. } | NodeKind::Group { children, clip: false } => {
for c in children.iter().filter(|c| c.visible) {
outline(c, out);
}
}
// A clip group wraps by its clipping path.
NodeKind::Group { children, clip: true } => {
if let Some(c) = children.first() {
outline(c, out);
}
}
_ => {
if let Some(b) = n.geometric_bounds() {
out.subpaths.extend(shapes::rectangle(b).subpaths);
}
}
}
}
enum Item<'a> {
Wrap(&'a Node),
Text(&'a Node),
}
fn collect<'a>(n: &'a Node, out: &mut Vec<Item<'a>>) {
if !n.visible {
return;
}
if n.extras().wrap.is_some() && !matches!(n.kind, NodeKind::Layer { .. }) {
out.push(Item::Wrap(n));
return;
}
match &n.kind {
NodeKind::Text(t) if matches!(t.kind, TextKind::Area { .. }) => out.push(Item::Text(n)),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
for c in children {
collect(c, out);
}
}
_ => {}
}
}
/// How much [`resolve_wrap`] would copy into the wrap shapes of every area text object in `doc`:
/// each wrap object's outline (its anchors, plus one for the shape) once for each area text object
/// below it in its layer.
pub fn wrap_work(doc: &Document) -> usize {
let mut total = 0usize;
for layer in &doc.layers {
let mut items = vec![];
collect(layer, &mut items);
let mut above = 0usize;
for it in items.iter().rev() {
match it {
Item::Wrap(n) => {
let mut pd = PathData::default();
outline(n, &mut pd);
if !pd.is_empty() {
let anchors: usize = pd.subpaths.iter().map(|sp| sp.anchors.len()).sum();
above = above.saturating_add(anchors.saturating_add(1));
}
}
Item::Text(_) => total = total.saturating_add(above),
}
}
}
total
}
/// Resolve the wrap shapes (`TextObject::wrap`, text space) of each area text object from the
/// outlines of the wrap objects above it in its layer. With `before`, the layers it holds unchanged
/// are skipped; without it, every layer is resolved. Returns the area text objects whose shapes
/// changed, whose cached bounds are then out of date.
pub fn resolve_wrap(before: Option<&Document>, doc: &mut Document) -> Vec<NodeId> {
let mut updates: Vec<(NodeId, Vec<WrapShape>)> = vec![];
for (idx, layer) in doc.layers.iter().enumerate() {
if let Some(before) = before
&& (before.layers.get(idx).is_some_and(|b| Arc::ptr_eq(b, layer))
|| before.layers.iter().any(|b| b.id == layer.id && Arc::ptr_eq(b, layer)))
{
continue;
}
let mut items = vec![];
collect(layer, &mut items);
if items.is_empty() {
continue;
}
// From the top down, each wrap object's outline is made once; each area text object takes
// those above it, bottom first.
let mut above: Vec<WrapShape> = vec![];
for it in items.iter().rev() {
match it {
Item::Wrap(n) => {
let mut pd = PathData::default();
outline(n, &mut pd);
if !pd.is_empty() {
above.push(WrapShape { path: pd, wrap: n.extras().wrap.unwrap_or_default() });
}
}
Item::Text(n) => {
let NodeKind::Text(t) = &n.kind else { continue };
let inv = t.xf.inverse();
let shapes: Vec<WrapShape> = above
.iter()
.rev()
.map(|s| {
let mut path = s.path.clone();
path.transform(inv);
WrapShape { path, wrap: s.wrap }
})
.collect();
if shapes != t.wrap {
updates.push((n.id, shapes));
}
}
}
}
}
let mut changed = Vec::with_capacity(updates.len());
for (id, shapes) in updates {
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
t.wrap = shapes;
changed.push(id);
}
}
changed
}
@@ -1106,6 +1106,60 @@ fn texts(s: &Session) -> Vec<String> {
art.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect()
}
/// A `.ai` saved without its PDF part whose layer `Art` holds a story in two area type frames
/// (art x 10 to 90 and 110 to 190, y 10 to 90), then a band across the first frame (y 30 to 70),
/// made a wrap object with no offset when `wrap` is set.
fn wrapped_thread_ai(wrap: bool) -> Vec<u8> {
// The template box is centred at (100, 50), the canvas centre 8191.5: y runs down the canvas.
let canvas = |x: f64, y: f64| (x + 8091.5, 8241.5 - y);
let rect = |x0: f64, y0: f64, x1: f64, y1: f64| {
let corners = [canvas(x0, y1), canvas(x0, y0), canvas(x1, y0), canvas(x1, y1), canvas(x0, y1)];
let segments: Vec<String> =
corners.windows(2).map(|w| format!("{} {} {} {} {} {} {} {}", w[0].0, w[0].1, w[0].0, w[0].1, w[1].0, w[1].1, w[1].0, w[1].1)).collect();
format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 >> >> >>", segments.join(" "))
};
let story = "Words keep flowing from the first frame into the second one, line after line, until the story ends here. ".repeat(3);
let n = story.chars().count() + 1;
let document = format!(
"/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ {} {} ] >> >>\n\
/1 << /1 [ << /0 << /0 ({story}\r) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 0 >> /6 0 >> >> /1 {n} >> ] >> \
/6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /1 10.0 >> >> >> /1 {n} >> ] >> >> /1 << /0 [ << /0 0 >> << /0 1 >> ] >> >> ] /2 << /1 12.0 >> >>\n",
rect(10.0, 10.0, 90.0, 90.0),
rect(110.0, 10.0, 190.0, 90.0)
);
let lines: Vec<String> = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
let frame = |f: u32| format!("/AI11Text :\n0 /FreeUndo ,\n{f} /FrameIndex ,\n0 /StoryIndex ,\n;\n");
let dict = if wrap { "%_/ArtDictionary :\n%_0 /Real (AI11TextWrapOffset) ,\n%_0 /Bool (AI11TextWrapInvert) ,\n%_;\n%_\n" } else { "" };
let art = format!("{}{}0.5 g\n{}f\n{dict}", frame(0), frame(1), ai::rect(10.0, 30.0, 90.0, 70.0));
let body = format!(
"%AI3_TemplateBox: 100 50 100 50\n{}%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n",
ai::layer("Art", &art),
lines.join("\n")
);
ai::ai(&compressed(ai::editing_data(200.0, 100.0, &body).as_bytes()), PLACEHOLDER)
}
/// Threaded area type below a wrap object flows around it when the file opens: the text the wrap
/// object pushes out of the first frame goes on in the second.
#[test]
fn wrap_threaded_area_after_open() {
let second = |wrap: bool| {
let mut s = Session::new();
let r = open(&mut s, "thread.ai", &wrapped_thread_ai(wrap), json!({})).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.text_threads.len(), 1, "{r}");
let text = |id: NodeId| match &doc.node(id).unwrap().kind {
NodeKind::Text(t) => t.clone(),
_ => panic!("area type"),
};
let (first, second) = (text(doc.text_threads[0][0]), text(doc.text_threads[0][1]));
assert_eq!(first.wrap.len(), usize::from(wrap), "{r}");
second.plain_text().chars().count()
};
let (plain, wrapped) = (second(false), second(true));
assert!(wrapped > plain, "the second frame holds more of the story: {plain} → {wrapped}");
}
/// The note that glyphs were kept as outlines is dropped when the type comes from the editing data's
/// text document, and stays with the PDF part alone.
#[test]
+2 -88
View File
@@ -4,11 +4,8 @@
//! resolves, for each area text object, the outlines of the wrap objects above it into
//! `TextObject::wrap` (text space); the layout engine subtracts them from each line's span.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, TextKind, TextWrap, WrapShape};
use vectorcraft_geom::{PathData, shapes};
use vectorcraft_doc::{Document, NodeId, NodeKind, TextWrap};
use super::typecmd::refresh_bounds;
use super::{CommandSpec, EngineError, Result, Session, bool_or, checked_ids_or_selection, cmd, f64_or, has_selection};
@@ -102,93 +99,10 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "offset": w.offset, "invert": w.invert }))
}
/// Outline of a wrap object in document coordinates: its paths, or its bounds for other art.
fn outline(n: &Node, out: &mut PathData) {
match &n.kind {
NodeKind::Path { path, guide: false, .. } => {
out.subpaths.extend(path.subpaths.iter().filter(|sp| sp.closed || sp.anchors.len() > 2).cloned())
}
NodeKind::Compound { children, .. } | NodeKind::Group { children, clip: false } => {
for c in children.iter().filter(|c| c.visible) {
outline(c, out);
}
}
// A clip group wraps by its clipping path.
NodeKind::Group { children, clip: true } => {
if let Some(c) = children.first() {
outline(c, out);
}
}
_ => {
if let Some(b) = n.geometric_bounds() {
out.subpaths.extend(shapes::rectangle(b).subpaths);
}
}
}
}
enum Item<'a> {
Wrap(&'a Node),
Text(NodeId),
}
fn collect<'a>(n: &'a Node, out: &mut Vec<Item<'a>>) {
if !n.visible {
return;
}
if n.extras().wrap.is_some() && !matches!(n.kind, NodeKind::Layer { .. }) {
out.push(Item::Wrap(n));
return;
}
match &n.kind {
NodeKind::Text(t) if matches!(t.kind, TextKind::Area { .. }) => out.push(Item::Text(n.id)),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
for c in children {
collect(c, out);
}
}
_ => {}
}
}
/// Re-resolve the wrap shapes of every area text object (called after each edit).
pub(crate) fn refresh(before: &Document, doc: &mut Document) {
let mut updates: Vec<(NodeId, Vec<WrapShape>)> = vec![];
for (idx, layer) in doc.layers.iter().enumerate() {
if before.layers.get(idx).is_some_and(|b| Arc::ptr_eq(b, layer)) || before.layers.iter().any(|b| b.id == layer.id && Arc::ptr_eq(b, layer)) {
continue;
}
let mut items = vec![];
collect(layer, &mut items);
if items.is_empty() {
continue;
}
for (i, it) in items.iter().enumerate() {
let Item::Text(id) = it else { continue };
let Some(NodeKind::Text(t)) = doc.node(*id).map(|n| &n.kind) else { continue };
let inv = t.xf.inverse();
let shapes: Vec<WrapShape> = items[i + 1..]
.iter()
.filter_map(|w| match w {
Item::Wrap(n) => {
let mut pd = PathData::default();
outline(n, &mut pd);
(!pd.is_empty()).then(|| {
pd.transform(inv);
WrapShape { path: pd, wrap: n.extras().wrap.unwrap_or_default() }
})
}
Item::Text(_) => None,
})
.collect();
if shapes != t.wrap {
updates.push((*id, shapes));
}
}
}
for (id, shapes) in updates {
for id in vectorcraft_doc::text::resolve_wrap(Some(before), doc) {
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
t.wrap = shapes;
refresh_bounds(t);
}
}
+32 -1
View File
@@ -3548,6 +3548,37 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
]
}
/// Text wrap in editing data: an object's dictionary with the wrap options in any order, missing,
/// repeated or of the wrong type, with numbers that aren't finite, nested dictionaries and the kept
/// outline's art; and wraparound groups (`*w` … `*W`), unmatched or nested.
fn arb_ai_wrap_entry() -> impl Strategy<Value = String> {
let value = prop_oneof![
arb_num(),
Just("nan".to_string()),
Just("inf".to_string()),
Just("-1e999".to_string()),
Just("1e-999".to_string()),
Just("(far)".to_string()),
Just("/Name".to_string()),
Just("[ 1 2 ]".to_string()),
];
let ty = prop::sample::select(vec!["/Real", "/Bool", "/Int", "/String", ""]);
let key = prop::sample::select(vec!["AI11TextWrapOffset", "AI11TextWrapInvert", "AI11TextWrapPath", "AI11TextWrapBeingComputed"]);
let entry = (value, ty, key).prop_map(|(v, t, k)| format!("%_{v} {t} ({k}) ,"));
let art = prop::sample::select(vec![
"%_X=\n%_0 0 m\n%_0 9 L\n%_9 9 L\n%_f\n%_X+\n%_ (AI11TextWrapPath) ,",
"%_X=\n%_X=\n%_0 0 m\n%_f\n%_X+\n%_ (AI11TextWrapPath) ,",
"%_X+\n%_ (AI11TextWrapPath) ,",
"%_/Dictionary :\n%_6 /Real (AI11TextWrapOffset) ,\n%_; (Nested) ,",
"%_/Dictionary :\n%_1 /Bool (AI11TextWrapInvert) ,",
])
.prop_map(str::to_string);
let dictionary = prop::collection::vec(prop_oneof![3 => entry, 1 => art], 0..6)
.prop_map(|entries| format!("\n%_/ArtDictionary :\n{}\n%_;\n%_\n", entries.join("\n")));
let group = prop::sample::select(vec!["*w", "*W", "*w *w", "*W *W", "*w u", "U *W", "*w 1 To", "TO *W"]).prop_map(str::to_string);
prop_oneof![2 => dictionary, 1 => group]
}
/// The testkit's sample editing data, compressed as older `.ai` files compress it.
fn ai_sample_compressed() -> Vec<u8> {
[&b"%AI12_CompressedData"[..], &deflated(&vectorcraft_testkit::ai::sample_data())].concat()
@@ -3611,7 +3642,7 @@ proptest! {
/// Hostile editing data in Illustrator EPS and `.ai` files: operators in any order with any
/// operands, sections and dictionaries left open, images of any size.
#[test]
fn ai_hostile_editing_data_never_panics(tokens in prop::collection::vec(arb_ai_token(), 0..80)) {
fn ai_hostile_editing_data_never_panics(tokens in prop::collection::vec(prop_oneof![8 => arb_ai_token(), 1 => arb_ai_wrap_entry()], 0..80)) {
use vectorcraft_testkit::ai;
let data = ai::editing_data(200.0, 100.0, &format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n{}\nLB\n", tokens.join(" ")));
survive("hostile editing data", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?;
+16 -7
View File
@@ -2,6 +2,7 @@ use super::document::slot_of;
use super::ops::{paints_apart, path_rule};
use super::types::{Frame, GState, Kind, MAX_DEPTH, MAX_NODES, PendingEnvelope};
use super::{AppearanceItem, Arc, BezPath, BlendMode, FillRule, HashSet, LayerColor, Node, NodeId, NodeKind, Obj, Paint, PathEl, Rc, Reader, V, obj};
use vectorcraft_doc::TextWrap;
impl<'a> Reader<'a> {
// ---- frames ----------------------------------------------------------------------------
@@ -77,11 +78,12 @@ impl<'a> Reader<'a> {
let f = match what {
"U" => self.close(what, |k| matches!(k, Kind::Group))?,
"*U" => self.close(what, |k| matches!(k, Kind::Compound))?,
"*W" => self.close(what, |k| matches!(k, Kind::Wrap))?,
_ => self.close(what, |k| matches!(k, Kind::Clip))?,
};
let Frame {
kind,
children: kids,
children: mut kids,
gs,
clips,
hidden: opened_hidden,
@@ -96,6 +98,15 @@ impl<'a> Reader<'a> {
self.pending_unread_fill = pending_unread_fill;
self.pending_unread_stroke = pending_unread_stroke;
}
// Wraparound text: the objects after its text object are wrap objects, at the default
// options (the format gives none) unless their dictionaries give theirs.
if matches!(kind, Kind::Wrap)
&& let Some(text) = kids.iter().position(|k| matches!(k.kind, NodeKind::Text(_)) || slot_of(k.name.as_deref()).is_some())
{
for k in kids.iter_mut().skip(text + 1).filter(|k| k.extras().wrap.is_none()) {
k.extras_mut().wrap = Some(TextWrap::default());
}
}
// A group holding only clipping paths (`q … u <path> W n U … Q`) doesn't clip on its own: `u`/`U` don't save
// the graphics state as `q`/`Q` do, so its paths clip the clipping group around it, as the file's PDF part draws it.
if matches!(kind, Kind::Group)
@@ -423,7 +434,7 @@ impl<'a> Reader<'a> {
/// Mark the first object container below the innermost layer.
pub(super) fn mark_unread(&mut self, what: String) -> bool {
let Some(layer) = self.frames.iter().rposition(|f| matches!(f.kind, Kind::Layer(_))) else { return false };
let Some(frame) = self.frames.get_mut(layer + 1).filter(|f| matches!(f.kind, Kind::Group | Kind::Compound | Kind::Clip)) else {
let Some(frame) = self.frames.get_mut(layer + 1).filter(|f| matches!(f.kind, Kind::Group | Kind::Wrap | Kind::Compound | Kind::Clip)) else {
return false;
};
if !frame.unread.contains(&what) {
@@ -443,11 +454,9 @@ impl<'a> Reader<'a> {
self.unreadable(what, false);
return Ok(None);
}
let in_container = self
.frames
.iter()
.rposition(|f| matches!(f.kind, Kind::Layer(_)))
.is_some_and(|layer| self.frames.get(layer + 1).is_some_and(|f| matches!(f.kind, Kind::Group | Kind::Compound | Kind::Clip)));
let in_container = self.frames.iter().rposition(|f| matches!(f.kind, Kind::Layer(_))).is_some_and(|layer| {
self.frames.get(layer + 1).is_some_and(|f| matches!(f.kind, Kind::Group | Kind::Wrap | Kind::Compound | Kind::Clip))
});
if !super::art::sane(bounds) || bounds.area() <= 0.0 {
self.unsupported.insert(what.into());
return Ok(None);
+24 -1
View File
@@ -5,7 +5,7 @@ use super::{
Affine, Arc, Artboard, CANVAS_CENTRE, ColorMode, Document, Node, NodeId, NodeKind, Obj, OpacityMask, Paint, Point, Reader, Rect, Structure,
Swatch, Unit, V, obj,
};
use vectorcraft_doc::{Guide, ImageBlob, PatternDef};
use vectorcraft_doc::{Guide, ImageBlob, PatternDef, TextWrap};
/// `%AI5_RulerUnits` → [`Unit`] (Illustrator 7 spec table 3; centimeters is 4; pixels is 6 in later files).
fn ruler_unit(v: u8) -> Option<Unit> {
@@ -220,6 +220,23 @@ fn variable_sets_node(data: &[u8]) -> Option<&[u8]> {
}
}
/// The text wrap options an object's dictionary gives: its offset in points (`AI11TextWrapOffset`,
/// times `scale`, the factor that takes the art onto the document) and Invert Wrap
/// (`AI11TextWrapInvert`). The outline the file may keep with them (`AI11TextWrapPath`) is not
/// read: the object's own shape gives it. A value that isn't a number is left at its default, and
/// a dictionary without a readable entry gives none.
fn text_wrap(o: &Obj, scale: f64) -> Option<TextWrap> {
let num = |key: &str| o.get(key).and_then(|v| v.iter().find_map(V::num)).filter(|v| v.is_finite());
let offset = num("AI11TextWrapOffset");
let invert = num("AI11TextWrapInvert").filter(|v| *v == 0.0 || *v == 1.0);
if offset.is_none() && invert.is_none() && o.get("AI11TextWrapPath").is_none() {
return None;
}
let default = TextWrap::default();
let offset = offset.map(|v| v * scale).filter(|v| !v.is_nan()).map_or(default.offset, |v| v.clamp(-1000.0, 1000.0));
Some(TextWrap { offset, invert: invert == Some(1.0) })
}
impl<'a> Reader<'a> {
/// An object's dictionary: its name (`AIArtName`, or the XML id `AI10_ArtUID`), and the
/// opacity mask it may carry.
@@ -263,6 +280,12 @@ impl<'a> Reader<'a> {
self.unreadable("opacity masks without readable art", false);
}
}
// Text wrap: area type below the object in its layer flows around it.
if let Some(wrap) = text_wrap(o, self.large_canvas.unwrap_or(1.0))
&& let Some(n) = self.last_object().filter(|n| !matches!(n.kind, NodeKind::Layer { .. }))
{
n.extras_mut().wrap = Some(wrap);
}
let name = match o.text("AIArtName") {
Some(name) => name,
None => match o.obj("AI10_ArtUID").and_then(Obj::content_text) {
+5 -1
View File
@@ -125,11 +125,15 @@ impl<'a> Reader<'a> {
self.end_path_quietly();
self.open(Kind::Compound, hidden)?
}
"*w" => {
self.end_path_quietly();
self.open(Kind::Wrap, hidden)?
}
"q" => {
self.end_path_quietly();
self.open(Kind::Clip, hidden)?
}
"U" | "*U" | "Q" => self.end_container(w, hidden)?,
"U" | "*U" | "*W" | "Q" => self.end_container(w, hidden)?,
"Lb" => self.layer_attrs(&vals),
"Ln" => {
if let Some(Frame { kind: Kind::Layer(a), .. }) = self.frames.last_mut() {
+3
View File
@@ -142,6 +142,9 @@ pub(super) struct LayerAttrs {
pub(super) enum Kind {
Layer(Box<LayerAttrs>),
Group,
/// Wraparound text (`*w` … `*W`, version 7 specification §15.4.2): a group whose objects after
/// its text object are wrap objects.
Wrap,
Compound,
Clip,
Mask,
+96
View File
@@ -3247,6 +3247,102 @@ fn a_blend_record_is_read_to_its_length() {
assert!(matches!(blend.kind, NodeKind::Blend { .. }), "{warnings:?}");
}
/// An object's dictionary with the text wrap `entries` (each a `values (key) ,` line).
fn wrap_dictionary(entries: &[&str]) -> String {
let lines: String = entries.iter().map(|e| format!("%_{e}\n")).collect();
format!("%_/ArtDictionary :\n{lines}%_;\n%_\n")
}
/// The wrap outline a file may keep beside the options: art, then its key.
const WRAP_PATH: &str = "X=\n%_4 As\n%_0 0 m\n%_0 20 L\n%_20 20 L\n%_20 0 L\n%_f\n%_X+\n%_ (AI11TextWrapPath) ,";
fn wrap_of(n: &Node) -> Option<vectorcraft_doc::TextWrap> {
n.extras().wrap
}
#[test]
fn wrap_dictionary_path() {
let dict = wrap_dictionary(&[WRAP_PATH, "0 /Bool (AI11TextWrapInvert) ,", "12.5 /Real (AI11TextWrapOffset) ,"]);
let r = read_ok(&layer(&format!("{RED}{}f\n{dict}", rect_path(10.0, 10.0, 50.0, 50.0))));
let kids = art(&r.doc);
assert_eq!(kids.len(), 1, "the kept outline isn't art: {:?}", r.warnings);
assert_eq!(wrap_of(&kids[0]), Some(vectorcraft_doc::TextWrap { offset: 12.5, invert: false }));
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
#[test]
fn wrap_dictionary_invert_and_default() {
let inverted = wrap_dictionary(&["12.5 /Real (AI11TextWrapOffset) ,", "1 /Bool (AI11TextWrapInvert) ,"]);
let default = wrap_dictionary(&["0 /Bool (AI11TextWrapInvert) ,"]);
let plain = wrap_dictionary(&["(Plain) /String (AIArtName) ,"]);
let r = read_ok(&layer(&format!(
"{RED}{}f\n{inverted}{}f\n{default}{}f\n{plain}",
rect_path(0.0, 0.0, 10.0, 10.0),
rect_path(20.0, 0.0, 30.0, 10.0),
rect_path(40.0, 0.0, 50.0, 10.0)
)));
let kids = art(&r.doc);
assert_eq!(kids.len(), 3);
assert_eq!(wrap_of(&kids[0]), Some(vectorcraft_doc::TextWrap { offset: 12.5, invert: true }));
assert_eq!(wrap_of(&kids[1]), Some(vectorcraft_doc::TextWrap::default()), "6 pt when the offset isn't given");
assert_eq!(wrap_of(&kids[2]), None, "an object without wrap options isn't a wrap object");
assert_eq!(kids[2].name.as_deref(), Some("Plain"));
}
#[test]
fn wrap_dictionary_bad_offset() {
let offsets = ["nan /Real", "1e308 /Real", "(far) /String", "2000 /Real", "-2000 /Real"];
let mut body = RED.to_string();
for (i, offset) in offsets.iter().enumerate() {
let x = 20.0 * i as f64;
body += &format!("{}f\n", rect_path(x, 0.0, x + 10.0, 10.0));
body += &wrap_dictionary(&[&format!("{offset} (AI11TextWrapOffset) ,"), "1 /Bool (AI11TextWrapInvert) ,"]);
}
// An offset that isn't a number, with nothing else of the options, isn't a wrap object.
body += &format!("{}f\n", rect_path(200.0, 0.0, 210.0, 10.0));
body += &wrap_dictionary(&["(far) /String (AI11TextWrapOffset) ,", "7 /Bool (AI11TextWrapInvert) ,"]);
let r = read_ok(&layer(&body));
let offsets: Vec<Option<f64>> = art(&r.doc).iter().map(|n| wrap_of(n).map(|w| w.offset)).collect();
assert_eq!(offsets, [Some(6.0), Some(1000.0), Some(6.0), Some(1000.0), Some(-1000.0), None]);
assert!(art(&r.doc).iter().take(5).all(|n| wrap_of(n).is_some_and(|w| w.invert)));
}
#[test]
fn wrap_dictionary_group() {
let dict = wrap_dictionary(&["6 /Real (AI11TextWrapOffset) ,", "0 /Bool (AI11TextWrapInvert) ,"]);
let r = read_ok(&layer(&format!("u\n{RED}{}f\n{}f\nU\n{dict}", rect_path(0.0, 0.0, 10.0, 10.0), rect_path(20.0, 0.0, 30.0, 10.0))));
let kids = art(&r.doc);
assert_eq!(kids.len(), 1);
assert_eq!(wrap_of(&kids[0]), Some(vectorcraft_doc::TextWrap::default()));
let NodeKind::Group { children, .. } = &kids[0].kind else { panic!("a group: {:?}", kids[0].kind) };
assert_eq!(children.len(), 2);
assert!(children.iter().all(|c| wrap_of(c).is_none() && matches!(c.kind, NodeKind::Path { .. })));
}
/// Wraparound text in the version 7 format (§15.4.2): `*w`, objects, the area text, the objects it
/// wraps around, `*W`.
#[test]
fn legacy_wrap_group() {
let text = format!("1 To\n1 0 0 1 0 0 0 Tp\n{}n\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n(Around) Tx\nTO\n", rect_path(0.0, 0.0, 100.0, 80.0));
// The last object's own dictionary gives its options.
let own = wrap_dictionary(&["3 /Real (AI11TextWrapOffset) ,", "1 /Bool (AI11TextWrapInvert) ,"]);
let body = format!(
"*w\n{RED}{}f\n{text}{}f\n{}f\n{own}*W\n",
rect_path(0.0, 0.0, 5.0, 5.0),
rect_path(30.0, 30.0, 50.0, 50.0),
rect_path(60.0, 30.0, 70.0, 50.0)
);
let r = read_ok(&layer(&body));
let kids = art(&r.doc);
assert_eq!(kids.len(), 1, "{:?}", r.warnings);
let NodeKind::Group { children, clip: false } = &kids[0].kind else { panic!("a group: {:?}", kids[0].kind) };
let wraps: Vec<(bool, bool)> = children.iter().map(|c| (matches!(c.kind, NodeKind::Text(_)), wrap_of(c).is_some())).collect();
assert_eq!(wraps, [(false, false), (true, false), (false, true), (false, true)], "{:?}", r.warnings);
assert_eq!(wrap_of(&children[2]), Some(vectorcraft_doc::TextWrap::default()));
assert_eq!(wrap_of(&children[3]), Some(vectorcraft_doc::TextWrap { offset: 3.0, invert: true }));
assert!(!r.warnings.iter().any(|w| w.contains("*w") || w.contains("*W")), "{:?}", r.warnings);
}
/// The printer's crop marks Illustrator draws after the last layer (`%AI3_BeginCrops`, inside
/// `userdict /AI3_noCropMarks known not { … } if`) aren't art outside the layers: the file reads
/// from its layers, without them (the crop area is `%AI3_Cropmarks`).
+176 -1
View File
@@ -289,6 +289,8 @@ pub(super) fn build_from(
doc.text_threads.push(thread.iter().map(|(_, _, id)| *id).collect());
}
}
// Area type flows around the wrap objects above it before it is compared with the page.
resolve_text_wrap(&mut doc)?;
if empty > 0 {
notes.push(format!("{empty} {}", if frame.is_some() { TEXT_LEFT_OUT } else { TYPE_LEFT_OUT }));
}
@@ -349,10 +351,47 @@ pub(super) fn build_from(
doc.limit_depth_noting(&mut notes);
if small.is_some() {
scale_document(&mut doc, unit);
relayout_wrapped(&mut doc);
}
Ok((doc, notes))
}
/// Most anchors copied into the wrap shapes of a file's area type ([`vectorcraft_doc::text::wrap_work`]).
const MAX_WRAP_WORK: usize = 2_000_000;
/// [`relayout_wrapped`], or an error when the wrap shapes would hold more than [`MAX_WRAP_WORK`]
/// anchors.
fn resolve_text_wrap(doc: &mut Document) -> Result<(), String> {
if has_wraps(doc) && vectorcraft_doc::text::wrap_work(doc) > MAX_WRAP_WORK {
return Err("it has too many text objects".into());
}
relayout_wrapped(doc);
Ok(())
}
/// Does an object of `doc` have text wrap options, or a text object wrap shapes?
fn has_wraps(doc: &Document) -> bool {
let mut wraps = false;
doc.walk(|n| wraps |= n.extras().wrap.is_some() || matches!(&n.kind, NodeKind::Text(t) if !t.wrap.is_empty()));
wraps
}
/// Resolve the wrap shapes of the area type in `doc` that has wrap objects above it (see
/// [`vectorcraft_doc::text::resolve_wrap`]); the type whose shapes changed is laid out again.
/// Nothing is resolved when the shapes would hold more than [`MAX_WRAP_WORK`] anchors (such a
/// file isn't read from its layers: see [`resolve_text_wrap`]).
fn relayout_wrapped(doc: &mut Document) {
if !has_wraps(doc) || vectorcraft_doc::text::wrap_work(doc) > MAX_WRAP_WORK {
return;
}
for id in vectorcraft_doc::text::resolve_wrap(None, doc) {
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
t.cached_bounds = None;
t.cached_baselines.clear();
}
}
}
/// A visible page node, with its source order and subtree range.
struct PageArt {
node: Arc<Node>,
@@ -836,6 +875,10 @@ fn elsewhere(doc: &Document, placed: &[(NodeId, Affine)]) -> Option<Document> {
moved = true;
}
}
// Area type wrapped around a moved image flows around it where it now is.
if moved {
relayout_wrapped(&mut doc);
}
moved.then_some(doc)
}
@@ -1012,7 +1055,9 @@ fn scale_document(doc: &mut Document, k: f64) {
let a = Affine::scale(k);
let scaling = Scaling { strokes: true, effects: Some(vectorcraft_effects::scale_effect), patterns: true, ..Default::default() };
for layer in &mut doc.layers {
Arc::make_mut(layer).transform(a, scaling);
let layer = Arc::make_mut(layer);
layer.transform(a, scaling);
scale_wrap_offsets(layer, k);
}
for board in &mut doc.artboards {
board.rect = a.transform_rect_bbox(board.rect);
@@ -1022,6 +1067,22 @@ fn scale_document(doc: &mut Document, k: f64) {
}
}
/// The text wrap offsets of `n` and what it holds, times `k` (within the -1000 to 1000 points
/// they may be).
fn scale_wrap_offsets(n: &mut Node, k: f64) {
if let Some(w) = n.extras_if_any().and_then(|e| e.wrap.as_mut()) {
let v = w.offset * k;
if !v.is_nan() {
w.offset = v.clamp(-1000.0, 1000.0);
}
}
if let Some(children) = n.children_mut() {
for c in children {
scale_wrap_offsets(Arc::make_mut(c), k);
}
}
}
/// Marks (in a node's name, until [`prune_unseen`] has looked) the text objects made from the file's
/// text document.
pub(super) const MADE: &str = "\u{1}made";
@@ -1102,6 +1163,8 @@ pub(super) fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Doc
let mut with = view.colour_difference(doc, rect);
let mut removed = 0;
for (id, bounds) in found {
// A removal before it can have let its area type flow differently.
let bounds = if removed > 0 { doc.node(id).and_then(|n| n.visual_bounds()) } else { bounds };
if bounds.is_none_or(|b| b.intersect(rect).is_zero_area()) {
continue;
}
@@ -1109,9 +1172,12 @@ pub(super) fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Doc
if !take_out(&mut without.layers, id, true) {
continue;
}
// Area type that wrapped around the text object flows without it.
relayout_wrapped(&mut without);
let d = view.colour_difference(&without, rect);
if d - with <= UNSEEN_MARGIN {
take_out(&mut doc.layers, id, false);
relayout_wrapped(doc);
with = d;
removed += 1;
}
@@ -1125,6 +1191,115 @@ mod candidate_tests {
use vectorcraft_doc::{ImageBlob, ImageObject};
/// Layers read in the units of a page scaled up to points take their wrap offsets with their art,
/// within ±1000 points.
#[test]
fn scaling_the_document_scales_its_wrap_offsets() {
let mut doc = Document::new(100.0, 100.0);
let mut ids = vec![];
for (x, offset) in [(0.0, 6.0), (20.0, 200.0), (40.0, -150.0)] {
let id = doc.alloc_id();
let mut n = Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Default::default());
n.extras_mut().wrap = Some(vectorcraft_doc::TextWrap { offset, invert: true });
doc.insert(Some(doc.layers[0].id), 0, n).unwrap();
ids.push(id);
}
scale_document(&mut doc, 10.0);
let offsets: Vec<_> = ids.iter().map(|id| doc.node(*id).and_then(|n| n.extras().wrap)).collect();
let wrap = |offset| Some(vectorcraft_doc::TextWrap { offset, invert: true });
assert_eq!(offsets, [wrap(60.0), wrap(1000.0), wrap(-1000.0)]);
}
/// Area type in `doc`'s first layer at `index`, in the frame `r`.
fn insert_area(doc: &mut Document, index: usize, r: Rect) -> NodeId {
let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Wrapped", Default::default());
t.kind = vectorcraft_doc::TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(r) };
let id = doc.alloc_id();
doc.insert(Some(doc.layers[0].id), index, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap()
}
/// A wrap object on top of `doc`'s first layer: a closed path of `anchors` anchors in `r`.
fn insert_wrap(doc: &mut Document, r: Rect, anchors: usize) -> NodeId {
let mut bp = kurbo::BezPath::new();
bp.move_to((r.x0, r.y0));
for i in 1..anchors {
let t = i as f64 / anchors as f64;
bp.line_to((r.x0 + r.width() * t, if i % 2 == 0 { r.y0 } else { r.y1 }));
}
bp.close_path();
let id = doc.alloc_id();
let mut n = Node::path(id, PathData::from_bezpath(&bp), Default::default());
n.extras_mut().wrap = Some(vectorcraft_doc::TextWrap::default());
let top = doc.layers[0].children().map_or(0, Vec::len);
doc.insert(Some(doc.layers[0].id), top, n).unwrap()
}
fn wrap_shapes(doc: &Document, id: NodeId) -> usize {
match doc.node(id).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => t.wrap.len(),
_ => panic!("area type"),
}
}
/// A file whose wrap shapes would copy more than `MAX_WRAP_WORK` anchors isn't read from its
/// layers; one below that is.
#[test]
fn wrap_shapes_of_too_many_text_objects_are_refused() {
for (texts, ok) in [(150, true), (250, false)] {
let mut doc = Document::new(100.0, 100.0);
let ids: Vec<NodeId> = (0..texts).map(|_| insert_area(&mut doc, 0, Rect::new(0.0, 0.0, 100.0, 100.0))).collect();
insert_wrap(&mut doc, Rect::new(40.0, 0.0, 60.0, 100.0), 10_000);
assert!(vectorcraft_doc::text::wrap_work(&doc) >= texts * 10_000);
assert_eq!(resolve_text_wrap(&mut doc).is_ok(), ok, "{texts} text objects");
// Nor is anything copied on the way, as the placed files' other placement does.
relayout_wrapped(&mut doc);
assert!(ids.iter().all(|id| wrap_shapes(&doc, *id) == usize::from(ok)), "{texts} text objects");
}
}
/// Area type that wrapped around an object taken out of the layers flows without it.
#[test]
fn wrap_shapes_go_with_their_wrap_object() {
let mut doc = Document::new(100.0, 100.0);
let text = insert_area(&mut doc, 0, Rect::new(0.0, 0.0, 100.0, 100.0));
let wrap = insert_wrap(&mut doc, Rect::new(40.0, 0.0, 60.0, 100.0), 4);
resolve_text_wrap(&mut doc).unwrap();
assert_eq!(wrap_shapes(&doc, text), 1);
doc.remove(wrap).unwrap();
relayout_wrapped(&mut doc);
assert_eq!(wrap_shapes(&doc, text), 0);
}
/// Area type wrapped around a placed image flows around it at the image's other placement.
#[test]
fn wrap_shapes_follow_an_image_to_its_other_placement() {
let mut doc = Document::new(100.0, 100.0);
let text = insert_area(&mut doc, 0, Rect::new(0.0, 0.0, 100.0, 100.0));
let image = doc.alloc_id();
let mut n = Node::new(
image,
NodeKind::Image(Box::new(ImageObject {
key: "k".into(),
width: 10,
height: 10,
xf: Affine::translate(Vec2::new(10.0, 10.0)),
link: None,
placement: Default::default(),
pixels: None,
})),
);
n.extras_mut().wrap = Some(vectorcraft_doc::TextWrap::default());
doc.insert(Some(doc.layers[0].id), 1, n).unwrap();
resolve_text_wrap(&mut doc).unwrap();
let x0 = |d: &Document| match d.node(text).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => t.wrap.first().and_then(|w| w.path.bounds()).map(|b| b.x0),
_ => None,
};
assert_eq!(x0(&doc), Some(10.0));
let moved = elsewhere(&doc, &[(image, Affine::translate(Vec2::new(50.0, 0.0)))]).unwrap();
assert_eq!(x0(&moved), Some(60.0));
}
/// A page image with a different proxy keeps its editing layer and original blob.
#[test]
fn page_image_with_different_proxy_keeps_editing_layer() {
+46
View File
@@ -214,6 +214,8 @@ pub(super) fn fill_slots(
let frame = ai::slot_frame(n.name.as_deref());
let state = (n.opacity, n.blend, n.isolate, n.knockout);
let transparent = see_through(n);
// A text object that is a wrap object stays one.
let wrap = n.extras().wrap;
// An art style read onto the slot (fills, strokes and effects round the characters) is the type's.
let styled = (!n.appearance.items.is_empty() || !n.appearance.effects.is_empty()).then(|| n.appearance.clone());
let replacement = match content.next() {
@@ -257,6 +259,17 @@ pub(super) fn fill_slots(
parts.push(Arc::new(group));
}
}
// A wrap object stays one: the page's parts of it in a group, when there are several.
if wrap.is_some() {
if parts.len() > 1 {
let mut group = Node::group(doc.alloc_id(), std::mem::take(&mut parts));
group.visible = visible;
parts.push(Arc::new(group));
}
for p in &mut parts {
Arc::make_mut(p).extras_mut().wrap = wrap;
}
}
let n = parts.len();
nodes.splice(i..=i, parts);
i += n;
@@ -277,6 +290,9 @@ pub(super) fn fill_slots(
text.name = if seen { name.cloned() } else { Some(format!("{MADE}{}", name.map_or("", String::as_str))) };
// The type takes the slot's transparency.
(text.opacity, text.blend, text.isolate, text.knockout) = state;
if wrap.is_some() {
text.extras_mut().wrap = wrap;
}
if let Some(slot) = nodes.get_mut(i) {
*slot = Arc::new(*text);
}
@@ -477,3 +493,33 @@ pub(super) fn covered(object: &[Arc<Node>], content: &[Content], keep: impl Fn(u
pub(super) fn extent(parts: &[Arc<Node>]) -> Option<Rect> {
parts.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b))
}
#[cfg(test)]
mod tests {
use super::*;
/// A text slot that is a wrap object, filled with the page's type in two parts, stays one
/// wrap object: a group of the parts.
#[test]
fn a_wrapped_slot_filled_from_the_page_stays_one_wrap_object() {
let mut doc = Document::new(100.0, 100.0);
let mut slot = Node::group(doc.alloc_id(), vec![]);
slot.name = Some("\u{0}text0:0".into());
let wrap = vectorcraft_doc::TextWrap { offset: 3.0, invert: true };
slot.extras_mut().wrap = Some(wrap);
let part = |doc: &mut Document, x: f64| {
let t = vectorcraft_doc::TextObject::point(Point::new(x, 50.0), "Part", Default::default());
Arc::new(Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))))
};
let parts = vec![part(&mut doc, 10.0), part(&mut doc, 60.0)];
let mut nodes = vec![Arc::new(slot)];
let mut content = vec![Content::Page(parts)].into_iter();
let (mut empty, mut made) = (0, vec![]);
fill_slots(&mut doc, &mut nodes, &mut content, &Default::default(), &mut empty, &mut made, &mut vec![]);
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].extras().wrap, Some(wrap));
let NodeKind::Group { children, .. } = &nodes[0].kind else { panic!("a group: {:?}", nodes[0].kind) };
assert_eq!(children.len(), 2);
assert!(children.iter().all(|c| c.extras().wrap.is_none() && matches!(c.kind, NodeKind::Text(_))));
}
}
+103
View File
@@ -2801,6 +2801,109 @@ fn threaded_area_type_flows_through_its_frames() {
assert_eq!(r.document.text_threads, [ids]);
}
/// A filled bar of the art from x 40 to 60, the whole page high, made a wrap object with an offset
/// of 2 points when `wrap` is set.
fn wrap_bar(wrap: bool) -> String {
let dict = "%_/ArtDictionary :\n%_0 /Bool (AI11TextWrapInvert) ,\n%_2 /Real (AI11TextWrapOffset) ,\n%_;\n%_\n";
format!("0 0 1 0 k\n40 0 m\n40 100 L\n60 100 L\n60 0 L\nf\n{}", if wrap { dict } else { "" })
}
/// A story long enough to fill several lines of a 10-point area type frame.
const WRAP_STORY: &str = "Text flows around the bar in the middle of its frame when the bar is a wrap object\\r";
/// The `.ai` editing data of area type from x 10 to 90 of the art, with `below` before it and
/// `above` after it in its layer, and `other` on a layer above → the document read from it alone.
fn wrapped_area(below: &str, above: &str, other: Option<&str>) -> Document {
let doc = document_of(&[area_frame(10.0, 10.0, 90.0, 90.0)], &[story(WRAP_STORY, &[0], "/0 0", "/1 10.0")]);
let mut layers = layer("Art", true, &format!("{below}\n{}{above}", frame_object(0, 0)));
if let Some(other) = other {
layers += &layer("Top", true, other);
}
let (d, notes) = crate::ai_alone(&private(&native_with_text(&layers, &doc))).unwrap();
assert!(!notes.iter().any(|n| n.contains("couldn't be read")), "{notes:?}");
d
}
/// The area type of `d` (its first text object).
fn area_text(d: &Document) -> vectorcraft_doc::TextObject {
let mut found = None;
d.walk(|n| {
if let NodeKind::Text(t) = &n.kind
&& found.is_none()
{
found = Some((**t).clone());
}
});
found.expect("area type")
}
#[test]
fn wrap_area_after_open() {
let d = wrapped_area("", &wrap_bar(true), None);
let t = area_text(&d);
assert_eq!(t.wrap.len(), 1, "one wrap shape, the bar's");
assert_eq!(t.wrap[0].wrap, vectorcraft_doc::TextWrap { offset: 2.0, invert: false });
// Each line keeps to one side of the bar and its 2-point offset.
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
assert!(lay.lines.len() > 1);
let spans: Vec<(f64, f64)> = lay.lines.iter().map(|l| l.avail).collect();
assert!(spans.iter().all(|(a, b)| *b <= 38.0 + 1e-6 || *a >= 62.0 - 1e-6), "{spans:?}");
assert!(spans.iter().any(|(_, b)| *b <= 38.0 + 1e-6) && spans.iter().any(|(a, _)| *a >= 62.0 - 1e-6), "{spans:?}");
// A wrap object below the type, or on another layer, doesn't wrap it.
assert!(area_text(&wrapped_area(&wrap_bar(true), "", None)).wrap.is_empty());
assert!(area_text(&wrapped_area("", "", Some(&wrap_bar(true)))).wrap.is_empty());
}
#[test]
fn no_wrap_metadata_unchanged() {
let d = wrapped_area("", &wrap_bar(false), None);
let t = area_text(&d);
assert!(t.wrap.is_empty());
let mut wraps = 0;
d.walk(|n| wraps += usize::from(n.extras().wrap.is_some()));
assert_eq!(wraps, 0);
// The lines span the frame, through the bar.
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
assert!(
lay.lines.iter().all(|l| l.avail.0 <= 10.0 + 1e-6 && l.avail.1 >= 90.0 - 1e-6),
"{:?}",
lay.lines.iter().map(|l| l.avail).collect::<Vec<_>>()
);
}
/// A text object that is itself a wrap object keeps its options when its type is made from the
/// text document.
#[test]
fn a_wrapped_text_object_stays_a_wrap_object() {
let doc = document_of(
&[area_frame(10.0, 10.0, 90.0, 90.0), point_frame(45.0, 50.0)],
&[story(WRAP_STORY, &[0], "/0 0", "/1 10.0"), story("Mid\\r", &[1], "/0 0", "/1 10.0")],
);
let dict = "%_/ArtDictionary :\n%_0 /Bool (AI11TextWrapInvert) ,\n%_4 /Real (AI11TextWrapOffset) ,\n%_;\n%_\n";
let layers = layer("Art", true, &format!("{}{}{dict}", frame_object(0, 0), frame_object(1, 0)));
let (d, notes) = crate::ai_alone(&private(&native_with_text(&layers, &doc))).unwrap();
let kids = d.layers[0].children().unwrap();
assert_eq!(kids.len(), 2, "{notes:?}");
assert_eq!(kids[1].extras().wrap, Some(vectorcraft_doc::TextWrap { offset: 4.0, invert: false }), "{notes:?}");
let NodeKind::Text(area) = &kids[0].kind else { panic!("area type") };
assert_eq!(area.wrap.len(), 1, "the area type wraps around the text object above it");
}
/// A Large Canvas editing copy is written at 1/N scale: the wrap offset is scaled with the art.
#[test]
fn wrap_offset_on_a_large_canvas() {
let dict = "%_/ArtDictionary :\n%_0.5 /Real (AI11TextWrapOffset) ,\n%_0 /Bool (AI11TextWrapInvert) ,\n%_;\n%_\n";
let art = native_large_canvas(10.0, 10.0, &layer("Art", true, &format!("0 0 1 0 k\n5 5 m\n5.4 5 L\n5.4 5.4 L\n5 5.4 L\nf\n{dict}")));
let r = import(&eps(&page_square(50, 50), &art)).unwrap();
let kids = r.document.layers[0].children().unwrap();
assert_eq!(kids[0].extras().wrap.map(|w| w.offset), Some(5.0), "{:?}", r.warnings);
// A `.ai`'s art is scaled when its page is read in its units: the offset with it.
let art = native_large_canvas(100.0, 10.0, &layer("Art", true, &format!("{}\n{dict}", square(10, 10))));
let (d, w, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0);
assert!(read, "{w:?}");
assert_eq!(d.layers[0].children().unwrap()[0].extras().wrap.map(|w| w.offset), Some(0.5), "{w:?}");
}
/// Replacing a group containing either threaded frame must use the PDF page instead.
#[test]
fn unread_group_holding_threaded_frame_falls_back() {
File diff suppressed because one or more lines are too long