mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-07 12:13:19 +00:00
.ai save: the editing copy keeps type as type
The editing copy of a PDF-compatible .ai wrote every text object as its
outlines. Point type, area type and type on a path are now written as
text: a text object in the layers that names its story, and the text
document after the document data (%AI11_BeginTextDocument) holding the
stories.
- Each story holds its characters (a paragraph ends in \r, a forced line
break is \x03), its character runs (font, size, leading, tracking,
baseline shift, horizontal and vertical scale, solid fill and stroke
with its weight, capitals, Tate-chu-yoko) and its paragraph runs
(alignment, indents, space before and after, hyphenation, right-to-left
direction).
- Point type keeps its point and, when it is turned, its matrix. Type
scaled by one factor, such as all type on a large canvas, has its scale
in its sizes, frame and path. Area type keeps its frame, rows,
columns, gutters and first baseline, type on a path its path and where
it starts and ends, and vertical type its orientation. A threaded story
is one story through its frames in order. Area type and type on a path
carry their frame's path in the layers as well.
- Type the text document can't hold is written as its outlines, with a
warning: type in opacity masks, pattern tiles and art cut to a clipping
object, mirrored type, turned or unevenly scaled area type and type on
a path, frames of several parts, area type that flows around a text wrap,
threads whose frames differ in orientation or include a frame written
as outlines, type and stories past the sizes the reader reads, and type
with inline graphics, ruby, gradient or pattern paint or an appearance
or opacity mask of its own. Other character and paragraph attributes are left out, with
a warning.
Tracking is written as a whole number of thousandths of an em, and
solid paints as simple paints; tracking that isn't a whole number is
rounded, with the warning.
Each construct is written in the form VectorCraft's .ai reader reads
(crates/eps/src/import/ate.rs at 36ef34dfdd: Texts::read :407, story
:501, frame :456, para :562, style :588, paint :622, AreaFrame::read
:284; the text object in import/ai/read/art.rs:15 and its path in
import/ai/frames.rs:26), and the tests read the written data back
through that reader. The scripting
reference documents the same attributes on the reading side (text frame
kinds, columnCount, columnGutter, rowCount, startTValue, endTValue,
orientation, and the character attributes):
https://web.archive.org/web/20160410101607/https://wwwimages.adobe.com/content/dam/Adobe/en/devnet/illustrator/sdk/CC2014/Illustrator%20Scripting%20Reference%20-%20JavaScript.pdf
The reader now closes a text object's frame path painted with a closing
operator (n, f, s, b, h), so area type's frame comes back closed; places
point type whose story keeps no composed lines at its frame's point;
keeps a story's last empty paragraph (only the \r that ends the story is
dropped); gives type made from the text document its text object's lock,
as it gives it its visibility; and gives type its text object's knockout
shape flag, as it gives it its opacity and blend mode.
Tests: the round trip of every kind of type and its state, a second
write of what is read back giving the same bytes, the text document and
stubs, scaled point type, a story split between threaded frames, the
fallbacks and the warnings, and the engine's editing copy with every save
option; a fuzz case for damaged text documents and stubs.
Docs: docs/file-format-parity.md.
This commit is contained in:
@@ -803,6 +803,65 @@ fn images_come_back_from_the_editing_copy() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The type of `doc`'s art, one line each: its kind, point type's matrix or area type's frame on the
|
||||
/// document (rounded to hundredths), its characters, and each run's font, size and fill.
|
||||
fn type_outline(doc: &vectorcraft_doc::Document) -> Vec<String> {
|
||||
use vectorcraft_doc::{NodeKind, TextKind};
|
||||
let mut out = vec![];
|
||||
let round = |v: f64| (v * 100.0).round() / 100.0 + 0.0;
|
||||
doc.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
let (kind, xf) = match &t.kind {
|
||||
TextKind::Point => ("point", t.xf.as_coeffs().map(round).to_vec()),
|
||||
TextKind::Area { frame } => {
|
||||
let b = frame.transformed(t.xf).bounds().unwrap();
|
||||
("area", vec![round(b.x0), round(b.y0), round(b.x1), round(b.y1)])
|
||||
}
|
||||
TextKind::OnPath { .. } => ("path", vec![]),
|
||||
};
|
||||
let runs: Vec<String> = t
|
||||
.runs
|
||||
.iter()
|
||||
.map(|r| format!("{:?} {} {} {} {:?}", r.text, r.style.font_family, r.style.font_style, round(r.style.size), r.style.fill))
|
||||
.collect();
|
||||
out.push(format!("{kind} {xf:?} {runs:?}"));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Point type of two paragraphs, turned point type and area type come back from the editing copy
|
||||
/// for other apps alone as type, with every save option that writes the copy.
|
||||
#[test]
|
||||
fn type_comes_back_from_the_editing_copy() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
|
||||
s.execute("text.create", &json!({"x": 20, "y": 40, "text": "Hello\nWorld", "size": 24})).unwrap();
|
||||
s.execute("text.create", &json!({"x": 150, "y": 100, "text": "Turned"})).unwrap();
|
||||
// The new type is selected.
|
||||
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
|
||||
s.execute("text.create", &json!({"x": 20, "y": 120, "text": "Area type in a frame", "area": {"width": 120, "height": 60}})).unwrap();
|
||||
let path = tmp("type.ai");
|
||||
s.execute("document.save", &json!({"path": path})).unwrap();
|
||||
let before = type_outline(&s.doc().unwrap().doc);
|
||||
assert_eq!(before.len(), 3, "{before:#?}");
|
||||
for opts in [
|
||||
json!({"format": "ai"}),
|
||||
json!({"format": "ai", "compress": false}),
|
||||
json!({"format": "ai", "embedProfiles": false}),
|
||||
json!({"format": "ai", "includeLinked": true}),
|
||||
json!({"format": "ai", "range": "all"}),
|
||||
json!({"format": "ai", "pdfCompatible": false}),
|
||||
] {
|
||||
let r = s.execute("document.export", &opts).unwrap();
|
||||
assert!(!r["warnings"].to_string().contains("type"), "{opts} {}", r["warnings"]);
|
||||
let copy = load::load_with(&path, &b64(&r), &LoadOptions { native: false, ..Default::default() }).unwrap();
|
||||
assert!(copy.editing_data && !copy.restored, "{opts} {:?}", copy.warnings);
|
||||
assert!(!copy.warnings.iter().any(|w| w.contains("editing data")), "{opts} {:?}", copy.warnings);
|
||||
assert_eq!(type_outline(©.doc), before, "{opts}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A document of two artboards (the art on the first): a rectangle filled with a linear gradient of
|
||||
/// three stops placed across part of it, an ellipse filled with an elliptical radial gradient whose
|
||||
/// focal point is off its center, a rectangle stroked with a gradient, rectangles filled and
|
||||
|
||||
@@ -3413,6 +3413,37 @@ proptest! {
|
||||
survive("hostile .ai images and placed files", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?;
|
||||
}
|
||||
|
||||
/// A `.ai` whose type (point type turned, area type and type on a path, their stubs with the
|
||||
/// frames' paths, and the text document they name, as the `.ai` writer writes them) is damaged:
|
||||
/// read from its editing data alone and beside a page.
|
||||
#[test]
|
||||
fn ai_text_documents_never_panic(
|
||||
cut in 0usize..30_000,
|
||||
edits in prop::collection::vec((0usize..30_000, prop::sample::select(vec!['0', '1', '9', ' ', '\n', '(', ')', '[', ']', '<', '>', '/', '%', '_', 'n', 'f', 'z', '~'])), 0..16),
|
||||
) {
|
||||
use vectorcraft_doc::{CharStyle, Node, NodeKind, TextKind, TextObject};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, shapes};
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let mut point = TextObject::point(Point::new(10.0, 30.0), "Turned\ntype", CharStyle::default());
|
||||
point.xf *= Affine::rotate(0.4);
|
||||
let mut area = TextObject::point(Point::ZERO, "Area type", CharStyle::default());
|
||||
area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(20.0, 40.0, 90.0, 90.0)) };
|
||||
area.xf = Affine::IDENTITY;
|
||||
let mut on_path = TextObject::point(Point::ZERO, "On a path", CharStyle::default());
|
||||
on_path.kind = TextKind::OnPath { path: shapes::ellipse(Rect::new(100.0, 20.0, 180.0, 80.0)), start: 0.25, end: None };
|
||||
on_path.xf = Affine::IDENTITY;
|
||||
for t in [point, area, on_path] {
|
||||
let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
}
|
||||
let program = vectorcraft_eps::export_ai_editing(&doc, &vectorcraft_eps::AiEditingOptions::default()).unwrap().program;
|
||||
let text = mutate_text(&String::from_utf8_lossy(&program), cut, &edits);
|
||||
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat();
|
||||
survive("hostile .ai type", || vectorcraft_eps::ai_alone(&private).ok().map(|r| r.0))?;
|
||||
survive("hostile .ai type beside a page", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?;
|
||||
}
|
||||
|
||||
/// A `.ai` whose variable library is damaged or hostile: the library is counted for the warning
|
||||
/// whether the layers are read or not (an inserted `Z` can make an operator the reader doesn't
|
||||
/// handle, and then the layers aren't read).
|
||||
|
||||
@@ -78,12 +78,23 @@
|
||||
//! (`w h rowBytes 32 17 `, then rows of premultiplied ARGB), each in versioned content, `~`,
|
||||
//! `%AI5_EndPlace`. The box is the file's pixels at art space's origin on the canvas; the matrix
|
||||
//! takes it into art space. In opacity masks and pattern tiles, linked images are embedded.
|
||||
//! - Type: a text object `/AI11Text :` naming its story and frame (`/StoryIndex`, `/FrameIndex`),
|
||||
//! for area type and type on a path with its frame's path (`/Art :` … `; /ConfiningPath ,`), and
|
||||
//! the text document after the document data (`%AI11_BeginTextDocument`, see
|
||||
//! `writer::texts`), in the form the reader's `ate.rs` reads: the characters, the fonts, sizes,
|
||||
//! leading, tracking, baseline shift, scales, solid fills and strokes and capitals of each run,
|
||||
//! the alignment, indents, spacing, hyphenation and direction of each paragraph, and the frame:
|
||||
//! point type's point and matrix, area type's frame, rows, columns, gutters and first baseline,
|
||||
//! type on a path's path and span, vertical type, and the frames a threaded story flows through.
|
||||
//!
|
||||
//! Lines end in CR LF. Strings are written in UTF-8. Art space is ruler space: y up, its origin at
|
||||
//! the first artboard's top-left corner. On the canvas ([`CANVAS_SIZE`] points square, y down) the
|
||||
//! artboards together are centered; each artboard's `RulerOrigin` is its top-left corner there, and
|
||||
//! `%AI3_TemplateBox` holds the canvas's center in art space. What the form above can't hold is written as plain filled and stroked paths, each such
|
||||
//! loss a warning: type as its outlines and its inline graphics' art, freeform gradients, gradients
|
||||
//! loss a warning: type the text document can't hold (in opacity masks, pattern tiles and art cut to
|
||||
//! a clipping object, mirrored, turned or unevenly scaled area type and type on a path, inline graphics, ruby,
|
||||
//! gradient or pattern paint, an appearance of its own) as its outlines and its inline graphics'
|
||||
//! art, the character and paragraph attributes the text document doesn't hold, freeform gradients, gradients
|
||||
//! along or across a stroke and gradients without stops, with more than 256 stops or without extent
|
||||
//! as their average color
|
||||
//! (gradients that dither or mix perceptually are written as classic ones, and gradient stops linked
|
||||
@@ -225,6 +236,9 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditi
|
||||
w.definitions(&mut s);
|
||||
s.push_str(&palette);
|
||||
artboards(&mut s, doc, to_art, origin);
|
||||
if !w.texts.is_empty() {
|
||||
s.push_str(&w.texts.section(doc));
|
||||
}
|
||||
s.push_str("%%EndSetup\n");
|
||||
s.push_str(&w.body);
|
||||
s.push_str("%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n");
|
||||
|
||||
@@ -637,10 +637,16 @@ fn unsupported() -> Document {
|
||||
};
|
||||
let im = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im)));
|
||||
doc.insert(Some(layer), usize::MAX, im).unwrap();
|
||||
// Type the text document can't hold (a gradient fill).
|
||||
let shade = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]);
|
||||
let t = vectorcraft_doc::TextObject::point(
|
||||
vectorcraft_geom::Point::new(10.0, 70.0),
|
||||
"Type",
|
||||
vectorcraft_doc::CharStyle { size: 18.0, fill: solid(Color::BLACK), ..vectorcraft_doc::CharStyle::default() },
|
||||
vectorcraft_doc::CharStyle {
|
||||
size: 18.0,
|
||||
fill: Paint::Gradient(Box::new(GradientPaint::new(shade))),
|
||||
..vectorcraft_doc::CharStyle::default()
|
||||
},
|
||||
);
|
||||
let t = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
doc.insert(Some(layer), usize::MAX, t).unwrap();
|
||||
@@ -720,7 +726,9 @@ fn brushed_strokes_are_written_as_their_brush_art() {
|
||||
fn type_is_written_as_create_outlines_makes_it() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let style = vectorcraft_doc::CharStyle { size: 18.0, fill: solid(Color::BLACK), underline: true, ..vectorcraft_doc::CharStyle::default() };
|
||||
// A pattern fill: type the text document can't hold.
|
||||
let pattern = Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY };
|
||||
let style = vectorcraft_doc::CharStyle { size: 18.0, fill: pattern, underline: true, ..vectorcraft_doc::CharStyle::default() };
|
||||
let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 70.0), "Under", style);
|
||||
let mut t = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t)));
|
||||
t.name = Some("Title".into());
|
||||
@@ -3067,3 +3075,781 @@ fn sixteen_bit_cmyk_images_are_written_at_eight_bits() {
|
||||
assert_eq!(out.warnings, [writer::IMAGE_DEPTH]);
|
||||
assert!(program_shown(&doc).contains("/DeviceCMYK XN\n"));
|
||||
}
|
||||
|
||||
// ---------- type ----------
|
||||
|
||||
fn style(size: f64, fill: Color) -> vectorcraft_doc::CharStyle {
|
||||
vectorcraft_doc::CharStyle { size, fill: solid(fill), ..vectorcraft_doc::CharStyle::default() }
|
||||
}
|
||||
|
||||
fn text_node(doc: &mut Document, t: vectorcraft_doc::TextObject) -> Node {
|
||||
Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t)))
|
||||
}
|
||||
|
||||
/// A 300 × 200 document of the type the slice writes as text, on a layer `Type`: two paragraphs
|
||||
/// of point type (the second centered, with space before, a fixed leading) named `Greeting`; point
|
||||
/// type turned 30° in CMYK; area type in two columns with a gutter and a first baseline at its
|
||||
/// leading, its runs plain, then bold, tracked, shifted, scaled, stroked and in capitals, then a
|
||||
/// right-to-left paragraph; type on an open curve from a tenth of its length; vertical point type;
|
||||
/// hidden, locked point type at half opacity multiplied; and area type threaded through two frames.
|
||||
fn typed() -> Document {
|
||||
use vectorcraft_doc::{CharStyle, FirstBaseline, Justify, ParaDirection, ParaStyle, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_geom::Point;
|
||||
let mut doc = Document::new(300.0, 200.0);
|
||||
// One CMYK swatch, not the default ones: their CMYK equivalents round differently once read back.
|
||||
doc.swatches = vec![
|
||||
Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false },
|
||||
Swatch { name: "Ink".into(), paint: solid(BRAND), global: false, spot: false },
|
||||
]
|
||||
.into();
|
||||
doc.swatch_groups = vec![].into();
|
||||
doc.layers.clear();
|
||||
let layer = doc.add_layer(Some("Type"));
|
||||
|
||||
let mut greet = TextObject::point(Point::new(20.0, 40.0), "Hello\nWorld", CharStyle { leading: Some(30.0), ..style(24.0, SKY) });
|
||||
let centered = ParaStyle { justify: Justify::Center, space_before: 6.0, ..ParaStyle::default() };
|
||||
greet.set_paragraph_styles(vec![ParaStyle { first_line_indent: 10.0, ..ParaStyle::default() }, centered]);
|
||||
let greet = named(text_node(&mut doc, greet), "Greeting");
|
||||
doc.insert(Some(layer), usize::MAX, greet).unwrap();
|
||||
|
||||
let mut turned = TextObject::point(Point::ZERO, "Turned", style(18.0, BRAND));
|
||||
turned.xf = Affine::translate((150.0, 100.0)) * Affine::rotate(-std::f64::consts::FRAC_PI_6);
|
||||
let turned = text_node(&mut doc, turned);
|
||||
doc.insert(Some(layer), usize::MAX, turned).unwrap();
|
||||
|
||||
let regular = style(10.0, Color::BLACK);
|
||||
let bold = CharStyle {
|
||||
font_style: "Bold".into(),
|
||||
tracking: 50.0,
|
||||
baseline_shift: 3.0,
|
||||
h_scale: 120.0,
|
||||
v_scale: 90.0,
|
||||
stroke: solid(Color::rgb(0.0, 0.5, 0.0)),
|
||||
stroke_width: 0.5,
|
||||
all_caps: true,
|
||||
..regular.clone()
|
||||
};
|
||||
let mut area = TextObject::point(Point::ZERO, "", regular.clone());
|
||||
area.runs = vec![
|
||||
TextRun::new("Area type flows in two columns ", regular.clone()),
|
||||
TextRun::new("and wraps", bold),
|
||||
TextRun::new(" onto the next line.\nRight to left", regular.clone()),
|
||||
];
|
||||
area.set_paragraph_styles(vec![
|
||||
ParaStyle { left_indent: 4.0, right_indent: 2.0, space_after: 3.0, hyphenate: false, ..ParaStyle::default() },
|
||||
ParaStyle { direction: Some(ParaDirection::RightToLeft), justify: Justify::JustifyAll, ..ParaStyle::default() },
|
||||
]);
|
||||
area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(20.0, 80.0, 140.0, 160.0)) };
|
||||
area.xf = Affine::IDENTITY;
|
||||
area.area.columns = 2;
|
||||
area.area.gutter = 12.0;
|
||||
area.area.first_baseline = FirstBaseline::Leading;
|
||||
area.area.first_baseline_min = 4.0;
|
||||
let area = text_node(&mut doc, area);
|
||||
doc.insert(Some(layer), usize::MAX, area).unwrap();
|
||||
|
||||
let mut on_path = TextObject::point(Point::ZERO, "On a curve", style(12.0, Color::gray(0.6)));
|
||||
let mut curve = PathData::from_bezpath(&kurbo::BezPath::from_vec(vec![
|
||||
kurbo::PathEl::MoveTo((160.0, 150.0).into()),
|
||||
kurbo::PathEl::CurveTo((190.0, 110.0).into(), (250.0, 110.0).into(), (280.0, 150.0).into()),
|
||||
]));
|
||||
curve.subpaths.iter_mut().for_each(|sp| sp.closed = false);
|
||||
on_path.kind = TextKind::OnPath { path: curve, start: 0.1, end: None };
|
||||
on_path.xf = Affine::IDENTITY;
|
||||
let on_path = text_node(&mut doc, on_path);
|
||||
doc.insert(Some(layer), usize::MAX, on_path).unwrap();
|
||||
|
||||
let mut down = TextObject::point(Point::new(280.0, 20.0), "Down", style(14.0, Color::BLACK));
|
||||
down.vertical = true;
|
||||
let down = text_node(&mut doc, down);
|
||||
doc.insert(Some(layer), usize::MAX, down).unwrap();
|
||||
|
||||
let quiet = TextObject::point(Point::new(20.0, 190.0), "Quiet", style(9.0, Color::BLACK));
|
||||
let mut quiet = see_through(text_node(&mut doc, quiet), 0.5, BlendMode::Multiply);
|
||||
quiet.visible = false;
|
||||
quiet.locked = true;
|
||||
doc.insert(Some(layer), usize::MAX, quiet).unwrap();
|
||||
|
||||
let mut first = TextObject::point(Point::ZERO, "A story that runs from one frame into the next one.", regular.clone());
|
||||
first.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(160.0, 20.0, 220.0, 40.0)) };
|
||||
first.xf = Affine::IDENTITY;
|
||||
let mut second = first.clone();
|
||||
second.runs = vec![TextRun::new("", regular)];
|
||||
second.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(160.0, 50.0, 220.0, 90.0)) };
|
||||
let (first, second) = (text_node(&mut doc, first), text_node(&mut doc, second));
|
||||
doc.text_threads.push(vec![first.id, second.id]);
|
||||
doc.insert(Some(layer), usize::MAX, first).unwrap();
|
||||
doc.insert(Some(layer), usize::MAX, second).unwrap();
|
||||
doc
|
||||
}
|
||||
|
||||
/// What the reader's view of type is made of, one line per text object: its name, state,
|
||||
/// transparency, kind and frame, matrix, orientation, area options and path span; each paragraph's
|
||||
/// attributes; each run's characters and attributes. Lengths to three decimals.
|
||||
fn type_lines(doc: &Document) -> Vec<String> {
|
||||
use vectorcraft_doc::TextKind;
|
||||
let mut out = vec![];
|
||||
let mut all = vec![];
|
||||
paths(&doc.layers, &mut all);
|
||||
for n in all {
|
||||
let NodeKind::Text(t) = &n.kind else {
|
||||
out.push(format!("not type: {:?}", n.name));
|
||||
continue;
|
||||
};
|
||||
let pts = |p: &PathData| {
|
||||
p.subpaths
|
||||
.iter()
|
||||
.map(|sp| {
|
||||
format!("[{} {}]", sp.closed, sp.anchors.iter().map(|a| format!("{},{}", r3(a.p.x), r3(a.p.y))).collect::<Vec<_>>().join(" "))
|
||||
})
|
||||
.collect::<String>()
|
||||
};
|
||||
let kind = match &t.kind {
|
||||
TextKind::Point => "point".to_string(),
|
||||
TextKind::Area { frame } => format!("area {}", pts(frame)),
|
||||
TextKind::OnPath { path, start, end } => format!("path {} {} {:?}", pts(path), r3(*start), end.map(r3)),
|
||||
};
|
||||
let a = &t.area;
|
||||
out.push(format!(
|
||||
"{:?} shown={} locked={} {} {:?} {kind} xf={:?} vertical={} area {} {} {} {:?} {:?} {}",
|
||||
n.name,
|
||||
n.visible,
|
||||
n.locked,
|
||||
r3(f64::from(n.opacity)),
|
||||
n.blend,
|
||||
t.xf.as_coeffs().map(r3),
|
||||
t.vertical,
|
||||
a.rows,
|
||||
a.columns,
|
||||
r3(a.gutter),
|
||||
a.row_gutter.map(r3),
|
||||
a.first_baseline,
|
||||
r3(a.first_baseline_min),
|
||||
));
|
||||
for p in t.paragraph_styles() {
|
||||
out.push(format!(
|
||||
" para {:?} {} {} {} {} {} {} {:?}",
|
||||
p.justify,
|
||||
r3(p.first_line_indent),
|
||||
r3(p.left_indent),
|
||||
r3(p.right_indent),
|
||||
r3(p.space_before),
|
||||
r3(p.space_after),
|
||||
p.hyphenate,
|
||||
p.direction
|
||||
));
|
||||
}
|
||||
for r in &t.runs {
|
||||
let s = &r.style;
|
||||
out.push(format!(
|
||||
" run {:?} {} {} {} {:?} {} {} {} {} fill {} stroke {} {} caps={} small={:?} tcy={}",
|
||||
r.text,
|
||||
s.font_family,
|
||||
s.font_style,
|
||||
r3(s.size),
|
||||
s.leading.map(r3),
|
||||
r3(s.tracking),
|
||||
r3(s.baseline_shift),
|
||||
r3(s.h_scale),
|
||||
r3(s.v_scale),
|
||||
paint(&s.fill),
|
||||
paint(&s.stroke),
|
||||
r3(s.stroke_width),
|
||||
s.all_caps,
|
||||
s.small_caps.map(r3),
|
||||
s.tate_chu_yoko,
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_comes_back_as_type() {
|
||||
let doc = typed();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
same_lines(&type_lines(&back), &type_lines(&doc));
|
||||
assert_eq!(back.text_threads.len(), 1, "the thread comes back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_is_written_again_as_the_same_bytes() {
|
||||
let doc = typed();
|
||||
let first = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert_eq!(first.program, export_ai_editing(&doc, &opts()).unwrap().program);
|
||||
let again = export_ai_editing(&read_back(&doc), &opts()).unwrap();
|
||||
let text = |p: &[u8]| text_document(&String::from_utf8_lossy(p).replace("\r\n", "\n"));
|
||||
assert_eq!(text(&again.program), text(&first.program));
|
||||
assert_eq!(String::from_utf8_lossy(&again.program), String::from_utf8_lossy(&first.program));
|
||||
}
|
||||
|
||||
/// The text document of program `text` (LF line ends), decoded, with its UTF-16 strings' zero
|
||||
/// bytes left out.
|
||||
fn text_document(text: &str) -> String {
|
||||
let Some(start) = text.find("%AI11_BeginTextDocument\n") else { return String::new() };
|
||||
let end = text.find("%AI11_EndTextDocument").unwrap();
|
||||
let packed: String = text[start..end].lines().skip(2).filter(|l| *l != ";").map(|l| l.strip_prefix('%').unwrap_or(l)).collect();
|
||||
String::from_utf8_lossy(&crate::ps::ascii85_decode(&packed).unwrap()).replace('\0', "")
|
||||
}
|
||||
|
||||
/// The text document: its section after the document data, ASCII85 on comment lines none of which
|
||||
/// is a `%_` line; a stub for each text object, area type and type on a path with their paths.
|
||||
#[test]
|
||||
fn type_is_a_text_document_and_stubs() {
|
||||
let text = program(&typed());
|
||||
let doc_data = text.find("%AI9_EndDocumentData").unwrap();
|
||||
let start = text.find("%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n").unwrap();
|
||||
let end = text.find(";\n%AI11_EndTextDocument\n%%EndSetup\n").unwrap();
|
||||
assert!(doc_data < start && start < end);
|
||||
let body = &text[start..end];
|
||||
for l in body.lines().skip(2) {
|
||||
assert!(l.starts_with('%') && !l.starts_with("%_"), "{l}");
|
||||
}
|
||||
assert_eq!(text.matches("/AI11Text :\n0 /FreeUndo ,\n").count(), 8);
|
||||
assert_eq!(text.matches("; /ConfiningPath ,\n").count(), 4);
|
||||
assert!(text.contains("0 /FrameIndex ,\n6 /StoryIndex ,") && text.contains("1 /FrameIndex ,\n6 /StoryIndex ,"), "{text}");
|
||||
// The decoded document: the version, a font of each face and the hidden characters' font, the
|
||||
// stories, the normal sheets and the document's text settings.
|
||||
let decoded = text_document(&text);
|
||||
assert!(decoded.starts_with("/98 << /0 14 >> /0 <<"), "{decoded}");
|
||||
for want in
|
||||
["SourceSans3-Regular", "SourceSans3-Bold", "AdobeInvisFont", "[Normal Character Style]", "[Normal Paragraph Style]", "Hello\rWorld\r"]
|
||||
{
|
||||
assert!(decoded.contains(want), "{want}: {decoded}");
|
||||
}
|
||||
// Tracking is a whole number, and colors are simple paints.
|
||||
assert!(decoded.contains("/8 50 ") && !decoded.contains("/8 50."), "{decoded}");
|
||||
assert!(decoded.contains("/53 << /99 /SimplePaint") && !decoded.contains("CAITextPaint"), "{decoded}");
|
||||
}
|
||||
|
||||
/// Point type scaled 150% comes back at its size: its scale is in its sizes, its matrix only turns
|
||||
/// it. Type that ends in an empty paragraph keeps it.
|
||||
#[test]
|
||||
fn scaled_point_type_and_a_last_empty_paragraph_come_back() {
|
||||
use vectorcraft_doc::{CharStyle, ParaStyle, TextObject};
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let mut big = TextObject::point(vectorcraft_geom::Point::ZERO, "Big", CharStyle { leading: Some(20.0), ..style(12.0, Color::BLACK) });
|
||||
big.xf = Affine::translate((20.0, 40.0)) * Affine::rotate(0.3) * Affine::scale(1.5);
|
||||
big.set_all_paras(ParaStyle { left_indent: 4.0, ..ParaStyle::default() });
|
||||
let ends = TextObject::point(vectorcraft_geom::Point::new(20.0, 80.0), "Ends\n", style(12.0, Color::BLACK));
|
||||
let (big, ends) = (text_node(&mut doc, big), text_node(&mut doc, ends));
|
||||
doc.insert(Some(layer), usize::MAX, big).unwrap();
|
||||
doc.insert(Some(layer), usize::MAX, ends).unwrap();
|
||||
let back = read_back(&doc);
|
||||
let texts: Vec<&vectorcraft_doc::TextObject> =
|
||||
back.layers[0].children().unwrap().iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.as_ref()) } else { None }).collect();
|
||||
let [big, ends] = texts.as_slice() else { panic!("two text objects: {texts:?}") };
|
||||
let s = big.xf.determinant().sqrt();
|
||||
let st = &big.runs[0].style;
|
||||
assert_eq!((r3(s), r3(st.size), st.leading.map(r3), r3(big.para.left_indent)), (r3(1.0), r3(18.0), Some(r3(30.0)), r3(6.0)));
|
||||
assert_eq!(big.xf.as_coeffs().map(r3), (Affine::translate((20.0, 40.0)) * Affine::rotate(0.3)).as_coeffs().map(r3));
|
||||
assert_eq!(ends.plain_text(), "Ends\n");
|
||||
// A tiny size, and a stroke of no width that keeps its paint.
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let layer = d.layers[0].id;
|
||||
let st = CharStyle { stroke: solid(Color::rgb(1.0, 0.0, 0.0)), stroke_width: 0.0, ..style(0.00001, Color::BLACK) };
|
||||
let n = text_node(&mut d, TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), "Tiny", st));
|
||||
d.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let back = read_back(&d);
|
||||
let NodeKind::Text(t) = &back.layers[0].children().unwrap()[0].kind else { panic!("type") };
|
||||
let s = &t.runs[0].style;
|
||||
assert_eq!(
|
||||
(format!("{:.6}", s.size), paint(&s.stroke), r3(s.stroke_width)),
|
||||
("0.000010".into(), paint(&solid(Color::rgb(1.0, 0.0, 0.0))), r3(0.0))
|
||||
);
|
||||
}
|
||||
|
||||
/// A threaded story whose frames each hold their slice of it, a paragraph split between them,
|
||||
/// comes back as one story in its first frame with each paragraph's attributes once.
|
||||
#[test]
|
||||
fn a_story_split_between_frames_comes_back_whole() {
|
||||
use vectorcraft_doc::{Justify, ParaStyle, TextKind, TextObject, TextRun};
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let st = style(10.0, Color::BLACK);
|
||||
let (p1, p2, p3) = (
|
||||
ParaStyle { justify: Justify::Center, ..ParaStyle::default() },
|
||||
ParaStyle { left_indent: 5.0, ..ParaStyle::default() },
|
||||
ParaStyle { space_before: 4.0, ..ParaStyle::default() },
|
||||
);
|
||||
let mut first = TextObject::point(vectorcraft_geom::Point::ZERO, "", st.clone());
|
||||
first.runs = vec![TextRun::new("One two\nthree ", st.clone())];
|
||||
first.set_paragraph_styles(vec![p1.clone(), p2.clone()]);
|
||||
first.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 40.0)) };
|
||||
first.xf = Affine::IDENTITY;
|
||||
let p4 = ParaStyle { right_indent: 7.0, ..ParaStyle::default() };
|
||||
let mut second = first.clone();
|
||||
second.runs = vec![TextRun::new("four\nFive\n", st)];
|
||||
second.set_paragraph_styles(vec![p2.clone(), p3.clone(), p4.clone()]);
|
||||
second.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(110.0, 10.0, 190.0, 40.0)) };
|
||||
let (first, second) = (text_node(&mut doc, first), text_node(&mut doc, second));
|
||||
doc.text_threads.push(vec![first.id, second.id]);
|
||||
doc.insert(Some(layer), usize::MAX, first).unwrap();
|
||||
doc.insert(Some(layer), usize::MAX, second).unwrap();
|
||||
let back = read_back(&doc);
|
||||
let texts: Vec<&TextObject> =
|
||||
back.layers[0].children().unwrap().iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.as_ref()) } else { None }).collect();
|
||||
let [a, b] = texts.as_slice() else { panic!("two frames: {texts:?}") };
|
||||
assert_eq!((a.plain_text().as_str(), b.plain_text().as_str()), ("One two\nthree four\nFive\n", ""));
|
||||
let attrs = |p: &ParaStyle| (p.justify, r3(p.left_indent), r3(p.right_indent), r3(p.space_before));
|
||||
let got: Vec<_> = a.paragraph_styles().iter().map(attrs).collect();
|
||||
assert_eq!(got, [&p1, &p2, &p3, &p4].into_iter().map(attrs).collect::<Vec<_>>(), "the last, empty paragraph keeps its own");
|
||||
assert_eq!(back.text_threads.len(), 1);
|
||||
assert!(export_ai_editing(&doc, &opts()).unwrap().warnings.is_empty());
|
||||
// An attribute the text document doesn't hold in a later frame is reported.
|
||||
let second_id = doc.text_threads[0][1];
|
||||
let mut lossy = doc.clone();
|
||||
if let NodeKind::Text(t) = &mut lossy.node_mut(second_id).unwrap().kind {
|
||||
t.runs[0].style.underline = true;
|
||||
}
|
||||
assert_eq!(export_ai_editing(&lossy, &opts()).unwrap().warnings, [writer::TYPE_ATTRIBUTES]);
|
||||
// So is a paragraph split between the frames whose second part has other attributes.
|
||||
let mut split = doc.clone();
|
||||
if let NodeKind::Text(t) = &mut split.node_mut(second_id).unwrap().kind {
|
||||
t.set_paragraph_styles(vec![ParaStyle { left_indent: 9.0, ..ParaStyle::default() }, p3, p4]);
|
||||
}
|
||||
assert_eq!(export_ai_editing(&split, &opts()).unwrap().warnings, [writer::TYPE_ATTRIBUTES]);
|
||||
}
|
||||
|
||||
/// A story longer than the reader reads is written as its outlines.
|
||||
#[test]
|
||||
fn a_story_longer_than_the_reader_reads_is_written_as_its_outlines() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let long = format!("A{}", " ".repeat(crate::import::MAX_STORY));
|
||||
let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 50.0), &long, style(12.0, Color::BLACK));
|
||||
let short = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 90.0), "Short", style(12.0, Color::BLACK));
|
||||
let (t, short) = (text_node(&mut doc, t), text_node(&mut doc, short));
|
||||
doc.insert(Some(layer), usize::MAX, t).unwrap();
|
||||
doc.insert(Some(layer), usize::MAX, short).unwrap();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert_eq!(out.warnings, [writer::TYPE]);
|
||||
assert_eq!(program(&doc).matches("/AI11Text :").count(), 1, "the short story is text");
|
||||
}
|
||||
|
||||
/// Stories that together make a text document larger than the reader reads: the ones past it are
|
||||
/// written as their outlines, and what is written reads back.
|
||||
#[test]
|
||||
fn stories_past_the_text_document_the_reader_reads_are_written_as_their_outlines() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let long = format!("A{}", " ".repeat(900_000));
|
||||
for i in 0..9 {
|
||||
// A font of its own, which no other story names.
|
||||
let st = vectorcraft_doc::CharStyle { font_family: format!("Probe Family {i}"), ..style(8.0, Color::BLACK) };
|
||||
let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 10.0 + f64::from(i) * 10.0), &long, st);
|
||||
let n = text_node(&mut doc, t);
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
}
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert_eq!(out.warnings, [writer::TYPE]);
|
||||
let text = String::from_utf8_lossy(&out.program).replace("\r\n", "\n");
|
||||
let stubs = text.matches("/AI11Text :").count();
|
||||
assert!((1..9).contains(&stubs), "{stubs}");
|
||||
// The font set holds the fonts of the stories written, and the hidden characters' font.
|
||||
assert_eq!(text_document(&text).matches("/CoolTypeFont").count(), stubs + 1);
|
||||
let back = read_back(&doc);
|
||||
let texts = back.layers[0].children().unwrap().iter().filter(|n| matches!(n.kind, NodeKind::Text(_))).count();
|
||||
assert_eq!(texts, stubs, "every story written comes back");
|
||||
}
|
||||
|
||||
/// A thread whose frames differ in orientation is written as its outlines: a story's frames share
|
||||
/// one.
|
||||
#[test]
|
||||
fn a_thread_of_horizontal_and_vertical_frames_is_written_as_its_outlines() {
|
||||
use vectorcraft_doc::{TextKind, TextObject};
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let mut first = TextObject::point(vectorcraft_geom::Point::ZERO, "Across, then down", style(10.0, Color::BLACK));
|
||||
first.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 40.0)) };
|
||||
first.xf = Affine::IDENTITY;
|
||||
let mut second = first.clone();
|
||||
second.runs[0].text.clear();
|
||||
second.vertical = true;
|
||||
second.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(110.0, 10.0, 190.0, 90.0)) };
|
||||
let (first, second) = (text_node(&mut doc, first), text_node(&mut doc, second));
|
||||
doc.text_threads.push(vec![first.id, second.id]);
|
||||
doc.insert(Some(layer), usize::MAX, first).unwrap();
|
||||
doc.insert(Some(layer), usize::MAX, second).unwrap();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert_eq!(out.warnings, [writer::TYPE]);
|
||||
assert!(!program(&doc).contains("/AI11Text"));
|
||||
}
|
||||
|
||||
/// A thread with a frame in art cut to a clipping object that isn't a path is written as its
|
||||
/// outlines, every frame of it; so is type whose size is past what the reader reads.
|
||||
#[test]
|
||||
fn a_thread_with_a_frame_in_cut_art_and_type_past_the_readers_sizes_are_outlines() {
|
||||
use vectorcraft_doc::{TextKind, TextObject};
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let mut first = TextObject::point(vectorcraft_geom::Point::ZERO, "A story in two frames", style(10.0, Color::BLACK));
|
||||
first.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 40.0)) };
|
||||
first.xf = Affine::IDENTITY;
|
||||
let mut second = first.clone();
|
||||
second.runs[0].text.clear();
|
||||
second.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(110.0, 10.0, 190.0, 40.0)) };
|
||||
let (first, second) = (text_node(&mut doc, first), text_node(&mut doc, second));
|
||||
doc.text_threads.push(vec![first.id, second.id]);
|
||||
// The second frame is clipped by type: its art is cut to the type's outlines.
|
||||
let clipper = text_node(&mut doc, TextObject::point(vectorcraft_geom::Point::new(110.0, 30.0), "CUT", style(30.0, Color::BLACK)));
|
||||
let cut = group(&mut doc, vec![clipper, second], true);
|
||||
doc.insert(Some(layer), usize::MAX, first).unwrap();
|
||||
doc.insert(Some(layer), usize::MAX, cut).unwrap();
|
||||
let huge = TextObject::point(vectorcraft_geom::Point::new(10.0, 90.0), "Huge", style(12.0, Color::BLACK));
|
||||
let huge = TextObject { xf: Affine::translate((10.0, 90.0)) * Affine::scale(1e5), ..huge };
|
||||
let huge = text_node(&mut doc, huge);
|
||||
doc.insert(Some(layer), usize::MAX, huge).unwrap();
|
||||
let text = program(&doc);
|
||||
assert!(!text.contains("/AI11Text"), "{text}");
|
||||
// Tracking that rounds to the reader's limit, 100,000, is written as outlines.
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let mut wide = TextObject::point(vectorcraft_geom::Point::new(10.0, 50.0), "Wide", style(12.0, Color::BLACK));
|
||||
wide.runs[0].style.tracking = 99_999.6;
|
||||
let wide = text_node(&mut doc, wide);
|
||||
doc.insert(Some(layer), usize::MAX, wide).unwrap();
|
||||
let text = program(&doc);
|
||||
assert!(!text.contains("/AI11Text"), "{text}");
|
||||
// A frame that is the clipping object of a clipping group is written as the clipping outline.
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let mut first = TextObject::point(vectorcraft_geom::Point::ZERO, "A story in two frames", style(10.0, Color::BLACK));
|
||||
first.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 40.0)) };
|
||||
first.xf = Affine::IDENTITY;
|
||||
let mut second = first.clone();
|
||||
second.runs[0].text.clear();
|
||||
second.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(110.0, 10.0, 190.0, 40.0)) };
|
||||
let (first, second) = (text_node(&mut doc, first), text_node(&mut doc, second));
|
||||
doc.text_threads.push(vec![first.id, second.id]);
|
||||
let backing = square(&mut doc, Rect::new(100.0, 0.0, 200.0, 50.0), Color::rgb(1.0, 0.0, 0.0));
|
||||
let clipped = group(&mut doc, vec![second, backing], true);
|
||||
doc.insert(Some(layer), usize::MAX, first).unwrap();
|
||||
doc.insert(Some(layer), usize::MAX, clipped).unwrap();
|
||||
let text = program(&doc);
|
||||
assert!(!text.contains("/AI11Text"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_the_data_cant_hold_is_written_as_its_outlines() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(1.0, Color::rgb(1.0, 0.0, 0.0))]);
|
||||
let fill = Paint::Gradient(Box::new(GradientPaint::new(g)));
|
||||
let t = vectorcraft_doc::TextObject::point(
|
||||
vectorcraft_geom::Point::new(10.0, 70.0),
|
||||
"Shaded",
|
||||
vectorcraft_doc::CharStyle { size: 18.0, fill, ..vectorcraft_doc::CharStyle::default() },
|
||||
);
|
||||
let mirrored = vectorcraft_doc::TextObject {
|
||||
xf: Affine::new([-1.0, 0.0, 0.0, 1.0, 150.0, 70.0]),
|
||||
..vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Mirror", style(18.0, Color::BLACK))
|
||||
};
|
||||
// Area type in a frame of two subpaths: a frame of the text document is one path.
|
||||
let mut holed = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Two rings", style(10.0, Color::BLACK));
|
||||
let mut rings = shapes::rectangle(Rect::new(100.0, 10.0, 190.0, 90.0));
|
||||
rings.subpaths.extend(shapes::rectangle(Rect::new(120.0, 30.0, 170.0, 70.0)).subpaths);
|
||||
holed.kind = vectorcraft_doc::TextKind::Area { frame: rings };
|
||||
holed.xf = Affine::IDENTITY;
|
||||
// A carriage return in the characters: a story ends its paragraphs with it. Area type that
|
||||
// flows around a wrap object: the wrap object's settings aren't written.
|
||||
let returned = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 90.0), "A\rB", style(10.0, Color::BLACK));
|
||||
let mut wrapped = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Wrapped round", style(10.0, Color::BLACK));
|
||||
wrapped.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 60.0)) };
|
||||
wrapped.xf = Affine::IDENTITY;
|
||||
wrapped.wrap = vec![vectorcraft_doc::WrapShape { path: shapes::rectangle(Rect::new(30.0, 20.0, 50.0, 40.0)), wrap: Default::default() }];
|
||||
let wrapped = text_node(&mut doc, wrapped);
|
||||
doc.insert(Some(layer), usize::MAX, wrapped).unwrap();
|
||||
// Type on an open path whose ends meet: it would be read back on a closed path.
|
||||
let mut ring = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Round", style(10.0, Color::BLACK));
|
||||
let mut open_ring = shapes::ellipse(Rect::new(120.0, 10.0, 180.0, 70.0));
|
||||
if let Some(first) = open_ring.subpaths[0].anchors.first().copied() {
|
||||
open_ring.subpaths[0].anchors.push(first);
|
||||
}
|
||||
open_ring.subpaths[0].closed = false;
|
||||
ring.kind = vectorcraft_doc::TextKind::OnPath { path: open_ring, start: 0.0, end: None };
|
||||
ring.xf = Affine::IDENTITY;
|
||||
let ring = text_node(&mut doc, ring);
|
||||
doc.insert(Some(layer), usize::MAX, ring).unwrap();
|
||||
let (t, mirrored, holed, returned) =
|
||||
(text_node(&mut doc, t), text_node(&mut doc, mirrored), text_node(&mut doc, holed), text_node(&mut doc, returned));
|
||||
doc.insert(Some(layer), usize::MAX, t).unwrap();
|
||||
doc.insert(Some(layer), usize::MAX, mirrored).unwrap();
|
||||
doc.insert(Some(layer), usize::MAX, holed).unwrap();
|
||||
doc.insert(Some(layer), usize::MAX, returned).unwrap();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert_eq!(out.warnings, [writer::TYPE]);
|
||||
assert!(!program(&doc).contains("%AI11_BeginTextDocument"));
|
||||
let back = read_back(&doc);
|
||||
let kinds: Vec<bool> = back.layers[0].children().unwrap().iter().map(|n| matches!(n.kind, NodeKind::Group { .. })).collect();
|
||||
assert_eq!(kinds, [true; 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_attributes_the_data_cant_hold_are_reported() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let st = vectorcraft_doc::CharStyle { underline: true, ..style(18.0, Color::BLACK) };
|
||||
let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 70.0), "Under", st);
|
||||
let t = text_node(&mut doc, t);
|
||||
doc.insert(Some(layer), usize::MAX, t).unwrap();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert_eq!(out.warnings, [writer::TYPE_ATTRIBUTES]);
|
||||
let back = read_back(&doc);
|
||||
assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::Text(_)));
|
||||
// Any other attribute the text document doesn't hold is reported too: No Break, a paragraph's
|
||||
// left-to-right direction set on it, Optical Margin Alignment, a font version, a font that isn't
|
||||
// installed whose name doesn't come back (a hyphen in its family), a Lab color,
|
||||
// small capitals at another size, right-to-left letters aligned to the start of a paragraph
|
||||
// without a direction of its own, tracking that isn't a whole number.
|
||||
for edit in [
|
||||
(|t: &mut vectorcraft_doc::TextObject| t.runs[0].style.no_break = true) as fn(&mut vectorcraft_doc::TextObject),
|
||||
|t| t.runs[0].style.tracking = 12.5,
|
||||
|t| t.para.direction = Some(vectorcraft_doc::ParaDirection::LeftToRight),
|
||||
|t| t.optical_margin = true,
|
||||
|t| t.runs[0].style.font_version = Some("2.0".into()),
|
||||
|t| t.runs[0].style.font_family = "my-missing font".into(),
|
||||
|t| t.runs[0].style.fill = Paint::solid(MINT),
|
||||
|t| t.runs[0].style.small_caps = Some(80.0),
|
||||
|t| {
|
||||
t.runs[0].text = "\u{645}\u{631}\u{62d}\u{628}\u{627}".into();
|
||||
t.para.justify = vectorcraft_doc::Justify::Auto;
|
||||
},
|
||||
] {
|
||||
let with = |t: vectorcraft_doc::TextObject| {
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
let layer = d.layers[0].id;
|
||||
let n = text_node(&mut d, t);
|
||||
d.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
export_ai_editing(&d, &opts()).unwrap().warnings
|
||||
};
|
||||
let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 70.0), "Kept", style(18.0, Color::BLACK));
|
||||
assert!(with(t.clone()).is_empty());
|
||||
// A font that isn't installed keeps its family and style as its name.
|
||||
let mut missing = t.clone();
|
||||
missing.runs[0].style.font_family = "Missing Sans Probe".into();
|
||||
missing.runs[0].style.font_style = "Bold Italic".into();
|
||||
assert!(with(missing).is_empty());
|
||||
edit(&mut t);
|
||||
assert_eq!(with(t), [writer::TYPE_ATTRIBUTES]);
|
||||
}
|
||||
}
|
||||
|
||||
/// On a large canvas, area type, threaded frames and type on a path stay type: the frames and
|
||||
/// lengths are written at the canvas's scale. Frames of a thread at two scales, and area type
|
||||
/// scaled by two factors, are written as outlines.
|
||||
#[test]
|
||||
fn scaled_area_type_and_type_on_a_path_stay_type() {
|
||||
use vectorcraft_doc::{TextKind, TextObject};
|
||||
let mut doc = Document::new(20_000.0, 10_000.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let framed = |text: &str, r: Rect| {
|
||||
let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, text, style(100.0, Color::BLACK));
|
||||
t.kind = TextKind::Area { frame: shapes::rectangle(r) };
|
||||
t.xf = Affine::IDENTITY;
|
||||
t
|
||||
};
|
||||
let mut area = framed("Area", Rect::new(1000.0, 1000.0, 5000.0, 3000.0));
|
||||
area.area.columns = 2;
|
||||
area.area.gutter = 200.0;
|
||||
let mut on_path = TextObject::point(vectorcraft_geom::Point::ZERO, "Along", style(100.0, Color::BLACK));
|
||||
let mut curve = PathData::from_bezpath(&kurbo::BezPath::from_vec(vec![
|
||||
kurbo::PathEl::MoveTo((1000.0, 5000.0).into()),
|
||||
kurbo::PathEl::CurveTo((3000.0, 4000.0).into(), (7000.0, 4000.0).into(), (9000.0, 5000.0).into()),
|
||||
]));
|
||||
curve.subpaths.iter_mut().for_each(|sp| sp.closed = false);
|
||||
on_path.kind = TextKind::OnPath { path: curve, start: 0.0, end: None };
|
||||
on_path.xf = Affine::IDENTITY;
|
||||
let first = text_node(&mut doc, framed("A story in two frames", Rect::new(1000.0, 6000.0, 5000.0, 8000.0)));
|
||||
let second = text_node(&mut doc, framed("", Rect::new(6000.0, 6000.0, 10_000.0, 8000.0)));
|
||||
doc.text_threads.push(vec![first.id, second.id]);
|
||||
let (area, on_path) = (text_node(&mut doc, area), text_node(&mut doc, on_path));
|
||||
for n in [area, on_path, first, second] {
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
}
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
|
||||
let back = read_back(&doc);
|
||||
let art = back.layers[0].children().unwrap();
|
||||
let texts: Vec<&TextObject> = art.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.as_ref()) } else { None }).collect();
|
||||
assert_eq!(texts.len(), 4);
|
||||
assert_eq!(back.text_threads.len(), 1);
|
||||
let TextKind::Area { frame } = &texts[0].kind else { panic!("area type") };
|
||||
let b = frame.transformed(texts[0].xf).bounds().unwrap();
|
||||
assert_eq!([b.x0, b.y0, b.x1, b.y1].map(r3), ["1000.000", "1000.000", "5000.000", "3000.000"].map(String::from));
|
||||
// The reader scales the document back: its matrix holds the canvas's scale, and its lengths
|
||||
// through that matrix are the ones written.
|
||||
let k = |t: &TextObject| t.xf.as_coeffs()[0];
|
||||
assert_eq!(
|
||||
(r3(texts[0].runs[0].style.size * k(texts[0])), texts[0].area.columns, r3(texts[0].area.gutter * k(texts[0]))),
|
||||
("100.000".to_string(), 2, "200.000".to_string())
|
||||
);
|
||||
assert!(matches!(texts[1].kind, TextKind::OnPath { .. }));
|
||||
assert_eq!(r3(texts[1].runs[0].style.size * k(texts[1])), "100.000");
|
||||
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let mut uneven = framed("Uneven", Rect::new(10.0, 10.0, 50.0, 30.0));
|
||||
uneven.xf = Affine::new([2.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
|
||||
let uneven = text_node(&mut doc, uneven);
|
||||
doc.insert(Some(layer), usize::MAX, uneven).unwrap();
|
||||
assert!(!program(&doc).contains("/AI11Text"));
|
||||
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let first = framed("A story in two frames", Rect::new(10.0, 10.0, 50.0, 30.0));
|
||||
let mut second = framed("", Rect::new(30.0, 20.0, 50.0, 30.0));
|
||||
second.xf = Affine::scale(2.0);
|
||||
let (first, second) = (text_node(&mut doc, first), text_node(&mut doc, second));
|
||||
doc.text_threads.push(vec![first.id, second.id]);
|
||||
doc.insert(Some(layer), usize::MAX, first).unwrap();
|
||||
doc.insert(Some(layer), usize::MAX, second).unwrap();
|
||||
assert!(!program(&doc).contains("/AI11Text"));
|
||||
}
|
||||
|
||||
/// See-through type with the knockout shape flag comes back with the flag and writes no warning.
|
||||
#[test]
|
||||
fn the_knockout_shape_of_type_comes_back() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let mut text = text_node(&mut doc, vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 40.0), "Hi", style(10.0, Color::BLACK)));
|
||||
(text.opacity, text.knockout_shape) = (0.5, true);
|
||||
doc.insert(Some(layer), usize::MAX, text).unwrap();
|
||||
assert!(export_ai_editing(&doc, &opts()).unwrap().warnings.is_empty());
|
||||
let back = read_back(&doc);
|
||||
let n = &back.layers[0].children().unwrap()[0];
|
||||
assert!(matches!(n.kind, NodeKind::Text(_)));
|
||||
assert_eq!((n.opacity, n.knockout_shape), (0.5, true));
|
||||
}
|
||||
|
||||
/// An empty frame after a slice that ends with a paragraph break holds the story's last paragraph,
|
||||
/// and that paragraph comes back with the empty frame's style.
|
||||
#[test]
|
||||
fn an_empty_last_frame_keeps_the_style_of_the_last_paragraph() {
|
||||
use vectorcraft_doc::{Justify, ParaStyle, TextKind, TextObject};
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let mut first = TextObject::point(vectorcraft_geom::Point::ZERO, "Ends\n", style(10.0, Color::BLACK));
|
||||
first.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 40.0)) };
|
||||
first.xf = Affine::IDENTITY;
|
||||
let mut second = TextObject::point(vectorcraft_geom::Point::ZERO, "", style(10.0, Color::BLACK));
|
||||
second.set_all_paras(ParaStyle { justify: Justify::Right, ..ParaStyle::default() });
|
||||
second.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(110.0, 10.0, 190.0, 40.0)) };
|
||||
second.xf = Affine::IDENTITY;
|
||||
let (first, second) = (text_node(&mut doc, first), text_node(&mut doc, second));
|
||||
doc.text_threads.push(vec![first.id, second.id]);
|
||||
doc.insert(Some(layer), usize::MAX, first).unwrap();
|
||||
doc.insert(Some(layer), usize::MAX, second).unwrap();
|
||||
let back = read_back(&doc);
|
||||
let NodeKind::Text(t) = &back.layers[0].children().unwrap()[0].kind else { panic!("type") };
|
||||
assert_eq!(t.plain_text(), "Ends\n");
|
||||
let justify: Vec<Justify> = t.paragraph_styles().iter().map(|p| p.justify).collect();
|
||||
assert_eq!(justify, [Justify::Left, Justify::Right]);
|
||||
}
|
||||
|
||||
/// A minimum first baseline with the Ascent first baseline is reported, and kept with the others.
|
||||
/// An end bracket of an open path that the reader would read as none is reported, and one further
|
||||
/// from the path's end comes back.
|
||||
#[test]
|
||||
fn a_first_baseline_minimum_and_a_path_end_the_reader_cant_read_are_reported() {
|
||||
use vectorcraft_doc::{FirstBaseline, TextKind, TextObject};
|
||||
let with = |t: TextObject| {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let layer = d.layers[0].id;
|
||||
let n = text_node(&mut d, t);
|
||||
d.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
let warnings = export_ai_editing(&d, &opts()).unwrap().warnings;
|
||||
(warnings, read_back(&d))
|
||||
};
|
||||
let mut area = TextObject::point(vectorcraft_geom::Point::ZERO, "Area", style(12.0, Color::BLACK));
|
||||
area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(20.0, 20.0, 140.0, 160.0)) };
|
||||
area.xf = Affine::IDENTITY;
|
||||
area.area.first_baseline_min = 40.0;
|
||||
let (warnings, _) = with(area.clone());
|
||||
assert_eq!(warnings, [writer::TYPE_ATTRIBUTES]);
|
||||
area.area.first_baseline = FirstBaseline::Fixed;
|
||||
let (warnings, back) = with(area);
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let NodeKind::Text(t) = &back.layers[0].children().unwrap()[0].kind else { panic!("type") };
|
||||
assert_eq!((t.area.first_baseline, t.area.first_baseline_min), (FirstBaseline::Fixed, 40.0));
|
||||
|
||||
let on_path = |end: Option<f64>| {
|
||||
let mut curve = PathData::from_bezpath(&kurbo::BezPath::from_vec(vec![
|
||||
kurbo::PathEl::MoveTo((20.0, 150.0).into()),
|
||||
kurbo::PathEl::CurveTo((50.0, 110.0).into(), (110.0, 110.0).into(), (140.0, 150.0).into()),
|
||||
]));
|
||||
curve.subpaths.iter_mut().for_each(|sp| sp.closed = false);
|
||||
let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "On", style(12.0, Color::BLACK));
|
||||
t.kind = TextKind::OnPath { path: curve, start: 0.0, end };
|
||||
t.xf = Affine::IDENTITY;
|
||||
t
|
||||
};
|
||||
assert_eq!(with(on_path(Some(0.999))).0, [writer::TYPE_ATTRIBUTES]);
|
||||
assert!(with(on_path(None)).0.is_empty());
|
||||
let (warnings, back) = with(on_path(Some(0.5)));
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let NodeKind::Text(t) = &back.layers[0].children().unwrap()[0].kind else { panic!("type") };
|
||||
let TextKind::OnPath { end: Some(end), .. } = t.kind else { panic!("an end bracket") };
|
||||
assert!((end - 0.5).abs() < 1e-3, "{end}");
|
||||
}
|
||||
|
||||
/// A right-to-left paragraph aligned to the start of its direction comes back aligned right, as
|
||||
/// it draws; Latin type aligned to the start comes back aligned left.
|
||||
#[test]
|
||||
fn type_aligned_to_the_start_of_its_direction_keeps_its_side() {
|
||||
use vectorcraft_doc::{Justify, ParaDirection, ParaStyle, TextObject};
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let mut rtl = TextObject::point(vectorcraft_geom::Point::new(150.0, 40.0), "\u{5e9}\u{5dc}\u{5d5}\u{5dd}", style(12.0, Color::BLACK));
|
||||
rtl.set_all_paras(ParaStyle { justify: Justify::Auto, direction: Some(ParaDirection::RightToLeft), ..ParaStyle::default() });
|
||||
let mut ltr = TextObject::point(vectorcraft_geom::Point::new(20.0, 80.0), "Start", style(12.0, Color::BLACK));
|
||||
ltr.set_all_paras(ParaStyle { justify: Justify::Auto, ..ParaStyle::default() });
|
||||
let (rtl, ltr) = (text_node(&mut doc, rtl), text_node(&mut doc, ltr));
|
||||
doc.insert(Some(layer), usize::MAX, rtl).unwrap();
|
||||
doc.insert(Some(layer), usize::MAX, ltr).unwrap();
|
||||
assert!(export_ai_editing(&doc, &opts()).unwrap().warnings.is_empty());
|
||||
let back = read_back(&doc);
|
||||
let sides: Vec<Justify> =
|
||||
back.layers[0].children().unwrap().iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.para.justify) } else { None }).collect();
|
||||
assert_eq!(sides, [Justify::Right, Justify::Left]);
|
||||
}
|
||||
|
||||
/// Type in an opacity mask is written as its outlines; the type beside it as text.
|
||||
#[test]
|
||||
fn type_in_opacity_masks_is_written_as_its_outlines() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let label = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 40.0), "Mask", style(18.0, Color::BLACK));
|
||||
let label = text_node(&mut doc, label);
|
||||
let mut square = square(&mut doc, Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(1.0, 0.0, 0.0));
|
||||
square.extras_mut().mask = Some(Box::new(OpacityMask::new(label, false)));
|
||||
doc.insert(Some(layer), usize::MAX, square).unwrap();
|
||||
let plain = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 90.0), "Plain", style(18.0, Color::BLACK));
|
||||
let plain = text_node(&mut doc, plain);
|
||||
doc.insert(Some(layer), usize::MAX, plain).unwrap();
|
||||
let out = export_ai_editing(&doc, &opts()).unwrap();
|
||||
assert_eq!(out.warnings, [writer::TYPE]);
|
||||
let text = program(&doc);
|
||||
assert_eq!(text.matches("/AI11Text :").count(), 1);
|
||||
}
|
||||
|
||||
/// Type with an opacity mask of its own is written as its outlines, which keep the mask.
|
||||
#[test]
|
||||
fn type_with_an_opacity_mask_is_written_as_its_outlines() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 70.0), "Masked", style(18.0, Color::BLACK));
|
||||
let mut t = text_node(&mut doc, t);
|
||||
let art = square(&mut doc, Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(1.0, 1.0, 1.0));
|
||||
t.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
|
||||
doc.insert(Some(layer), usize::MAX, t).unwrap();
|
||||
assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::TYPE]);
|
||||
assert_eq!(program(&doc).matches("/AI11Text :").count(), 0);
|
||||
let back = read_back(&doc);
|
||||
let top = &back.layers[0].children().unwrap()[0];
|
||||
assert!(!matches!(top.kind, NodeKind::Text(_)));
|
||||
assert!(top.extras().mask.is_some());
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::sync::Arc;
|
||||
use vectorcraft_color::cms::Cms;
|
||||
use vectorcraft_color::swatch::{REGISTRATION, Swatch};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Knockout, LAYER_COLORS, LayerColor, Node, NodeKind, OpacityMask, StrokeLayer};
|
||||
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Knockout, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, OpacityMask, StrokeLayer};
|
||||
use vectorcraft_effects::stroke;
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Shape, SubPath};
|
||||
use vectorcraft_pathops::BoolOp;
|
||||
@@ -17,19 +17,22 @@ use crate::ps;
|
||||
|
||||
mod images;
|
||||
mod paints;
|
||||
mod texts;
|
||||
|
||||
pub(super) use images::PAYLOAD;
|
||||
#[cfg(test)]
|
||||
pub(super) use images::{IMAGE_DEPTH, IMAGES, LINKS_EMBEDDED};
|
||||
#[cfg(test)]
|
||||
pub(super) use paints::{GRADIENT_MIX, GRADIENT_SHAPES, GRADIENTS, STOP_LINKS, SWATCH_PLACEMENT, free_gradient_name};
|
||||
#[cfg(test)]
|
||||
pub(super) use texts::TYPE_ATTRIBUTES;
|
||||
|
||||
/// How deep symbols, live objects and pattern tiles may nest in one another (deeper art is left
|
||||
/// out). Groups and layers need no limit of their own: a document is never deeper than
|
||||
/// [`vectorcraft_doc::MAX_DEPTH`].
|
||||
const MAX_NEST: u32 = 8;
|
||||
|
||||
pub(super) const TYPE: &str = "type is written as its outlines in the editing data for other apps";
|
||||
pub(super) const TYPE: &str = "type in opacity masks, pattern tiles and art cut to a clipping object, mirrored type, area type and type on a path that is turned or unevenly scaled or whose frame or path has several parts, area type that flows around a text wrap, threads whose frames differ in orientation or hold a frame written as outlines, type of sizes past what the reader reads, stories past what the reader reads (1,048,576 bytes a story, 16,777,216 bytes in all, and the size of text document it reads), and type with inline graphics, ruby, gradient or pattern paint, or fills, strokes, effects or an opacity mask of the object's own, is written as its outlines in the editing data for other apps";
|
||||
pub(super) const PATTERNS: &str = "pattern fills whose tiles aren't a grid or whose art reaches past the tile, pattern fills in another pattern's tile and pattern fills nested more than 8 deep are written as their tiles cut to the shape in the editing data for other apps";
|
||||
pub(super) const PATTERN_TILES: &str = "pattern fills with more tiles than a fill places are written filled gray in the editing data for other apps";
|
||||
pub(super) const CLIPPING: &str = "clipping groups and clipping layers whose clipping object isn't a path, and the clipping groups inside them, are written as their art cut to that object in the editing data for other apps";
|
||||
@@ -157,6 +160,8 @@ pub(super) struct Writer<'a> {
|
||||
/// How many parts of the data written on `%_` lines (opacity masks' art, patterns' tiles) the
|
||||
/// art being written is in.
|
||||
hidden: u32,
|
||||
/// The text document: the stories of the type written as text.
|
||||
pub(super) texts: texts::TextDoc,
|
||||
}
|
||||
|
||||
impl<'a> Writer<'a> {
|
||||
@@ -176,6 +181,7 @@ impl<'a> Writer<'a> {
|
||||
canvas_origin: origin,
|
||||
payloads: vec![],
|
||||
hidden: 0,
|
||||
texts: texts::TextDoc::default(),
|
||||
cms: vectorcraft_color::cms::active(),
|
||||
body: String::new(),
|
||||
warnings: vec![],
|
||||
@@ -880,12 +886,39 @@ impl<'a> Writer<'a> {
|
||||
|
||||
// ---------- type ----------
|
||||
|
||||
/// Type `n`: a text object stub naming its story in the text document ([`texts`]) when the
|
||||
/// text document can hold it, outside opacity masks, pattern tiles and art cut to a clipping
|
||||
/// object; else its outlines ([`Self::outlined_text`]). A frame of threaded area type names the
|
||||
/// story of its thread, which its first frame holds.
|
||||
fn text(&mut self, n: &Node, xy: Xy) {
|
||||
let NodeKind::Text(t) = &n.kind else { return };
|
||||
let doc = self.doc;
|
||||
let placed = if self.hidden > 0 || self.clip.is_some() {
|
||||
None
|
||||
} else if let Some(thread) = doc.text_threads.iter().find(|th| th.contains(&n.id)) {
|
||||
// A thread is checked for what it loses, in every frame, when its story is written.
|
||||
let placed = self.texts.thread(doc, thread, n.id, self.canvas, &|id| !cut_to_clipping_object(doc, id));
|
||||
placed.map(|p| (p, self.texts.take_lost()))
|
||||
} else if texts::holds(n, t) {
|
||||
self.texts.add(t, self.canvas).map(|story| ((story, 0), texts::loses(t)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some(((story, frame), lost)) = placed else { return self.outlined_text(n, xy) };
|
||||
if lost {
|
||||
self.warn(texts::TYPE_ATTRIBUTES);
|
||||
}
|
||||
// The stub takes the transparency set before it, as a path does.
|
||||
self.set_xy(xy.object());
|
||||
texts::stub(&mut self.body, story, frame, t, self.to_art);
|
||||
}
|
||||
|
||||
/// Type `n` as a group of what Type › Create Outlines makes of it
|
||||
/// ([`vectorcraft_effects::outline_text`]): each run's glyphs with their underline and
|
||||
/// strikethrough, its color glyphs, and the object's own fills and strokes; then its inline
|
||||
/// graphics as their symbols' art where the layout places them, as the PDF page draws them;
|
||||
/// the group has the type's transparency `xy`.
|
||||
fn text(&mut self, n: &Node, xy: Xy) {
|
||||
fn outlined_text(&mut self, n: &Node, xy: Xy) {
|
||||
self.warn(TYPE);
|
||||
self.line("u");
|
||||
let doc = self.doc;
|
||||
@@ -1153,6 +1186,18 @@ fn compound_path(n: &Node) -> PathData {
|
||||
/// The outline and fill rule a clipping path or compound path `n` clips by; `None` for other
|
||||
/// clipping objects and for a path without points (it clips everything away, see
|
||||
/// [`Writer::clip_contents`]).
|
||||
/// Is object `id` of `doc` in (or inside) a clipping group or clipping layer whose clipping object
|
||||
/// isn't a path or compound path (its art is written cut to that object, [`Writer::clip_group`])?
|
||||
/// Type that is a clipping object is one of these: it isn't a path.
|
||||
fn cut_to_clipping_object(doc: &Document, id: NodeId) -> bool {
|
||||
let ancestry = doc.ancestry(id).unwrap_or_default();
|
||||
let above = ancestry.split_last().map(|(_, a)| a).unwrap_or_default();
|
||||
above.iter().filter_map(|a| doc.node(*a)).any(|p| {
|
||||
matches!(p.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. })
|
||||
&& p.children().and_then(|c| c.first()).is_none_or(|c| clip_path(c).is_none())
|
||||
})
|
||||
}
|
||||
|
||||
fn clip_path(n: &Node) -> Option<(PathData, FillRule)> {
|
||||
let (path, rule) = match &n.kind {
|
||||
NodeKind::Path { path, rule, guide: false, .. } => (path.clone(), *rule),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -71,8 +71,9 @@ use interp::{Fault, Interp};
|
||||
use obj::PsError;
|
||||
|
||||
pub(crate) use ai::MAX_GRADIENT_STOPS;
|
||||
pub(crate) use native::scale_document;
|
||||
pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES};
|
||||
pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, is_loss, layered_ai, layered_ai_from};
|
||||
pub(crate) use native::{MAX_TEXT, scale_document};
|
||||
pub use text::family_style;
|
||||
|
||||
/// The page of a PostScript file without a bounding box (US Letter).
|
||||
|
||||
@@ -66,8 +66,8 @@ fn number_before(head: &[u8], key: &[u8]) -> Option<f64> {
|
||||
number(words(head.get(..k)?).last()?)
|
||||
}
|
||||
|
||||
/// A path in the format's path operators (`m`, `l`/`L`, `c`/`C`, `v`/`V`, `y`/`Y`; any other
|
||||
/// operator ends the numbers before it).
|
||||
/// A path in the format's path operators (`m`, `l`/`L`, `c`/`C`, `v`/`V`, `y`/`Y`, closed by `n`,
|
||||
/// `f`, `s`, `b` or `h`; any other operator ends the numbers before it).
|
||||
fn path(text: &[u8]) -> Option<BezPath> {
|
||||
let mut bp = BezPath::new();
|
||||
let mut nums: Vec<f64> = vec![];
|
||||
@@ -106,6 +106,12 @@ fn path(text: &[u8]) -> Option<BezPath> {
|
||||
bp.curve_to(p(n - 4)?, q, q);
|
||||
cur = Some(q);
|
||||
}
|
||||
// The painting operators that close the path first (lower case), and `h`: an area's frame
|
||||
// is closed, its last point the first again.
|
||||
b"n" | b"f" | b"s" | b"b" | b"h" if cur.is_some() => {
|
||||
bp.close_path();
|
||||
cur = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
nums.clear();
|
||||
@@ -135,4 +141,17 @@ mod tests {
|
||||
assert_eq!(f[&(49, 0)].bounding_box(), kurbo::Rect::new(1.0, 2.0, 3.0, 4.0));
|
||||
assert!(text_frames(b"/AI11Text : garbage").is_empty());
|
||||
}
|
||||
|
||||
/// An area's frame painted with `n` (as the format writes it, the last point the first again)
|
||||
/// is a closed path of its corners; one painted with `N` stays open.
|
||||
#[test]
|
||||
fn a_frame_painted_closed_is_closed() {
|
||||
let doc = b"/AI11Text :\r0 /FreeUndo ,\r0 /FrameIndex ,\r3 /StoryIndex ,\r/Art :\rX=\r40 130 m\r40 250 L\r240 250 L\r240 130 L\r40 130 L\rn\rX+\r; /ConfiningPath ,\r;\r\
|
||||
/AI11Text :\r0 /FreeUndo ,\r0 /FrameIndex ,\r4 /StoryIndex ,\r/Art :\rX=\r40 130 m\r40 250 L\r240 250 L\rN\rX+\r; /ConfiningPath ,\r;\r";
|
||||
let f = text_frames(doc);
|
||||
let closed = vectorcraft_geom::PathData::from_bezpath(&f[&(3, 0)]);
|
||||
assert!(closed.subpaths[0].closed);
|
||||
assert_eq!(closed.subpaths[0].anchors.len(), 4, "the last point merges with the first");
|
||||
assert!(!vectorcraft_geom::PathData::from_bezpath(&f[&(4, 0)]).subpaths[0].closed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ impl<'a> Reader<'a> {
|
||||
// The type takes the state's transparency, as a path does; an `Xy` after it sets the slot's own.
|
||||
n.opacity = gs.opacity;
|
||||
n.blend = gs.blend;
|
||||
n.knockout_shape = gs.knockout_shape;
|
||||
let hidden = self.obj_hidden;
|
||||
self.add(n, hidden)
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
//! offset from it. A right-to-left paragraph (key `33` of its attributes) swaps the two: its
|
||||
//! "left"-aligned lines end at the anchor and its "right"-aligned ones start there. The frame of the
|
||||
//! story gives a matrix that carries that layout onto the canvas of the app, which is 16383 points a
|
||||
//! side; the canvas's centre, 8191.5, is the centre of the file's `%AI3_TemplateBox`. See
|
||||
//! [`Story::place`].
|
||||
//! side; the canvas's centre, 8191.5, is the centre of the file's `%AI3_TemplateBox`. A story without
|
||||
//! a layout has its point type's anchor at its frame's point. See [`Story::place`].
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -28,13 +28,13 @@ use vectorcraft_doc::{CharStyle, Justify, Node, NodeId, NodeKind, ParaDirection,
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect};
|
||||
|
||||
/// Longest story read (bytes).
|
||||
const MAX_STORY: usize = 1 << 20;
|
||||
pub(crate) const MAX_STORY: usize = 1 << 20;
|
||||
/// Biggest text document read (bytes, decoded).
|
||||
const MAX_BYTES: usize = 64 << 20;
|
||||
pub(crate) const MAX_BYTES: usize = 64 << 20;
|
||||
/// Deepest nesting read.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
/// Most values read.
|
||||
const MAX_VALUES: usize = 4_000_000;
|
||||
pub(crate) const MAX_VALUES: usize = 4_000_000;
|
||||
/// The centre of the app's canvas, in the units of its text document.
|
||||
pub(super) const CANVAS_CENTRE: f64 = 8191.5;
|
||||
|
||||
@@ -241,8 +241,8 @@ const MAX_SHEETS: usize = 4096;
|
||||
const MAX_SHEETS_BASED_ON: usize = 16;
|
||||
|
||||
/// Most frames read of one story, and most path segments of a frame.
|
||||
const MAX_FRAMES: usize = 4096;
|
||||
const MAX_SEGMENTS: usize = 1 << 16;
|
||||
pub(crate) const MAX_FRAMES: usize = 4096;
|
||||
pub(crate) const MAX_SEGMENTS: usize = 1 << 16;
|
||||
|
||||
/// Where a frame puts its story's text.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -513,8 +513,17 @@ impl Texts {
|
||||
if indices.iter().any(|n| self.frame_vertical(*n) != vertical) {
|
||||
return None;
|
||||
}
|
||||
// The first frame's layout, when the file keeps one: where its lines start.
|
||||
let (mut anchor, mut lines, mut path_spacing) = ((0.0, 0.0), vec![], 0.0);
|
||||
// The first frame's layout, when the file keeps one: where its lines start. Without one, the
|
||||
// anchor of point type is its frame's point (key 0, where the layout's anchor is too).
|
||||
let point = indices.first().and_then(|n| self.frames.get(*n)).and_then(|f| f.get("0")).and_then(|f| f.get("0")).and_then(Val::nums);
|
||||
let (mut anchor, mut lines, mut path_spacing) = (
|
||||
match point.as_deref() {
|
||||
Some([x, y]) if matches!(frames.first(), Some(FrameKind::Point(_))) => (*x, *y),
|
||||
_ => (0.0, 0.0),
|
||||
},
|
||||
vec![],
|
||||
0.0,
|
||||
);
|
||||
if let Some(f) = view.get("2").and_then(|l| find_node(l, "F")) {
|
||||
// Vertical type on a path turns its glyphs across the path: their matrices don't give
|
||||
// the spacing.
|
||||
@@ -854,8 +863,11 @@ impl Story {
|
||||
let text: String = if threaded {
|
||||
String::new()
|
||||
} else {
|
||||
// The `\r` that ends the story's last paragraph; one before it ends a paragraph, and the
|
||||
// empty paragraph after it is the story's last.
|
||||
self.text
|
||||
.trim_end_matches('\r')
|
||||
.strip_suffix('\r')
|
||||
.unwrap_or(&self.text)
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'\r' => '\n',
|
||||
|
||||
@@ -56,6 +56,7 @@ use compare::{
|
||||
};
|
||||
pub use layers::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, layered_ai, layered_ai_from};
|
||||
pub(super) use layers::{layered, program_alone};
|
||||
pub(crate) use slots::MAX_TEXT;
|
||||
pub use slots::is_loss;
|
||||
#[cfg(test)]
|
||||
use slots::join_pieces;
|
||||
|
||||
@@ -43,7 +43,7 @@ pub(super) const MAX_SLOTS: usize = 5000;
|
||||
const MAX_PIECES: usize = 2000;
|
||||
|
||||
/// Most bytes of text in the text objects of the layers (a story named twice counts twice).
|
||||
pub(super) const MAX_TEXT: usize = 16 << 20;
|
||||
pub(crate) const MAX_TEXT: usize = 16 << 20;
|
||||
|
||||
/// Most pairs of a text object and a piece of the page's type compared, and of a glyph the page
|
||||
/// kept as outlines and a node of the page's type.
|
||||
@@ -286,11 +286,12 @@ pub(super) fn fill_slots(
|
||||
let Some(n) = nodes.get(i) else { break };
|
||||
if let Some(story) = slot_of(n.name.as_deref()) {
|
||||
let slot = n.id;
|
||||
let visible = n.visible;
|
||||
let (visible, locked) = (n.visible, n.locked);
|
||||
let name = names.get(&n.id);
|
||||
let frame = ai::slot_frame(n.name.as_deref());
|
||||
let state = (n.opacity, n.blend, n.isolate, n.knockout);
|
||||
let transparent = see_through(n);
|
||||
let shape = n.knockout_shape;
|
||||
let transparent = see_through(n) || shape;
|
||||
// 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.
|
||||
@@ -307,6 +308,9 @@ pub(super) fn fill_slots(
|
||||
if !visible {
|
||||
Arc::make_mut(&mut p).visible = false;
|
||||
}
|
||||
if locked {
|
||||
Arc::make_mut(&mut p).locked = true;
|
||||
}
|
||||
if let Some(name) = name.filter(|_| matches!(p.kind, NodeKind::Text(_))) {
|
||||
Arc::make_mut(&mut p).name = Some(name.clone());
|
||||
}
|
||||
@@ -333,10 +337,11 @@ pub(super) fn fill_slots(
|
||||
}
|
||||
if let [p] = parts.as_mut_slice() {
|
||||
let p = Arc::make_mut(p);
|
||||
(p.opacity, p.blend, p.isolate, p.knockout) = state;
|
||||
(p.opacity, p.blend, p.isolate, p.knockout, p.knockout_shape) = (state.0, state.1, state.2, state.3, shape);
|
||||
} else if transparent {
|
||||
let mut group = Node::group(doc.alloc_id(), std::mem::take(&mut parts));
|
||||
(group.opacity, group.blend, group.isolate, group.knockout) = state;
|
||||
(group.opacity, group.blend, group.isolate, group.knockout, group.knockout_shape) =
|
||||
(state.0, state.1, state.2, state.3, shape);
|
||||
parts.push(Arc::new(group));
|
||||
}
|
||||
}
|
||||
@@ -372,7 +377,7 @@ pub(super) fn fill_slots(
|
||||
if let Some(story) = story {
|
||||
made.push((story, frame, text.id));
|
||||
}
|
||||
text.visible = visible;
|
||||
(text.visible, text.locked) = (visible, locked);
|
||||
// Type with an art style is what the page draws as the style's look: it is seen.
|
||||
let seen = seen || styled.is_some();
|
||||
if let Some(appearance) = styled {
|
||||
@@ -384,7 +389,7 @@ pub(super) fn fill_slots(
|
||||
// Type the page shows is kept; other made type is weighed against the page.
|
||||
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;
|
||||
(text.opacity, text.blend, text.isolate, text.knockout, text.knockout_shape) = (state.0, state.1, state.2, state.3, shape);
|
||||
if wrap.is_some() {
|
||||
text.extras_mut().wrap = wrap;
|
||||
}
|
||||
|
||||
@@ -3417,6 +3417,41 @@ fn type_keeps_the_opacity_and_blend_mode_of_its_text_object() {
|
||||
assert!(group.children().unwrap().iter().all(|p| (p.opacity, p.blend) == (1.0, BlendMode::Normal)));
|
||||
}
|
||||
|
||||
/// The knockout shape flag of a text object's `Xy` is its type's, made from the text document or
|
||||
/// taken from the page, and is on the group of the two where the type comes with other art.
|
||||
#[test]
|
||||
fn type_keeps_the_knockout_shape_of_its_text_object() {
|
||||
let shapes = |d: &Document| {
|
||||
let mut v = vec![];
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Text(_) = &n.kind {
|
||||
v.push(n.knockout_shape);
|
||||
}
|
||||
});
|
||||
v
|
||||
};
|
||||
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
|
||||
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show";
|
||||
for body in [format!("1 0.2 0 0 1 Xy\n{}", text_object(0)), format!("{}1 0.2 0 0 1 Xy\n", text_object(0))] {
|
||||
let art = native_with_text(&layer("Art", true, &body), &doc);
|
||||
let (d, notes) = crate::ai_alone(&private(&art)).unwrap();
|
||||
assert_eq!(shapes(&d), [true], "{body}{notes:?}");
|
||||
let r = import(&eps(page, &art)).unwrap();
|
||||
assert_eq!(shapes(&r.document), [true], "{body}{:?}", r.warnings);
|
||||
}
|
||||
let art = native_with_text(&layer("Art", true, &format!("1 0.2 0 0 0 Xy\n{}", text_object(0))), &doc);
|
||||
assert_eq!(shapes(&crate::ai_alone(&private(&art)).unwrap().0), [false]);
|
||||
let page = format!(
|
||||
"{}\n/Helvetica findfont 12 scalefont setfont 10 50 moveto (Hi) show\n10 50 moveto (Hi) false charpath 2 setlinewidth stroke",
|
||||
page_square(10, 10)
|
||||
);
|
||||
let words = layer("Words", true, "0 1 0 0 1 Xy\n/AI11Text :\n0 /FreeUndo ,\n;\n");
|
||||
let r = import(&eps(&page, &native(&(layer("Art", true, &square(10, 10)) + &words)))).unwrap();
|
||||
let words = &r.document.layers[1];
|
||||
assert_eq!(kinds(words), ["group[2]"], "{:?}", r.warnings);
|
||||
assert!(words.children().unwrap()[0].knockout_shape);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_commented_copy_of_a_story_that_has_a_plain_text_object_is_not_another_object() {
|
||||
// The file writes some stories twice (plain, then as comments) and others only as comments.
|
||||
|
||||
@@ -73,8 +73,15 @@ private editing data. VectorCraft:
|
||||
RGB or CMYK ink amounts, and their alpha channel) and linked images as placed files with their path, their folder
|
||||
relative to the document's and a preview; images that can't be decoded or have more than 33,554,432 pixels are
|
||||
written as their outlines filled mid gray, images of 16 bits a channel at 8 bits, and linked images in opacity
|
||||
masks and pattern tiles as embedded images, each with a warning. Type, other patterns, symbols, other clipping
|
||||
objects and live objects (placed documents included) are written as plain filled and stroked paths, and freeform gradients, gradients along
|
||||
masks and pattern tiles as embedded images, each with a warning. Point type, area type (with its rows, columns,
|
||||
gutters and first baseline, threaded frames included) and type on a path are written as text: a text object for
|
||||
each, naming its story in the text document, with the characters, fonts, sizes, leading, tracking, baseline shift,
|
||||
scales, solid fills and strokes, capitals and paragraph alignment, indents, spacing and direction; other character
|
||||
and paragraph attributes are left out with a warning. Type in opacity masks, pattern tiles and art cut to a
|
||||
clipping object, mirrored type, turned or unevenly scaled area type and type on a path, area type that flows around a text
|
||||
wrap, threads whose frames differ in orientation, type and stories past the sizes VectorCraft's reader reads, and
|
||||
type with inline graphics, ruby, gradient or pattern paint or an appearance or opacity mask of its own, other patterns, symbols, other clipping objects and live
|
||||
objects (placed documents included) are written as plain filled and stroked paths, and freeform gradients, gradients along
|
||||
or across a stroke and gradients without stops, with more than 256 stops or without extent as their average
|
||||
color, each with a warning. Gradients that dither or mix their colors perceptually are written as classic
|
||||
gradients, and gradient stops linked to global process colors as their colors, each with a warning. The opacity
|
||||
|
||||
Reference in New Issue
Block a user