.ai import: type comes from the text document alone when every shown story reads

Squashed from #4865 onto main.
This commit is contained in:
dotCooCoo
2026-11-01 00:35:35 +07:00
committed by bflatastic
parent bb6cbfb2b4
commit d6297dbfb9
10 changed files with 962 additions and 223 deletions
+117
View File
@@ -2784,9 +2784,126 @@ fn text_eps_of(document: &str, objects: &str) -> Vec<u8> {
editing_eps(&editing)
}
/// A text document of `kinds.len()` stories, story `i` in frame `i` of kind `kinds[i]` (0 point type,
/// 1 area type, 2 type on a path, others a kind the reader doesn't know), with a line break inside
/// its paragraph (`\x03`) at each of `soft` (by character, wrapping, the story's end included) and
/// its paragraph runs `runs` characters long (the whole story when there are none).
fn stories_document_text(kinds: &[u8], soft: &[usize], runs: &[usize]) -> String {
let frames: Vec<String> = kinds
.iter()
.enumerate()
.map(|(i, kind)| {
// Six rows 15 apart on the page (the canvas is y down), then the same rows again a little
// to the right.
let (x, y) = (8150.0 + (i / 6) as f64 * 5.0, 8150.0 + (i % 6) as f64 * 15.0);
match kind {
0 => format!("<< /0 << /0 [ 0 0 ] /2 << /2 [ 1 0 0 1 {x} {y} ] >> >> >>"),
1 => format!(
"<< /0 << /0 [ 0 0 ] /1 << /0 [ {x} {y} {x} {y} {0} {y} {0} {y} {0} {y} {0} {y} {0} {1} {0} {1} {0} {1} {0} {1} {x} {1} {x} {1} ] >> /2 << /0 1 >> >> >>",
x + 70.0,
y + 12.0
),
2 => format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {x} {y} {x} {y} {0} {y} {0} {y} ] >> /2 << /0 2 /6 [ 0 0.9 ] >> >> >>", x + 70.0),
k => format!("<< /0 << /0 [ 0 0 ] /2 << /0 {k} /2 [ 1 0 0 1 {x} {y} ] >> >> >>"),
}
})
.collect();
let stories: Vec<String> = (0..kinds.len())
.map(|i| {
let mut chars: Vec<char> = format!("Line {i} one two\rand three\r").chars().collect();
for at in soft {
chars.insert(at % (chars.len() + 1), '\x03');
}
let n = chars.len();
let text: String = chars.iter().map(|c| match c {
'\r' => "\\r".to_string(),
'\x03' => "\\003".to_string(),
c => c.to_string(),
}).collect();
let lengths: Vec<usize> = if runs.is_empty() { vec![n] } else { runs.to_vec() };
let paras: String = lengths
.iter()
.enumerate()
.map(|(j, len)| format!("<< /0 << /0 << /0 () /5 << /0 {} /2 2 /4 {j} >> /6 0 >> >> /1 {len} >> ", j % 3))
.collect();
format!(
"<< /0 << /0 ({text}) /5 << /0 [ {paras}] >> /6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /1 8.0 >> >> >> /1 {n} >> ] >> >> /1 << /0 [ << /0 {i} >> ] >> >>"
)
})
.collect();
format!(
"/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ {} ] >> >>\n/1 << /1 [ {} ] /2 << /1 12.0 >> >>\n",
frames.join(" "),
stories.join(" ")
)
}
/// An EPS whose page draws `page` (PostScript), with the text `objects` on a layer that shows and
/// the stories in `document`.
fn shown_text_eps(document: &str, objects: &str, page: &str) -> Vec<u8> {
let lines: Vec<String> = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
let editing = format!(
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n%AI3_TemplateBox: 50 50 50 50\n\
%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Words) Ln\n{objects}LB\n%AI5_EndLayer--\n\
%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n%%Trailer\n",
lines.join("\n")
);
let stream = ascii85(&zstd(&editing));
let private: Vec<String> = stream.as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
format!(
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n0 0 0 1 setcmykcolor /Helvetica findfont 8 scalefont setfont {page}\nshowpage\n%%EOF\n%AI9_PrivateDataBegin\n%AI24_DataStream\n{}\n%AI9_PrivateDataEnd\n",
private.join("\n")
)
.into_bytes()
}
/// The page of [`stories_document_text`]'s first story's first line, at its place.
const FIRST_LINE: &str = "8.5 91.5 moveto (Line 0 one two) show";
/// The text objects of `pairs` (story, frame among the story's).
fn story_objects(pairs: &[(u32, u32)]) -> String {
pairs.iter().map(|(story, frame)| format!("/AI11Text :\n{frame} /FrameIndex ,\n{story} /StoryIndex ,\n;\n")).collect()
}
/// The fixture, as it is (point type, area type and type on a path, a line break inside each
/// paragraph), opens with its layers and every story's type from its text document.
#[test]
fn the_shown_stories_fixture_opens_with_its_layers() {
let doc = stories_document_text(&[0, 1, 2], &[4], &[]);
// Each story's first line where the page draws it: the area type's below its frame's top, the
// type on a path on its path.
let page = format!("{FIRST_LINE} 8.5 69.5 moveto (Line 1) show 8.5 61.5 moveto (Line 2) show");
let l = vectorcraft_engine::cmd::fileio::load("x.eps", &shown_text_eps(&doc, &story_objects(&[(0, 0), (1, 0), (2, 0)]), &page)).unwrap();
let names: Vec<_> = l.doc.layers.iter().map(|l| l.name.clone()).collect();
assert_eq!(names, [Some("Words".to_string())], "{:?}", l.warnings);
let mut texts = vec![];
l.doc.walk(|n| {
if let vectorcraft_doc::NodeKind::Text(t) = &n.kind {
texts.push(t.plain_text());
}
});
assert_eq!(texts.len(), 3, "{texts:?} {:?}", l.warnings);
assert!(texts.iter().all(|t| t.contains('\u{2028}') && t.contains('\n')), "{texts:?}");
}
proptest! {
#![proptest_config(config())]
/// An Illustrator EPS with text objects that show, in frames of every kind and some the reader
/// doesn't know, naming stories and frames that may not exist, with line breaks inside their
/// paragraphs and paragraph runs of any length: read as its layers, or as its page.
#[test]
fn eps_shown_stories_never_panic(
kinds in prop::collection::vec(0u8..4, 1..20),
pairs in prop::collection::vec((0u32..24, 0u32..3), 1..24),
soft in prop::collection::vec(0usize..40, 0..5),
runs in prop::collection::vec(0usize..40, 0..5),
) {
let kinds: Vec<u8> = kinds.into_iter().map(|k| if k == 3 { 9 } else { k }).collect();
let bytes = shown_text_eps(&stories_document_text(&kinds, &soft, &runs), &story_objects(&pairs), FIRST_LINE);
survive("EPS with shown stories", || vectorcraft_engine::cmd::fileio::load("x.eps", &bytes).ok().map(|l| l.doc))?;
}
/// An Illustrator EPS whose text document is damaged or hostile: read as its type, or without it.
#[test]
fn eps_text_document_never_panics(cut in 0usize..1_500, edits in prop::collection::vec((0usize..1_500, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', '[', ']', '<', '>', '\\', 'e', '1'])), 0..12)) {
+29 -9
View File
@@ -8,9 +8,9 @@
//! The format isn't documented. What is read here was worked out from the files of the project's
//! users, used locally and never committed, by comparing it with the type their pages draw: the
//! meaning of each key below was checked against the page's own type in more than a thousand text
//! objects, and it is only used for the keys that matched. A story the reader doesn't recognise (area
//! type, type on a path, anything with a frame that has more than a point and a matrix) is left
//! alone: it has no text, and the importer says so.
//! objects, and it is only used for the keys that matched. Point type, area type and type on a path
//! are read; a story in a frame of any other kind is left alone: it has no text, and the importer
//! says so.
//!
//! Where type sits: a story lays its lines out round a point `F` (the anchor: the start of a line of
//! left-aligned type, the middle of a centred one, the end of a right-aligned one), each line with an
@@ -654,11 +654,6 @@ impl Story {
self.text.len()
}
/// The story's characters, with `\r` ending paragraphs and `\x03` ending lines.
pub(super) fn text(&self) -> &str {
&self.text
}
/// The point frame's matrix (the first frame's, when it is one), else the identity.
fn matrix(&self) -> [f64; 6] {
match self.frames.first() {
@@ -749,10 +744,20 @@ impl Story {
(1.0, Affine::IDENTITY, TextKind::OnPath { path: path_of(segments, template, to_doc), start: length_fraction(segments, *start), end })
}
};
// A `\x03` ends a line inside its paragraph: a forced line break (U+2028), which the
// paragraph's style runs over.
let text: String = if threaded {
String::new()
} else {
self.text.trim_end_matches('\r').chars().map(|c| if matches!(c, '\r' | '\x03') { '\n' } else { c }).collect()
self.text
.trim_end_matches('\r')
.chars()
.map(|c| match c {
'\r' => '\n',
'\x03' => '\u{2028}',
c => c,
})
.collect()
};
if text.trim().is_empty() && !threaded {
return None;
@@ -1253,6 +1258,21 @@ mod tests {
assert_eq!(story("one\r", &[0.0], &Para::default()).line_leading(1.0), None);
}
/// A `\x03` ends a line inside its paragraph: it comes in as a forced line break, and the
/// paragraph's style runs over it to the paragraph's end.
#[test]
fn a_soft_return_is_a_line_break_in_its_paragraph() {
let para = Para { space_before: 4.0, space_after: 2.0, first_indent: 6.0, ..Para::default() };
let mut s = story("A\x03B\rC\r", &[0.0, 15.6, 31.2], &para);
s.paras[1].1 = Para { left_indent: 3.0, ..para.clone() };
let node = s.node(NodeId(1), 0, (0.0, 0.0), Affine::IDENTITY).unwrap();
let NodeKind::Text(t) = &node.kind else { panic!("not text") };
assert_eq!(t.plain_text(), "A\u{2028}B\nC");
assert_eq!(t.paras.len(), 2, "{:?}", t.paras);
assert_eq!((t.paras[0].first_line_indent, t.paras[0].left_indent, t.paras[0].space_before), (6.0, 0.0, 4.0));
assert_eq!((t.paras[1].first_line_indent, t.paras[1].left_indent), (6.0, 3.0));
}
#[test]
fn a_frame_too_short_for_its_first_line_is_made_taller() {
// A heading in a frame as tall as its size: a line of the font it shows in here is taller.
+40 -9
View File
@@ -12,10 +12,12 @@
//! colours, gradients, transparency and images of the art.
//!
//! Text objects are slots in that document: their characters, fonts and places are in the file's
//! text document (see `ate`). Type that shows comes from the page, which draws it, into the slot
//! whose text is where the page has it. Type that doesn't show (a hidden layer or object, or off
//! the page) is made from the text document; where that can't be read (area type, type on a path)
//! it's left out, with a warning.
//! text document (see `ate`). When the text document has the type of every text object that shows,
//! all the type is made from it, each text object in its own place, and the page's type is only
//! compared with it. Otherwise the type it has is still made from it, and the page's type that
//! lies elsewhere goes into the text objects it can't make, by where the text document puts their
//! lines or else in painting order. A text object whose type neither has (a frame of a kind it
//! doesn't read, on a hidden layer or object) is left out, with a warning.
//!
//! What a layer that shows has that `ai` doesn't read makes the file come in as its page, as it did
//! before; on a layer that doesn't show it is left out of that layer, with a warning. The page is
@@ -46,7 +48,9 @@ mod slots;
#[cfg(test)]
use build::{Frame, MADE, prune_unseen};
#[cfg(test)]
use compare::{MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, PageView, compare, compared, comparison_render, comparison_samples, differ, differ_in_colour};
use compare::{
MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, PageView, Shares, compare, compared, comparison_render, comparison_samples, differ, differ_in_colour,
};
pub use layers::{ai_alone, layered_ai};
pub(super) use layers::{layered, program_alone};
pub use slots::is_loss;
@@ -88,7 +92,7 @@ mod tests {
let rect = page.artboards[0].rect;
let frame = Frame { page: &page, page_rect: rect, rect, whole: false };
let mut view = PageView::new(&frame);
assert_eq!(prune_unseen(&frame, &mut view, &mut layers), 1, "only the ring the page doesn't draw");
assert_eq!(prune_unseen(&frame, &mut view, &mut layers, false), 1, "only the ring the page doesn't draw");
let widths: Vec<f64> = layers.layers[0].children().unwrap().iter().filter_map(|n| n.appearance.stroke().map(|s| s.width)).collect();
assert_eq!(widths, [12.0, 6.0]);
// Taken by lightness alone, the red and the dark red are alike.
@@ -99,6 +103,33 @@ mod tests {
assert!(!differ(&px(dark), &px(red)) && differ_in_colour(&px(dark), &px(red)));
}
/// The share of a rectangle's pixels in the areas, however many areas there are: none for one
/// that only shares their edge, lies off the drawing or isn't a number.
#[test]
fn the_share_of_a_rectangle_in_the_areas_counts_its_pixels() {
let in_areas = |areas: &[Rect], w: usize, h: usize| {
let marks: Vec<bool> = (0..w * h)
.map(|k| {
let (x, y) = ((k % w) as f64 + 0.5, (k / w) as f64 + 0.5);
areas.iter().any(|a| a.contains(Point::new(x, y)))
})
.collect();
let shares = Shares::of(&marks, w, h);
move |r: &Rect| shares.share(r)
};
let share = in_areas(&[Rect::new(10.0, 10.0, 20.0, 20.0), Rect::new(30.0, 0.0, 31.0, 1.0)], 40, 40);
assert_eq!((share(&Rect::new(12.0, 12.0, 22.0, 18.0)), share(&Rect::new(10.0, 10.0, 30.0, 20.0))), (0.8, 0.5));
assert_eq!((share(&Rect::new(15.0, 15.0, 30.0, 30.0)), share(&Rect::new(30.2, 0.2, 30.8, 0.8))), (25.0 / 225.0, 1.0));
assert_eq!(
[share(&Rect::new(20.0, 20.0, 30.0, 30.0)), share(&Rect::new(0.0, 0.0, 5.0, 5.0)), share(&Rect::new(50.0, 50.0, 60.0, 60.0))],
[0.0; 3]
);
assert_eq!(share(&Rect::new(f64::NAN, 0.0, 39.0, 39.0)), 0.0);
let many: Vec<Rect> = (0..5000).map(|i| Rect::new(f64::from(i % 70), 50.0, f64::from(i % 70) + 1.0, 51.0)).collect();
let share = in_areas(&many, 80, 80);
assert_eq!((share(&Rect::new(60.0, 50.0, 75.0, 51.0)), share(&Rect::new(70.0, 50.0, 75.0, 51.0))), (10.0 / 15.0, 0.0));
}
fn piece(text: &str, x: f64, y: f64) -> Arc<Node> {
Arc::new(Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(x, y), text, CharStyle::default())))))
}
@@ -163,9 +194,9 @@ mod tests {
assert!(compared(&a, &b, &[], &[]).solid > MAX_SOLID_DIFFERENCE, "the old point sampling falsely refused this page");
let frame = Frame { page: &page, page_rect: rect, rect, whole: false };
let mut view = PageView::new(&frame);
assert!(compare(&frame, &mut view, &vector).is_ok());
assert!(compare(&frame, &mut view, &Document::new(1000.0, 500.0)).is_err(), "missing artwork remains an error");
assert!(compare(&frame, &mut view, &vector, false).is_ok());
assert!(compare(&frame, &mut view, &Document::new(1000.0, 500.0), false).is_err(), "missing artwork remains an error");
let shifted = Rect::new(0.0, 0.0, 500.0, 500.0);
assert!(view.difference(&vector, shifted).any > MAX_DIFFERENCE, "wrong framing remains an error");
assert!(view.difference(&vector, shifted, false).any > MAX_DIFFERENCE, "wrong framing remains an error");
}
}
+60 -19
View File
@@ -1,8 +1,8 @@
use super::compare::{MAX_DIFFERENCE, PageView, compare, difference};
use super::plan::plan;
use super::plan::{made_only, make_slots, plan, readable_all};
use super::slots::{
ART_LEFT_OUT, Content, MAX_PAIRS, MAX_SLOTS, MAX_TEXT, OUTLINE_REACH, TEXT_LEFT_OUT, TYPE_LEFT_OUT, collect_slots, extent, few, fill_slots,
letters, text_objects, words,
ART_LEFT_OUT, Content, MAX_PAIRS, MAX_SLOTS, MAX_TEXT, OUTLINE_REACH, SlotInfo, TEXT_LEFT_OUT, TYPE_LEFT_OUT, collect_slots, extent, few,
fill_slots, letters, text_objects, words,
};
use super::{Affine, Arc, BTreeSet, Document, Node, NodeId, NodeKind, PathData, Rect, Scaling, Story, Structure, Texts, Unit, Vec2, ai};
@@ -183,10 +183,6 @@ pub(super) fn build_from(
// The page's type that needs no text object of the layers: the layers' own legacy text, or
// placed files that show it.
let as_outlines = page_type_elsewhere(infos.is_empty(), infos.iter().all(|i| !i.shown), &mut shown, &mut hidden, &doc, &placed_elsewhere)?;
// The page's type is compared with every text object of the layers: that has its limits.
if infos.len() > MAX_SLOTS || infos.len().saturating_mul(shown.len()) > MAX_PAIRS {
return Err("it has too many text objects".into());
}
let texts = Texts::read(data);
// Each story is read once, however many text objects name it.
let mut read: std::collections::BTreeMap<u32, Option<Arc<Story>>> = Default::default();
@@ -202,7 +198,8 @@ pub(super) fn build_from(
}
let template = template.map(|t| ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0));
let mut notes = warnings;
let content = plan(&infos, &stories, template, to_doc, [&shown, &hidden], &mut notes);
let own = (!outlined).then(|| outside_placed_files(&shown, &doc, &placed_elsewhere));
let (content, reflow) = slot_content(&infos, &stories, template, to_doc, own.as_deref(), [&shown, &hidden], &mut notes)?;
// The glyphs left out with the rest of the page's art, and how many text objects take type of
// the page right next to them (not counted past `MAX_PAIRS` comparisons).
let compared = lone.len().saturating_mul(shown.iter().chain(&hidden).map(Vec::len).sum());
@@ -211,7 +208,7 @@ pub(super) fn build_from(
matches!(n.kind, NodeKind::Text(_))
&& n.visual_bounds().is_some_and(|b| missing.iter().any(|r| !b.inflate(OUTLINE_REACH, OUTLINE_REACH).intersect(*r).is_zero_area()))
};
let short = content.iter().filter(|c| matches!(c, Content::Page(p) | Content::Made(_, _, _, p) if p.iter().any(next_to_missing))).count();
let short = content.iter().filter(|c| matches!(c, Content::Page(p) if p.iter().any(next_to_missing))).count();
let mut empty = 0;
let mut layers = std::mem::take(&mut doc.layers);
let mut made = vec![];
@@ -244,16 +241,16 @@ pub(super) fn build_from(
// doesn't draw is weighed again with the images there, since an image that covered a
// text object at the first placement may not cover it at the other.
let other = elsewhere(&doc, &placed_elsewhere);
let unseen = prune_unseen(&frame, &mut view, &mut doc);
let unseen = prune_unseen(&frame, &mut view, &mut doc, reflow);
let unseen_note = |n: usize| (n > 0).then(|| format!("{n} {UNSEEN_TEXT}"));
match compare(&frame, &mut view, &doc) {
match compare(&frame, &mut view, &doc, reflow) {
// Placed files at the other point their data gives, where that draws closer to the
// page: a small image in the wrong place differs too little to fail.
Ok(note) => {
let closer = other.and_then(|mut other| {
let unseen = prune_unseen(&frame, &mut view, &mut other);
let closer = view.difference(&other, frame.rect).any < view.difference(&doc, frame.rect).any;
closer.then(|| compare(&frame, &mut view, &other).ok()).flatten().map(|note| (other, unseen, note))
let unseen = prune_unseen(&frame, &mut view, &mut other, reflow);
let closer = view.difference(&other, frame.rect, reflow).any < view.difference(&doc, frame.rect, reflow).any;
closer.then(|| compare(&frame, &mut view, &other, reflow).ok()).flatten().map(|note| (other, unseen, note))
});
match closer {
Some((other, unseen, note)) => {
@@ -265,8 +262,8 @@ pub(super) fn build_from(
}
// Placed files at the other point their data gives, where that draws as the page.
Err(why) => match other.map(|mut other| {
let unseen = prune_unseen(&frame, &mut view, &mut other);
(compare(&frame, &mut view, &other), other, unseen)
let unseen = prune_unseen(&frame, &mut view, &mut other, reflow);
(compare(&frame, &mut view, &other, reflow), other, unseen)
}) {
Some((Ok(note), other, unseen)) => {
doc = other;
@@ -288,6 +285,34 @@ pub(super) fn build_from(
Ok((doc, notes))
}
/// What goes into each of `infos`' slots, and whether the type is all made from the file's text
/// document. When the text document makes the type of every text object that shows (and the type
/// isn't asked for as outlines: `own` has the page's text objects that show outside placed files),
/// each slot takes its own made type and the page's type is only compared with it: type laid out
/// again, in another font where the file's isn't installed, may differ from the page's. Otherwise
/// see [`plan`], with `pages` (the page's text objects that show and that don't).
fn slot_content(
infos: &[SlotInfo],
stories: &[Option<Arc<Story>>],
template: Option<(f64, f64)>,
to_doc: Affine,
own: Option<&[Vec<Arc<Node>>]>,
pages: [&[Vec<Arc<Node>>]; 2],
notes: &mut Vec<String>,
) -> Result<(Vec<Content>, bool), String> {
let made = make_slots(infos, stories, template, to_doc, notes);
if let Some(own) = own.filter(|_| readable_all(infos, &made)) {
return Ok((made_only(infos, made, [own, pages[1]], notes), true));
}
// The page's type is matched with every text object whose type isn't made, and weighed against
// where every made one draws: that has its limits.
let open = made.iter().filter(|m| m.is_none()).count();
if open > MAX_SLOTS || infos.len().saturating_mul(pages[0].len()) > MAX_PAIRS {
return Err("it has too many text objects".into());
}
Ok((plan(infos, stories, made, template, to_doc, pages, notes), false))
}
/// `doc` with the placed files `placed` lists at their other placement (see
/// [`ai::Structure::placed_elsewhere`]): each image's transform followed by its move. `None` when
/// none is listed.
@@ -391,6 +416,21 @@ fn in_placed_files(objects: &[Vec<Arc<Node>>], doc: &Document, placed: &[(NodeId
inside(doc) || elsewhere(doc, placed).is_some_and(|other| inside(&other))
}
/// The page's text objects in `objects` that don't lie inside a placed file that shows in `doc`, at
/// either placement (see [`in_placed_files`]): the placed file's preview has the others.
fn outside_placed_files(objects: &[Vec<Arc<Node>>], doc: &Document, placed: &[(NodeId, Affine)]) -> Vec<Vec<Arc<Node>>> {
let mut files = vec![];
placed_files(&doc.layers, &mut files);
if let Some(other) = elsewhere(doc, placed) {
placed_files(&other.layers, &mut files);
}
if files.is_empty() {
return objects.to_vec();
}
let inside = |o: &[Arc<Node>]| extent(o).is_some_and(|r| files.iter().any(|f| f.inflate(OUTLINE_REACH, OUTLINE_REACH).contains_rect(r)));
objects.iter().filter(|o| !inside(o)).cloned().collect()
}
/// Where the placed files that show in `nodes` are: the linked images the editing data's reader
/// makes of them.
fn placed_files(nodes: &[Arc<Node>], out: &mut Vec<Rect>) {
@@ -520,15 +560,16 @@ fn take_out(nodes: &mut Vec<Arc<Node>>, id: NodeId, hide: bool) -> bool {
/// The text objects made from the file's text document that show and print on the page's area,
/// where the page has no type to match them, and that the layers look no worse without, are not
/// drawn by the app that wrote the file (which paints every shown text object; it keeps the type
/// of what was turned to outlines): they are taken out. How many.
pub(super) fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> usize {
/// of what was turned to outlines): they are taken out. How many. `reflow`: the type is all laid
/// out again from the text document (see [`compare`]).
pub(super) fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document, reflow: bool) -> usize {
let mut found = vec![];
unmark(&mut doc.layers, true, true, &mut found);
let rect = frame.rect;
if found.is_empty() || found.len() > MAX_WEIGHED {
return 0;
}
let with = view.difference(doc, rect).any;
let with = view.difference(doc, rect, reflow).weighed(reflow);
// Taking a text object out changes only what is drawn in its box. When the art would still
// differ from the page by more than `compare` allows with every candidate gone, the layers
// aren't used whatever is pruned: don't draw them again for each one (#758).
+114 -21
View File
@@ -75,6 +75,14 @@ pub(super) struct Difference {
pub(super) solid: f64,
}
impl Difference {
/// The share [`compare`] holds to [`MAX_DIFFERENCE`]: every pixel that differs, or with `reflow`
/// (type laid out again from the file's text) those away from type.
pub(super) fn weighed(&self, reflow: bool) -> f64 {
if reflow { self.art } else { self.any }
}
}
/// The scale the comparisons draw a region at: about 400 pixels along its longer side.
fn compare_scale(r: Rect) -> f64 {
(400.0 / r.width().max(r.height()).max(1.0)).min(4.0)
@@ -129,6 +137,8 @@ pub(super) struct PageView {
/// rendering of the style there, the layers VectorCraft's live effects, and the two aren't
/// held to look alike.
restyled: Vec<Rect>,
/// Where the page draws anything (its type, or its own outlines of type) or has its type.
page_marks: Shares,
layers: vectorcraft_render::Renderer,
scale: f64,
samples: u32,
@@ -144,7 +154,11 @@ impl PageView {
let page = comparison_render(&mut vectorcraft_render::Renderer::new(), frame.page, ra, scale, samples);
let mut page_type = vec![];
type_areas(&frame.page.layers, ra, scale, &mut page_type);
Self { page, page_type, restyled: vec![], layers: vectorcraft_render::Renderer::new(), scale, samples }
let (w, h) = (page.width as usize, page.height as usize);
let typed = in_areas(&page_type, w, h);
let marks: Vec<bool> = page.pixels.as_chunks::<4>().0.iter().zip(&typed).map(|(p, t)| *t || ink(on_white(p)) > WHITE).collect();
let page_marks = Shares::of(&marks, w, h);
Self { page, page_type, restyled: vec![], page_marks, layers: vectorcraft_render::Renderer::new(), scale, samples }
}
/// Leave the drawn looks in `looks` (on `region`, the page's area in the layers' document) out
@@ -159,13 +173,27 @@ impl PageView {
}
/// How `b` on `rb` (a rectangle the page's size) draws differently from the page ([`compared`]).
pub(super) fn difference(&mut self, b: &Document, rb: Rect) -> Difference {
/// With `reflow` (`b`'s type laid out again from the file's text), type counts as type only where
/// both have something: `b`'s where the page has type or draws anything in at least [`MARKED`]
/// of it, the page's where at least half of it lies where `b` has a text object, shown or not,
/// or a placed file. Elsewhere it is art the other lacks.
pub(super) fn difference(&mut self, b: &Document, rb: Rect, reflow: bool) -> Difference {
// The page doesn't print what isn't printed.
let printed = as_printed(b);
let b = printed.as_ref().unwrap_or(b);
let y = comparison_render(&mut self.layers, b, rb, self.scale, self.samples);
let mut areas = self.page_type.clone();
let mut areas = vec![];
type_areas(&b.layers, rb, self.scale, &mut areas);
if reflow {
areas.retain(|r| self.page_marks.share(r) >= MARKED);
let mut texts = vec![];
all_type_areas(&b.layers, rb, self.scale, &mut texts);
let (w, h) = (self.page.width as usize, self.page.height as usize);
let on_layers = Shares::of(&in_areas(&texts, w, h), w, h);
areas.extend(self.page_type.iter().filter(|r| on_layers.share(r) >= 0.5));
} else {
areas.extend(&self.page_type);
}
compared(&self.page, &y, &areas, &self.restyled)
}
@@ -241,6 +269,62 @@ fn type_areas(nodes: &[Arc<Node>], region: Rect, scale: f64, out: &mut Vec<Rect>
}
}
/// Where every text object in `nodes` is, shown or not, as [`type_areas`] gives it, and every placed
/// file (whose preview draws the type it has).
fn all_type_areas(nodes: &[Arc<Node>], region: Rect, scale: f64, out: &mut Vec<Rect>) {
for n in nodes {
let placed = matches!(&n.kind, NodeKind::Image(im) if im.link.is_some());
if (placed || matches!(n.kind, NodeKind::Text(_)))
&& let Some(b) = n.visual_bounds()
{
let b = b.inflate(OUTLINE_REACH, OUTLINE_REACH);
out.push(Rect::new((b.x0 - region.x0) * scale, (b.y0 - region.y0) * scale, (b.x1 - region.x0) * scale, (b.y1 - region.y0) * scale));
} else if let Some(c) = n.children() {
all_type_areas(c, region, scale, out);
}
}
}
/// Which of `w` × `h` pixels are marked, counted so that the share of any rectangle's pixels that
/// are is read at once.
pub(super) struct Shares {
/// How many of the marked pixels lie above and left of each pixel corner.
sums: Vec<u32>,
w: usize,
h: usize,
}
impl Shares {
/// The pixels `marks` marks (row by row).
pub(super) fn of(marks: &[bool], w: usize, h: usize) -> Self {
let stride = w + 1;
let mut sums = vec![0u32; stride * (h + 1)];
for (k, marked) in marks.iter().enumerate().take(w * h) {
let (i, j) = (k % w.max(1), k / w.max(1));
let left = sums.get((j + 1) * stride + i).copied().unwrap_or(0);
let up = sums.get(j * stride + i + 1).copied().unwrap_or(0);
let corner = sums.get(j * stride + i).copied().unwrap_or(0);
if let Some(s) = sums.get_mut((j + 1) * stride + i + 1) {
*s = (left + up + u32::from(*marked)).saturating_sub(corner);
}
}
Self { sums, w, h }
}
/// The share (0 to 1) of `r`'s pixels that are marked: 0 for one with none of the pixels.
pub(super) fn share(&self, r: &Rect) -> f64 {
let (w, h, stride) = (self.w, self.h, self.w + 1);
let clamp = |v: f64, max: usize| if v.is_finite() { v.clamp(0.0, max as f64) as usize } else { 0 };
let (x0, y0, x1, y1) = (clamp(r.x0.floor(), w), clamp(r.y0.floor(), h), clamp(r.x1.ceil(), w), clamp(r.y1.ceil(), h));
if !r.is_finite() || x1 <= x0 || y1 <= y0 {
return 0.0;
}
let at = |x: usize, y: usize| self.sums.get(y * stride + x).copied().unwrap_or(0);
let inside = (at(x1, y1) + at(x0, y0)).saturating_sub(at(x0, y1) + at(x1, y0));
f64::from(inside) / ((x1 - x0) as f64 * (y1 - y0) as f64)
}
}
/// Which of `w` × `h` pixels (row by row) lie in one of `areas`.
fn in_areas(areas: &[Rect], w: usize, h: usize) -> Vec<bool> {
// Each area adds one inside it: corners marked, then summed along the rows and down the columns.
@@ -272,6 +356,10 @@ fn in_areas(areas: &[Rect], w: usize, h: usize) -> Vec<bool> {
(0..h).flat_map(|j| (0..w).map(move |i| (i, j))).map(|(i, j)| count.get(j * stride + i).is_some_and(|c| *c > 0)).collect()
}
/// The least share of a text object's area (with the margin round it) where the page draws anything
/// or has type, for its type laid out again to count as type.
const MARKED: f64 = 0.05;
/// More of the page than this differing between the layers' art and the page's own, and the
/// layers aren't used.
pub(super) const MAX_DIFFERENCE: f64 = 0.05;
@@ -287,11 +375,13 @@ pub(super) const MAX_SOLID_DIFFERENCE: f64 = 0.001;
const PAST_THE_PAGE: f64 = 1.0;
/// Do the layers' art and the page's own look alike (where the page holds all the art that prints,
/// also round it)? An error says they don't; `Ok` has the note to give when they differ a little:
/// also round it)? An error says they don't; `Ok` has the notes to give when they differ a little:
/// with the share of the pixels that differ away from type when that is notable, otherwise with the
/// share of those that differ in the type's areas.
pub(super) fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document) -> Result<Option<String>, String> {
let mut diff = view.difference(layered, frame.rect);
/// share of those that differ in the type's areas. With `type_reflow` (all the type laid out again
/// from the file's text, which may show in another font), type that differs doesn't make them
/// differ: only art away from type does, and the type's share is a note of its own.
pub(super) fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document, type_reflow: bool) -> Result<Vec<String>, String> {
let mut diff = view.difference(layered, frame.rect, type_reflow);
if frame.whole
&& let Some(art) = vectorcraft_render::encode::art_bounds(as_printed(layered).as_ref().unwrap_or(layered))
.filter(|a| !frame.rect.inflate(PAST_THE_PAGE, PAST_THE_PAGE).contains_rect(*a))
@@ -302,21 +392,24 @@ pub(super) fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document
let round = difference(frame.page, Rect::new(all.x0 + dx, all.y0 + dy, all.x1 + dx, all.y1 + dy), layered, all);
diff = Difference { any: diff.any.max(round.any), art: diff.art.max(round.art), solid: diff.solid.max(round.solid) };
}
if diff.any > MAX_DIFFERENCE || diff.solid > MAX_SOLID_DIFFERENCE {
return Err(format!("the art on them differs from the page's in {:.1}% of it", diff.any * 100.0));
if diff.weighed(type_reflow) > MAX_DIFFERENCE || diff.solid > MAX_SOLID_DIFFERENCE {
return Err(format!("the art on them differs from the page's in {:.1}% of it", diff.weighed(type_reflow) * 100.0));
}
if diff.any <= NOTABLE_DIFFERENCE {
return Ok(None);
return Ok(vec![]);
}
Ok(Some(if diff.art > NOTABLE_DIFFERENCE {
format!(
"the art on the layers differs from the file's own page in about {:.1}% of it (brush strokes, live effects and the like that the layers' data doesn't describe)",
diff.art * 100.0
)
} else {
format!(
"the type on the layers differs from the file's own page in about {:.1}% of it (type laid out again from the file's text, or drawn in another font)",
(diff.any - diff.art) * 100.0
)
}))
let art = format!(
"the art on the layers differs from the file's own page in about {:.1}% of it (brush strokes, live effects and the like that the layers' data doesn't describe)",
diff.art * 100.0
);
let typed = format!(
"the type on the layers differs from the file's own page in about {:.1}% of it (type laid out again from the file's text, or drawn in another font)",
(diff.any - diff.art) * 100.0
);
let art_notable = diff.art > NOTABLE_DIFFERENCE;
Ok(match (art_notable, type_reflow && diff.any - diff.art > NOTABLE_DIFFERENCE) {
(true, true) => vec![art, typed],
(true, false) => vec![art],
(false, _) => vec![typed],
})
}
+221 -95
View File
@@ -1,11 +1,21 @@
use super::layers::closed_where_ends_meet;
use super::slots::{Content, SlotInfo, assign, covered, extent, join_pieces, letters, same_type, words};
use super::slots::{
Content, MAX_PAIRS, OUTLINE_REACH, PAGE_TYPE_LEFT_OUT, PAGE_TYPE_UNCHECKED, SlotInfo, assign, covered, extent, join_pieces, letters, same_type,
words,
};
use super::{Affine, Arc, Node, NodeId, NodeKind, Point, Rect, Story, TextKind};
/// Match page objects to the nearest slot whose story describes them, including hidden slots.
fn match_page_objects(infos: &[SlotInfo], lines: &[Option<Vec<(String, Point)>>], shown: &[Vec<Arc<Node>>]) -> Vec<Option<usize>> {
/// A slot's type made from the file's text document, and roughly where it lies on the document.
pub(super) type Made = (Box<Node>, Option<Rect>);
/// Match page objects to the nearest slot that `open` (the slots whose type wasn't made) has and
/// whose story describes them, including hidden slots.
fn match_page_objects(infos: &[SlotInfo], lines: &[Option<Vec<(String, Point)>>], open: &[bool], shown: &[Vec<Arc<Node>>]) -> Vec<Option<usize>> {
let mut pairs: Vec<(f64, usize, usize)> = vec![];
for (k, _) in infos.iter().enumerate() {
if !open.get(k).copied().unwrap_or(false) {
continue;
}
let Some(l) = lines.get(k).and_then(Option::as_ref) else { continue };
for (j, object) in shown.iter().enumerate() {
if let Some(d) = object.iter().filter_map(|n| same_type(n, l)).min_by(f64::total_cmp) {
@@ -40,12 +50,139 @@ fn frame_text_from_art(info: &SlotInfo, node: &mut Node, region: &mut Option<Rec
}
}
/// What goes into each of `infos`' slots: the page's type that the file's text document puts where the
/// page has it, else type made from the text document, else (for a file whose text document can't say)
/// the page's type in painting order.
/// Each of `infos`' slots' type made from the file's text document, once: `None` where the text
/// document can't make it (no story, a frame of a kind it doesn't read, mirrored type, no text).
pub(super) fn make_slots(
infos: &[SlotInfo],
stories: &[Option<Arc<Story>>],
template: Option<(f64, f64)>,
to_doc: Affine,
warn: &mut Vec<String>,
) -> Vec<Option<Made>> {
let mut taller = 0;
let made = infos
.iter()
.enumerate()
.map(|(k, info)| {
let t = template?;
let story = stories.get(k)?.as_ref()?;
let mut node = story.node(NodeId(0), info.frame, t, to_doc)?;
let mut region = story.region(t, to_doc);
// The first line where the file has it, in the text document's frame (that of a
// copied object is its original's, as its lines are).
story.place_first_line(&mut node, t, to_doc);
// Area type and type on a path in the object's own frame: the text document's frame
// of a copied object keeps its original's path.
frame_text_from_art(info, &mut node, &mut region);
if let NodeKind::Text(text) = &mut node.kind
&& story.fit_first_line(text)
{
taller += 1;
}
Some((Box::new(node), region))
})
.collect();
if taller > 0 {
warn.push(format!(
"area type in frames too short for a line of the font it shows in got taller frames ({taller}): their type wouldn't show otherwise"
));
}
made
}
/// Does the file's text document make the type of every slot that shows (at least one does)?
pub(super) fn readable_all(infos: &[SlotInfo], made: &[Option<Made>]) -> bool {
infos.iter().any(|i| i.shown) && infos.iter().zip(made).all(|(i, m)| !i.shown || m.is_some())
}
/// Each slot's made type, where its slot is (the page's type that shows isn't put anywhere). Shown
/// made type with none of the page's type where it draws is weighed against the page (see
/// `prune_unseen`); the page's type that no made type takes, shown or not, is left out with a note
/// in `warn`. Slots that don't show and whose type isn't made take the page's type that doesn't
/// show (`pages`: the page's text objects that show and that don't), by painting order.
pub(super) fn made_only(infos: &[SlotInfo], made: Vec<Option<Made>>, pages: [&[Vec<Arc<Node>>]; 2], warn: &mut Vec<String>) -> Vec<Content> {
let [shown, hidden] = pages;
let mut content = made_content(infos, made);
match see_page_type(&mut content, shown) {
Some(0) => {}
Some(_) => warn.push(PAGE_TYPE_LEFT_OUT.into()),
None => warn.push(PAGE_TYPE_UNCHECKED.into()),
}
fill_hidden(infos, &mut content, hidden);
content
}
/// The letters and digits of `s`, in any script, in lower case: what is compared of the page's type
/// and made type (the page may spell a mark another way).
fn spelling(s: &str) -> String {
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
}
/// The text of `n`, if it is a text object.
fn plain_text(n: &Node) -> String {
if let NodeKind::Text(t) = &n.kind { t.plain_text() } else { String::new() }
}
/// The words of the page's text object `o`, each as its [`spelling`], the empty ones left out.
fn object_words(o: &[Arc<Node>]) -> Vec<String> {
o.iter().flat_map(|n| plain_text(n).split_whitespace().map(spelling).collect::<Vec<_>>()).filter(|w| !w.is_empty()).collect()
}
/// Each slot's made type as its content, else nothing yet. Made type that shows has where it draws,
/// or where its story lies when it draws nothing.
fn made_content(infos: &[SlotInfo], made: Vec<Option<Made>>) -> Vec<Content> {
infos
.iter()
.zip(made)
.map(|(info, m)| match m {
Some((node, region)) => {
let drawn = node.visual_bounds().filter(|b| !b.is_zero_area()).or(region);
Content::Made(node, drawn.filter(|_| info.shown), false)
}
None => Content::Empty,
})
.collect()
}
/// Mark the made type that has at least half of a piece of the page's type where it draws (or round
/// it, as far as strokes round type reach) as type the page draws. How many of the page's text
/// objects no made type takes, shown or not: none that holds a piece of the object has its words
/// ([`spelling`]). Past `MAX_PAIRS` comparisons nothing is marked or counted (`None`).
fn see_page_type(content: &mut [Content], shown: &[Vec<Arc<Node>>]) -> Option<usize> {
let texts = |o: &[Arc<Node>]| o.iter().filter_map(|n| if let NodeKind::Text(_) = &n.kind { Some(n.clone()) } else { None }).collect::<Vec<_>>();
// Each object's pieces of type, and the words of them that no made type holding it has yet.
let mut objects: Vec<(Vec<Rect>, Vec<String>, bool)> =
shown.iter().map(|o| (texts(o).iter().filter_map(|n| n.visual_bounds()).collect(), object_words(o), false)).collect();
let pieces: usize = objects.iter().map(|o| o.0.len()).sum();
if content.len().saturating_mul(pieces) > MAX_PAIRS {
return None;
}
let holds = |drawn: Rect, r: &Rect| drawn.inflate(OUTLINE_REACH, OUTLINE_REACH).intersect(*r).area() >= 0.5 * r.area().max(1e-9);
for c in content {
let Content::Made(node, region, seen) = c else { continue };
// Hidden made type has no region: where it draws, shown, is its own box.
let Some(drawn) = region.or_else(|| node.visual_bounds()) else { continue };
let spelled = if let NodeKind::Text(t) = &node.kind { spelling(&t.plain_text()) } else { String::new() };
for (rects, words, held) in &mut objects {
if rects.iter().any(|r| holds(drawn, r)) {
*held = true;
*seen |= region.is_some();
words.retain(|w| !spelled.contains(w.as_str()));
}
}
}
Some(objects.iter().filter(|(_, words, held)| !held || !words.is_empty()).count())
}
/// What goes into each of `infos`' slots when the file's text document can't make the type of every
/// slot that shows: type made from the text document (`made`) where it can, else the page's type that
/// the text document puts where the page has it, else the page's type in painting order. The page's
/// type that lies where made type draws and has its words is that type as the page draws it; the
/// rest goes only into slots without made type.
pub(super) fn plan(
infos: &[SlotInfo],
stories: &[Option<Arc<Story>>],
made: Vec<Option<Made>>,
template: Option<(f64, f64)>,
to_doc: Affine,
pages: [&[Vec<Arc<Node>>]; 2],
@@ -59,49 +196,16 @@ pub(super) fn plan(
Some(s.line_starts(t).into_iter().map(|(text, (x, y))| (text, to_doc * Point::new(x, y))).collect())
})
.collect();
let owner = match_page_objects(infos, &lines, shown);
// Type the text document makes, shown or not: the page's pieces of it aren't needed.
let mut content: Vec<Content> = infos.iter().map(|_| Content::Empty).collect();
// A story's lines are its first frame's: a frame of a story whose type is made in another
// text object can't say that the page's type there is its own.
let made_stories: std::collections::BTreeSet<u32> = infos.iter().zip(&made).filter(|(_, m)| m.is_some()).filter_map(|(i, _)| i.story).collect();
let open: Vec<bool> = infos.iter().zip(&made).map(|(i, m)| m.is_none() && i.story.is_none_or(|s| !made_stories.contains(&s))).collect();
let owner = match_page_objects(infos, &lines, &open, shown);
// Where the made type that doesn't show lies, by slot.
let mut hidden_at: Vec<(usize, Rect)> = vec![];
let mut taller = 0;
if let Some(t) = template {
for (k, info) in infos.iter().enumerate() {
let Some(Some(story)) = stories.get(k) else { continue };
let Some(mut node) = story.node(NodeId(0), info.frame, t, to_doc) else { continue };
let mut region = story.region(t, to_doc);
// The first line where the file has it, in the text document's frame (that of a
// copied object is its original's, as its lines are).
story.place_first_line(&mut node, t, to_doc);
// Area type and type on a path in the object's own frame: the text document's frame
// of a copied object keeps its original's path.
frame_text_from_art(info, &mut node, &mut region);
if let (false, Some(r)) = (info.shown, region) {
hidden_at.push((k, r));
}
if let NodeKind::Text(text) = &mut node.kind
&& story.fit_first_line(text)
{
taller += 1;
}
if let Some(c) = content.get_mut(k) {
*c = Content::Made(Box::new(node), region.filter(|_| info.shown), false, vec![]);
}
}
}
if taller > 0 {
warn.push(format!(
"area type in frames too short for a line of the font it shows in got taller frames ({taller}): their type wouldn't show otherwise"
));
}
// The file's text document is trusted where it agrees with the page about something (its lines
// start where the page's type does, or the page has type where it lays type out), or where the
// page has no type to disagree with.
let in_region = |object: &[Arc<Node>]| covered(object, &content, |_| true);
let trusted = owner.iter().any(Option::is_some) || shown.is_empty() || shown.iter().any(|o| in_region(o));
if !trusted {
content.iter_mut().for_each(|c| *c = Content::Empty);
}
let hidden_at: Vec<(usize, Rect)> =
infos.iter().zip(&made).enumerate().filter_map(|(k, (info, m))| Some((k, m.as_ref().filter(|_| !info.shown)?.1?))).collect();
// Type the text document makes, shown or not: the page's pieces of it aren't needed.
let mut content = made_content(infos, made);
// Is `object` the type of a text object that doesn't show, as the page draws it: in its frame,
// with its words? (A copied object's lines are where its original's are, so they don't say.)
let hidden_type = |object: &[Arc<Node>]| {
@@ -116,59 +220,45 @@ pub(super) fn plan(
})
})
};
// The letters of each slot's story, in lower case.
let spelled: Vec<String> = stories.iter().map(|s| s.as_ref().map(|s| letters(s.text()).to_ascii_lowercase()).unwrap_or_default()).collect();
// For each slot: is its made type where `object` is, with the words of one of `object`'s texts in
// its story? All are false unless such slots have the words of every text between them.
let spelling = |object: &[Arc<Node>], content: &[Content]| -> Vec<bool> {
let r = extent(object);
let texts: Vec<Vec<String>> = object
// The letters and digits of each slot's made type that shows, in any script, in lower case.
let spelled: Vec<String> = content
.iter()
.map(|c| match c {
Content::Made(node, Some(_), _) => match &node.kind {
NodeKind::Text(t) => spelling(&t.plain_text()),
_ => String::new(),
},
_ => String::new(),
})
.collect();
// Has made type that draws where `object` lies every word of it ([`spelling`]), in any order?
let made_words = |object: &[Arc<Node>], content: &[Content]| {
let Some(r) = extent(object) else { return true };
let near: Vec<&String> = content
.iter()
.filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(words(&t.plain_text().to_ascii_lowercase())) } else { None })
.filter(|ws| !ws.is_empty())
.zip(&spelled)
.filter(|(c, _)| matches!(c, Content::Made(_, Some(region), _) if !region.intersect(r).is_zero_area()))
.map(|(_, s)| s)
.collect();
let has = |k: usize| spelled.get(k).is_some_and(|s| texts.is_empty() || texts.iter().any(|ws| ws.iter().all(|w| s.contains(w.as_str()))));
let near: Vec<bool> = content
.iter()
.enumerate()
.map(|(k, c)| match (c, r) {
(Content::Made(_, Some(region), ..), Some(r)) => region.intersect(r).area() > 0.0 && has(k),
_ => false,
})
.collect();
let every = texts.iter().flatten().all(|w| near.iter().zip(&spelled).any(|(n, s)| *n && s.contains(w.as_str())));
if every { near } else { vec![] }
object_words(object).iter().all(|w| near.iter().any(|s| s.contains(w.as_str())))
};
let mut spare: Vec<Vec<Arc<Node>>> = vec![];
for (j, object) in shown.iter().enumerate() {
let spells = spelling(object, &content);
let spells = |k: usize| spells.get(k).copied().unwrap_or(false);
match owner.get(j).copied().flatten().filter(|_| trusted) {
// The type of a text object the layers hide: the layers have it, hidden, so the page's
// isn't another text object's.
Some(k) if infos.get(k).is_some_and(|i| !i.shown) && matches!(content.get(k), Some(Content::Made(..))) => {}
match owner.get(j).copied().flatten() {
Some(k) => match content.get_mut(k) {
Some(Content::Made(_, _, seen, _)) => *seen = true,
Some(Content::Page(p)) => p.extend(object.iter().cloned()),
Some(c) => *c = Content::Page(object.clone()),
None => {}
},
// What lies where made type is and has that type's words is that type as the page draws it.
None if covered(object, &content, spells) => {
let r = extent(object).unwrap_or(Rect::ZERO);
for (k, c) in content.iter_mut().enumerate() {
if let Content::Made(_, Some(region), seen, _) = c
&& !region.intersect(r).is_zero_area()
&& spells(k)
{
*seen = true;
}
}
}
None if trusted && hidden_type(object) => {}
// What lies where made type draws, with its words, is that type as the page draws it (in
// another font, maybe, and in any script).
None if covered(object, &content, |_| true) && made_words(object, &content) => {}
None if hidden_type(object) => {}
None => spare.push(object.clone()),
}
}
// The page's type that no made type takes is the spare type below.
let _ = see_page_type(&mut content, shown);
for (k, c) in content.iter_mut().enumerate() {
if let (Content::Page(parts), Some(Some(l))) = (c, lines.get(k)) {
join_pieces(parts, l);
@@ -179,12 +269,11 @@ pub(super) fn plan(
infos.iter().enumerate().filter(|(k, i)| i.shown && matches!(content.get(*k), Some(Content::Empty))).map(|(k, _)| k).collect();
if loose.is_empty() {
// Type of the page that no text object says it owns goes with the nearest text object that has
// type of the page; with none, it is left out.
// type of the page (never into made type); with none, it is left out.
let mut left_out = false;
for object in &spare {
let (centre, near) = (extent(object).map(|r| r.center()), |c: &Content| match c {
Content::Page(parts) => extent(parts).map(|r| r.center()),
Content::Made(_, region, ..) => region.map(|r| r.center()),
_ => None,
});
let nearest = content
@@ -192,14 +281,14 @@ pub(super) fn plan(
.filter_map(|c| near(c).zip(centre).map(|(p, q)| ((p.x - q.x).hypot(p.y - q.y), c)))
.min_by(|a, b| a.0.total_cmp(&b.0));
match nearest {
Some((_, Content::Page(parts) | Content::Made(_, _, _, parts))) => parts.extend(object.iter().cloned()),
Some((_, Content::Page(parts))) => parts.extend(object.iter().cloned()),
_ => left_out = true,
}
}
if !spare.is_empty() {
warn.push(
if left_out {
"some of the page's type has no text object in the layers, so it is left out"
PAGE_TYPE_LEFT_OUT
} else {
"some of the page's type has no text object of its own in the layers, so it is with another's"
}
@@ -221,13 +310,50 @@ pub(super) fn plan(
warn.push("the text is on the layers in the order the page paints it, so a text object may be in another group than it was".into());
}
}
// The same for the slots that don't show.
fill_hidden(infos, &mut content, hidden);
content
}
/// The page's type that doesn't show (`hidden`) goes by painting order into the slots that don't
/// show and have nothing yet, but for what is the type of made type that doesn't show: a page object
/// with words and at least half of it where such type draws, while the type has all its words left
/// (each word an object takes is the type's no longer). Past `MAX_PAIRS` comparisons all of it goes.
fn fill_hidden(infos: &[SlotInfo], content: &mut [Content], hidden: &[Vec<Arc<Node>>]) {
let unread: Vec<usize> =
infos.iter().enumerate().filter(|(k, i)| !i.shown && matches!(content.get(*k), Some(Content::Empty))).map(|(k, _)| k).collect();
for (slot, parts) in unread.iter().zip(assign(hidden, unread.len())) {
if unread.is_empty() {
return;
}
// Where each made type that doesn't show draws, and the letters it has left.
let mut made: Vec<(Rect, String)> = content
.iter()
.filter_map(|c| match c {
Content::Made(node, None, _) => node.visual_bounds().map(|b| (b.inflate(OUTLINE_REACH, OUTLINE_REACH), spelling(&plain_text(node)))),
_ => None,
})
.collect();
let pairs = made.len().saturating_mul(hidden.len());
// Is `o` the type of made type that doesn't show? That type's letters for it are taken.
let mut theirs = |o: &[Arc<Node>]| {
let words = object_words(o);
let Some(r) = extent(o).filter(|_| !words.is_empty()) else { return false };
let found = made.iter_mut().find_map(|(b, s)| {
if b.intersect(r).area() < 0.5 * r.area().max(1e-9) {
return None;
}
let mut left = s.clone();
for w in &words {
let at = left.find(w.as_str())?;
left.replace_range(at..at + w.len(), "");
}
Some((s, left))
});
found.map(|(s, left)| *s = left).is_some()
};
let hidden: Vec<Vec<Arc<Node>>> = if pairs > MAX_PAIRS { hidden.to_vec() } else { hidden.iter().filter(|o| !theirs(o)).cloned().collect() };
for (slot, parts) in unread.iter().zip(assign(&hidden, unread.len())) {
if let Some(c) = content.get_mut(*slot) {
*c = Content::Page(parts.into_iter().flat_map(|p| p.iter().cloned()).collect());
}
}
content
}
+21 -20
View File
@@ -9,13 +9,23 @@ pub(super) const TYPE_LEFT_OUT: &str = "text objects of kinds VectorCraft doesn'
pub(super) const ART_LEFT_OUT: &str = "hidden layers have art this can't read";
/// The page's type that no text object takes, left out.
pub(super) const PAGE_TYPE_LEFT_OUT: &str = "some of the page's type has no text object in the layers, so it is left out";
/// The page's type, too much to check against the text objects (it may have some that none takes).
pub(super) const PAGE_TYPE_UNCHECKED: &str =
"the page's type wasn't checked against the text objects, as there is too much of it: some of it may have no text object";
pub(super) const LAYERS_UNREAD: &str = "the file's layers weren't read";
/// Does this import note say that the file has something the document doesn't (hidden text, art or
/// layers that could not be read)? Writing the document over the file would lose it for good.
/// layers that could not be read, or the page's type that no text object takes or may not take)?
/// Writing the document over the file would lose it for good.
pub fn is_loss(note: &str) -> bool {
note.ends_with(TEXT_LEFT_OUT)
|| note.ends_with(TYPE_LEFT_OUT)
|| note == PAGE_TYPE_LEFT_OUT
|| note == PAGE_TYPE_UNCHECKED
|| [ART_LEFT_OUT, LAYERS_UNREAD, crate::import::graphics::TOO_MUCH, crate::import::graphics::FAR_AWAY].iter().any(|n| note.starts_with(n))
}
@@ -163,10 +173,9 @@ pub(super) fn assign(objects: &[Vec<Arc<Node>>], slots: usize) -> Vec<Vec<&[Arc<
pub(super) enum Content {
/// Text objects (with the strokes round them) of the page.
Page(Vec<Arc<Node>>),
/// A text object made from the file's text document, where it lies on the document, whether
/// the page has type where it is, and the page's type of no text object that is nearest it
/// (kept after it).
Made(Box<Node>, Option<Rect>, bool, Vec<Arc<Node>>),
/// A text object made from the file's text document, where it draws on the document (when it
/// shows), and whether the page has type where it is.
Made(Box<Node>, Option<Rect>, bool),
/// Nothing the file lets this read.
Empty,
/// Its type is on the page with another text object's; there is nothing to put here.
@@ -252,7 +261,7 @@ pub(super) fn fill_slots(
i += n;
continue;
}
Some(Content::Made(mut text, _, seen, extra)) => {
Some(Content::Made(mut text, _, seen)) => {
text.id = doc.alloc_id();
if let Some(story) = story {
made.push((story, frame, text.id));
@@ -265,20 +274,12 @@ 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))) };
let extra: Vec<Arc<Node>> = extra.iter().map(|p| reid(doc, p)).collect();
// The type takes the slot's transparency; the page's type kept after it, where it has
// none of its own, goes into a group with it that has the transparency instead.
let parts: Vec<Arc<Node>> = if transparent && !extra.is_empty() && !extra.iter().any(|p| see_through(p)) {
let mut group = Node::group(doc.alloc_id(), std::iter::once(Arc::new(*text)).chain(extra).collect());
(group.opacity, group.blend, group.isolate, group.knockout) = state;
vec![Arc::new(group)]
} else {
(text.opacity, text.blend, text.isolate, text.knockout) = state;
std::iter::once(Arc::new(*text)).chain(extra).collect()
};
let n = parts.len();
nodes.splice(i..=i, parts);
i += n;
// The type takes the slot's transparency.
(text.opacity, text.blend, text.isolate, text.knockout) = state;
if let Some(slot) = nodes.get_mut(i) {
*slot = Arc::new(*text);
}
i += 1;
continue;
}
Some(Content::Merged) => {
+356 -46
View File
@@ -342,6 +342,24 @@ fn type_of_the_page_inside_a_placed_file_needs_no_text_object() {
assert!(!read, "{warnings:?}");
assert!(warnings.iter().any(|w| w.contains("its layers have no text objects")), "{warnings:?}");
}
// With a text object the text document makes beside it, the placed file's type is still its
// own, not type left out.
// The template box's centre (100, 50) is the canvas centre: the anchor is at (5, 10) in the art.
let doc = text_document(&[("Note\r", 0, (8096.5, 8231.5), (0.0, 0.0), 0.0)]);
let both = native_with_text(&layer("Pages", true, &format!("{place}{}", text_object(0))), &doc)
.replace("%AI3_TemplateBox: 50 50 50 50", "%AI3_TemplateBox: 100 50 100 50");
let mut page = pdf(38.0);
let style = vectorcraft_doc::CharStyle { size: 14.0, ..Default::default() };
let note = Node::new(
page.alloc_id(),
NodeKind::Text(Box::new(vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 90.0), "Note", style))),
);
let layer_id = page.layers[0].id;
page.insert(Some(layer_id), 2, note).unwrap();
let (d, warnings, read) = crate::layered_ai(&private(&both), page, vec![], false, false, 1.0);
assert!(read, "{warnings:?}");
assert_eq!(text_of(&d.layers[0]).iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["Note"], "{warnings:?}");
assert!(!warnings.iter().any(|w| w.contains("left out")), "{warnings:?}");
}
/// A palette of `[None]` and White, and a color group `Inks` holding a spot color, Red ink.
@@ -979,38 +997,83 @@ fn a_line_the_page_draws_in_pieces_goes_to_its_text_object_whole() {
assert!(t.cached_bounds.is_none() && t.cached_baselines.is_empty());
}
#[test]
fn type_of_the_page_that_no_story_places_stays_with_the_nearest_text_object() {
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
let art = native_with_text(&layer("Art", true, &text_object(0)), &doc);
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show 58.5 30 moveto (Extra) show";
let r = import(&eps(page, &art)).unwrap();
let mut all = vec![];
r.document.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
all.push(t.plain_text());
}
});
assert!(all.contains(&"Extra".to_string()), "{all:?} {:?}", r.warnings);
/// `doc` (from [`text_document`], whose stories all name the first frame) with its last story in
/// its own frame, the last, of a kind this doesn't read.
fn last_frame_unread(doc: &str) -> String {
let frames = doc.matches("/2 << /2 [").count();
let mut doc = doc.to_string();
let at = doc.rfind("/1 << /0 [ << /0 0 >> ]").unwrap();
doc.replace_range(at..at + 23, &format!("/1 << /0 [ << /0 {} >> ]", frames - 1));
let at = doc.rfind("/2 << /2 [").unwrap() + "/2 << ".len();
doc.insert_str(at, "/0 9 ");
doc
}
/// Page type of no text object that lies where made type is, but has other letters, is not that type:
/// it stays, with the nearest text object.
#[test]
fn type_of_the_page_near_made_type_with_other_letters_stays() {
// "Extra" is 11.5 below the line of "Hi there", well inside the box round that line.
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
let art = native_with_text(&layer("Art", true, &text_object(0)), &doc);
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show 58.5 50 moveto (Extra) show";
let r = import(&eps(page, &art)).unwrap();
/// The plain text of every text object in `d`, in order.
fn all_texts(d: &Document) -> Vec<String> {
let mut all = vec![];
r.document.walk(|n| {
d.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
all.push(t.plain_text());
}
});
assert_eq!(all, ["Hi there", "Extra"], "{:?}", r.warnings);
assert!(r.warnings.iter().any(|w| w.contains("with another's")), "{:?}", r.warnings);
all
}
/// Type of the page that no story places goes to the text object whose type the text document
/// can't make, not into made type; with no such text object and none of the page's type in a text
/// object, it is art the layers don't draw, which the comparison weighs.
#[test]
fn type_of_the_page_that_no_story_places_goes_to_a_text_object_that_cant_be_made() {
let doc =
last_frame_unread(&text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Note\r", 0, (8200.0, 8120.0), (0.0, 0.0), 0.0)]));
let art = native_with_text(&layer("Art", true, &(text_object(0) + &text_object(1))), &doc);
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show 58.5 30 moveto (Extra) show";
let r = import(&eps(page, &art)).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
assert_eq!(all_texts(&r.document), ["Hi there", "Extra"], "{:?}", r.warnings);
// Every text object's type made: the page's "Extra" is in none of them, so the file comes in as its page.
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
let r = import(&eps(page, &native_with_text(&layer("Art", true, &text_object(0)), &doc))).unwrap();
assert!(r.warnings.iter().any(|w| w.contains("layers weren't read")), "{:?}", r.warnings);
}
/// A story whose first frame's type is made and whose second frame's can't be: the page's type of
/// the first frame stays the made type's, and isn't the second frame's too.
#[test]
fn page_type_of_a_made_frame_isnt_given_to_the_storys_other_frame() {
let mut doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Spare\r", 0, (8200.0, 8160.0), (0.0, 0.0), 0.0)]);
// Story 0 in frames 0 and 1, both point type: point type isn't threaded, so its second frame has none.
let at = doc.find("/1 << /0 [ << /0 0 >> ]").unwrap();
doc.replace_range(at..at + 23, "/1 << /0 [ << /0 0 >> << /0 1 >> ]");
let art = native_with_text(&layer("Art", true, &(frame_object(0, 0) + &frame_object(0, 1))), &doc);
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show";
let r = import(&eps(page, &art)).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
assert_eq!(all_texts(&r.document), ["Hi there"], "{:?}", r.warnings);
}
/// Page type of no text object that lies where made type draws, with its words in any case, is that
/// type as the page draws it, and doesn't go to a text object whose type can't be made. Page type
/// with other words there, or beside made type, does.
#[test]
fn type_of_the_page_where_made_type_draws_with_its_words_is_that_type() {
let doc =
last_frame_unread(&text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Note\r", 0, (8200.0, 8120.0), (0.0, 0.0), 0.0)]));
let art = native_with_text(&layer("Art", true, &(text_object(0) + &text_object(1))), &doc);
let with = |drawn: &str, y: f64| {
format!("0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show 58.5 {y} moveto ({drawn}) show")
};
// "THERE" over the line of "Hi there", a point below it.
let r = import(&eps(&with("THERE", 60.5), &art)).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
assert_eq!(all_texts(&r.document), ["Hi there"], "{:?}", r.warnings);
assert!(r.warnings.iter().any(|w| w.starts_with("1 text objects")), "{:?}", r.warnings);
// "Extra" there, or 11.5 below that line: the text object that can't be made takes it.
for y in [60.5, 50.0] {
let r = import(&eps(&with("Extra", y), &art)).unwrap();
assert_eq!(all_texts(&r.document), ["Hi there", "Extra"], "{y}: {:?}", r.warnings);
}
}
/// Page type of no text object that lies where made type is and has its letters, in any case, is that
@@ -1075,22 +1138,23 @@ fn type_of_the_page_with_the_letters_of_two_made_types_where_it_lies_is_their_ty
}
/// The file's text document is trusted where the page has type where it lays type out, whatever
/// letters that type has: its type is weighed against the page.
/// letters that type has: the type is the text document's, and the difference is noted as type.
/// The page's type with other words is left out, with a note that is a loss.
#[test]
fn page_type_where_made_type_is_trusts_the_text_document_whatever_its_letters() {
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
let art = native_with_text(&layer("Art", true, &text_object(0)), &doc);
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 50 moveto (Extra) show";
let r = import(&eps(page, &art)).unwrap();
let mut all = vec![];
r.document.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
all.push(t.plain_text());
}
});
assert_eq!(all, ["Extra"], "{:?}", r.warnings);
// "Hi there" was made from the text document and left out because the page doesn't draw it.
assert!(r.warnings.iter().any(|w| w.contains("doesn't draw")), "{:?}", r.warnings);
assert_eq!(all_texts(&r.document), ["Hi there"], "{:?}", r.warnings);
assert!(r.warnings.iter().any(|w| w.starts_with("the type on the layers differs")), "{:?}", r.warnings);
assert!(!r.warnings.iter().any(|w| w.contains("doesn't draw")), "{:?}", r.warnings);
assert!(r.warnings.iter().any(|w| w.ends_with("so it is left out") && crate::is_loss(w)), "{:?}", r.warnings);
// The page's type with the text document's words, in capitals: nothing is left out.
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 50 moveto (HI THERE) show";
let r = import(&eps(page, &art)).unwrap();
assert_eq!(all_texts(&r.document), ["Hi there"], "{:?}", r.warnings);
assert!(!r.warnings.iter().any(|w| w.contains("left out")), "{:?}", r.warnings);
}
#[test]
@@ -1161,15 +1225,6 @@ fn type_keeps_the_opacity_and_blend_mode_of_its_text_object() {
let art = native_with_text(&layer("Art", true, &(drawing.clone() + &format!("{own}1 (Style) XW\n").repeat(50))), &doc);
let (d, notes) = crate::ai_alone(&private(&art)).unwrap();
assert_eq!(depth(&d.layers[0]), 4, "{notes:?}");
// Made type with the page's type of no text object kept after it: a group of the two has it.
let art = native_with_text(&layer("Art", true, &format!("1 0.2 0 0 0 Xy\n{}", text_object(0))), &doc);
let r = import(&eps(&format!("{page} 58.5 30 moveto (Extra) show"), &art)).unwrap();
assert_eq!(kinds(&r.document.layers[0]), ["group[2]"], "{:?}", r.warnings);
let group = &r.document.layers[0].children().unwrap()[0];
assert_eq!((group.opacity, group.blend), (0.2, BlendMode::Multiply));
let texts: Vec<String> = text_of(group).iter().map(|t| t.plain_text()).collect();
assert_eq!(texts, ["Hi there", "Extra"]);
assert!(group.children().unwrap().iter().all(|p| (p.opacity, p.blend) == (1.0, BlendMode::Normal)));
// A story in a frame of a kind this doesn't know: the type that shows is the page's. An EPS page has
// no transparency, so its type takes its text object's.
let doc = doc.replace("/2 << /2 [ 1 0 0 1 0 0 ] >>", "/2 << /0 9 /2 [ 1 0 0 1 0 0 ] >>");
@@ -1296,8 +1351,9 @@ fn a_run_longer_than_any_text_doesnt_overflow() {
#[test]
fn more_text_objects_than_are_matched_leave_the_page() {
// Text objects whose type the text document can't make are matched with the page's type.
let many = text_object(0).repeat(5001);
let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
let doc = last_frame_unread(&text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]));
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &many)), &doc);
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
assert_eq!(names(&r.document), ["Back", "Spare", "Layer 1"], "{:?}", r.warnings);
@@ -1440,8 +1496,10 @@ fn art_the_page_doesnt_draw_is_noted_with_its_own_share_and_not_the_types() {
let r = import(&page_in_pieces_and_layers(bar)).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
let notes: Vec<&String> = r.warnings.iter().filter(|w| w.contains("differs from the file's own page")).collect();
assert_eq!(notes.len(), 1, "{:?}", r.warnings);
// The type, all laid out again from the text document, has a note of its own.
assert_eq!(notes.len(), 2, "{:?}", r.warnings);
assert!(notes[0].starts_with("the art on the layers differs from the file's own page in about 0.4% of it (brush strokes"), "{notes:?}");
assert!(notes[1].starts_with("the type on the layers differs from the file's own page"), "{notes:?}");
}
/// An EPS whose page is its art's box `[x0 y0 x1 y1]` (not its artboard, 0 0 100 100), drawing
@@ -1587,6 +1645,258 @@ fn area_type_and_type_on_a_path_come_from_the_text_document() {
assert!(!r.warnings.iter().any(|w| w.contains("couldn't be read")), "{:?}", r.warnings);
}
/// A point type frame at the point (`x`, `y`) of the art.
fn point_frame(x: f64, y: f64) -> String {
let (cx, cy) = canvas(x, y);
format!("<< /0 << /0 [ 0 0 ] /2 << /2 [ 1 0 0 1 {cx} {cy} ] >> >> >>")
}
/// An area type frame of the rectangle `x0 y0 x1 y1` of the art.
fn area_frame(x0: f64, y0: f64, x1: f64, y1: f64) -> String {
format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 >> >> >>", canvas_rect(x0, y0, x1, y1))
}
/// The text of `n` when it is a text object, or a group of one object whose text that is.
fn lone_text(n: &Node) -> Option<String> {
match &n.kind {
NodeKind::Text(t) => Some(t.plain_text()),
NodeKind::Group { .. } => match n.children()?.as_slice() {
[only] => lone_text(only),
_ => None,
},
_ => None,
}
}
/// What each object of layer `l` holds as one text object ([`lone_text`]).
fn lone_texts(l: &Node) -> Vec<Option<String>> {
l.children().into_iter().flatten().map(|n| lone_text(n)).collect()
}
/// `text` as a string of the text document: UTF-16 after its byte order mark, each byte in octal.
fn utf16(text: &str) -> String {
let mut out = String::from("\\376\\377");
for unit in text.encode_utf16() {
for b in unit.to_be_bytes() {
out.push_str(&format!("\\{b:03o}"));
}
}
out
}
/// Type the text document makes for every text object that shows stays in each one's own group,
/// on its own layer, however the page draws it: in pieces, in another order, with a quote hung
/// outside its frame.
#[test]
fn type_from_the_text_document_stays_in_its_groups() {
let frames = [
point_frame(10.0, 90.0),
point_frame(10.0, 78.0),
point_frame(55.0, 90.0),
area_frame(10.0, 40.0, 45.0, 55.0),
area_frame(55.0, 40.0, 90.0, 55.0),
];
let style = "/1 8.0";
let doc = document_of(
&frames,
&[
story("Alpha\\r", &[0], "/0 0", style),
story("Bravo\\r", &[1], "/0 0", style),
story("Charlie\\r", &[2], "/0 0", style),
story("\"Delta\"\\r", &[3], "/0 0", style),
story("\"Echo\"\\r", &[4], "/0 0", style),
],
);
let nested = |s: u32| format!("u\nu\n{}U\nU\n", frame_object(s, 0));
let single = |s: u32| format!("u\n{}U\n", frame_object(s, 0));
let words = layer("Words", true, &(nested(0) + &nested(1) + &single(3)));
let more = layer("More", true, &(single(2) + &nested(4)));
let art = native_with_text(&(words + &more), &doc);
// The page paints the third story before the first, each in two pieces, and hangs the area
// type's opening quotes outside their frames, painted last.
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 8 scalefont setfont \
55 90 moveto (Char) show 72.3 90 moveto (lie) show 10 90 moveto (Al) show 17.1 90 moveto (pha) show \
10 78 moveto (Bravo) show 10 48.5 moveto (Delta\") show 55 48.5 moveto (Echo\") show \
7.2 48.5 moveto (\") show 52.2 48.5 moveto (\") show";
let r = import(&eps(page, &art)).unwrap();
assert_eq!(names(&r.document), ["Words", "More"], "{:?}", r.warnings);
let some = |v: &[&str]| v.iter().map(|s| Some(s.to_string())).collect::<Vec<_>>();
assert_eq!(lone_texts(&r.document.layers[0]), some(&["Alpha", "Bravo", "\"Delta\""]), "{:?}", r.warnings);
assert_eq!(lone_texts(&r.document.layers[1]), some(&["Charlie", "\"Echo\""]), "{:?}", r.warnings);
assert_eq!(all_texts(&r.document).len(), 5, "{:?}", r.warnings);
}
/// The page's type isn't matched with the text objects when the text document makes the type of
/// every one: a file of more of them than are matched still comes in with its layers.
#[test]
fn six_thousand_point_stories_open_with_layers() {
let stories: Vec<(String, (f64, f64))> =
(0..6000).map(|i| (format!("S{i}\r"), (8150.0 + f64::from(i % 60), 8150.0 + f64::from(i / 60) * 0.8))).collect();
let list: Vec<Story<'_>> = stories.iter().map(|(t, at)| (t.as_str(), 0, *at, (0.0, 0.0), 0.0)).collect();
let doc = text_document(&list);
let objects: String = (0..6000).map(text_object).collect();
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Words", true, &objects)), &doc);
let (d, notes) = crate::ai_alone(&private(&art)).unwrap();
assert_eq!(names(&d), ["Back", "Words"], "{notes:?}");
assert_eq!(text_of(&d.layers[1]).len(), 6000, "{notes:?}");
}
/// A story in a frame of a kind this doesn't read takes the page's type for its own text object
/// only: the others keep the type made from the text document, in their groups, and the page's
/// type beside made type, with its letters, goes to the text object that can't be made.
#[test]
fn a_story_with_an_unknown_frame_kind_falls_back_for_that_slot_only() {
let doc = last_frame_unread(&text_document(&[
("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0),
("Low\r", 0, (8160.0, 8221.5), (0.0, 0.0), 0.0),
("Hi\r", 0, (8200.0, 8191.5), (0.0, 0.0), 0.0),
]));
let grouped = |s: u32| format!("u\n{}U\n", text_object(s));
let art = native_with_text(&layer("Art", true, &(grouped(0) + &grouped(1) + &grouped(2))), &doc);
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show 18.5 20 moveto (Low) show \
58.5 50 moveto (Hi) show";
let r = import(&eps(page, &art)).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
let some = |v: &[&str]| v.iter().map(|s| Some(s.to_string())).collect::<Vec<_>>();
assert_eq!(lone_texts(&r.document.layers[0]), some(&["Hi there", "Low", "Hi"]), "{:?}", r.warnings);
assert!(!r.warnings.iter().any(|w| w.contains("left out")), "{:?}", r.warnings);
}
/// Type laid out from the text document that the page draws differently (in another font, where
/// the file's isn't installed) keeps the layers, with a note; art that differs still doesn't.
#[test]
fn type_drawn_in_another_font_keeps_the_layers() {
let lines = [("Hello\r", 8221.5), ("World\r", 8201.5), ("Again\r", 8181.5), ("Today\r", 8161.5)];
let list: Vec<Story<'_>> = lines.iter().map(|(t, y)| (*t, 0, (8146.5, *y), (0.0, 0.0), 0.0)).collect();
let doc = text_document(&list).replace("/1 14.0", "/1 20.0");
let objects: String = (0..4).map(text_object).collect();
// The page draws each line in capitals: a font that isn't this one.
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 20 scalefont setfont \
5 85 moveto (HELLO) show 5 65 moveto (WORLD) show 5 45 moveto (AGAIN) show 5 25 moveto (TODAY) show";
let r = import(&eps(page, &native_with_text(&layer("Art", true, &objects), &doc))).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
assert_eq!(all_texts(&r.document), ["Hello", "World", "Again", "Today"], "{:?}", r.warnings);
assert!(r.warnings.iter().any(|w| w.starts_with("the type on the layers differs from the file's own page")), "{:?}", r.warnings);
// A square the page doesn't draw, away from the type: the file comes in as its page.
let block = "0 0 0 1 k\n80 5 m\n90 5 L\n90 15 L\n80 15 L\nf\n";
let r = import(&eps(page, &native_with_text(&layer("Art", true, &format!("{block}{objects}")), &doc))).unwrap();
assert!(r.warnings.iter().any(|w| w.contains("layers weren't read")), "{:?}", r.warnings);
}
/// Type laid out from the text document where the page draws nothing is art the page lacks, even
/// with more such text objects than are weighed one by one against the page.
#[test]
fn type_the_page_doesnt_draw_on_bare_page_still_differs() {
let mut list: Vec<Story<'_>> = vec![("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)];
for i in 0..14 {
list.push(("Gm\r", 0, (8146.5 + f64::from(i % 4) * 22.0, 8216.5 + f64::from(i / 4) * 18.0), (0.0, 0.0), 0.0));
}
let doc = text_document(&list);
let objects: String = (0..15).map(text_object).collect();
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show";
let r = import(&eps(page, &native_with_text(&layer("Art", true, &objects), &doc))).unwrap();
assert!(r.warnings.iter().any(|w| w.contains("layers weren't read")), "{:?}", r.warnings);
}
/// A hidden text object whose type the text document can't make takes the page's hidden type, as
/// it does when some shown text object's type can't be made either, but not the page's hidden type
/// of one whose type is made.
#[test]
fn a_hidden_text_object_that_cant_be_made_takes_the_pages_hidden_type() {
let doc = last_frame_unread(&text_document(&[
("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0),
("Known\rMore\r", 0, (8200.0, 8200.0), (0.0, 0.0), 0.0),
("Secret\r", 0, (8200.0, 8160.0), (0.0, 0.0), 0.0),
]));
let spare = layer("Spare", false, &(text_object(1) + &text_object(2)));
let art = native_with_text(&(layer("Art", true, &format!("{}\n{}", square(10, 10), text_object(0))) + &spare), &doc);
let style = vectorcraft_doc::CharStyle { size: 14.0, ..Default::default() };
let text = |d: &mut Document, s: &str, y: f64| {
Node::new(d.alloc_id(), NodeKind::Text(Box::new(vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(58.5, y), s, style.clone()))))
};
// The last text object's type over "Known", in words or in marks only.
for secret in ["Secret", "?!"] {
let mut page = pdf_part(10.0, 86.0);
let shown = text(&mut page, "Hi there", 38.5);
let first = page.layers[0].id;
page.insert(Some(first), 1, shown).unwrap();
let spare = page.add_layer(Some("Spare"));
// The two lines of "Known More" where its story puts them, and the other over them, painted
// first.
for (i, (s, y)) in [(secret, 60.0), ("Known", 58.5), ("More", 73.0)].into_iter().enumerate() {
let n = text(&mut page, s, y);
page.insert(Some(spare), i, n).unwrap();
}
if let Some(l) = page.node_mut(spare) {
l.visible = false;
}
let (d, w, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(read, "{secret}: {w:?}");
assert_eq!(names(&d), ["Art", "Spare"], "{w:?}");
let texts = |l: usize| text_of(&d.layers[l]).iter().map(|t| t.plain_text()).collect::<Vec<_>>();
assert_eq!((texts(0), texts(1)), (vec!["Hi there".to_string()], vec!["Known\nMore".to_string(), secret.to_string()]), "{w:?}");
}
}
/// A small label the page draws where no text object of the layers has type differs too little to
/// send the file to its page: the import says it is left out. Type the page draws of a hidden
/// text object isn't counted.
#[test]
fn a_small_label_of_the_page_in_no_text_object_is_noted() {
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Extra\r", 0, (8200.0, 8211.5), (0.0, 0.0), 0.0)]);
let art = native_with_text(&(layer("Art", true, &text_object(0)) + &layer("Spare", false, &text_object(1))), &doc);
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show 58.5 30 moveto (Extra) show";
let r = import(&eps(page, &art)).unwrap();
assert_eq!(names(&r.document), ["Art", "Spare"], "{:?}", r.warnings);
assert!(!r.warnings.iter().any(|w| w.contains("so it is left out")), "{:?}", r.warnings);
let label = format!("{page} /Helvetica findfont 3 scalefont setfont 10 90 moveto (no.) show");
let r = import(&eps(&label, &art)).unwrap();
assert_eq!(names(&r.document), ["Art", "Spare"], "{:?}", r.warnings);
let note = r.warnings.iter().find(|w| *w == "some of the page's type has no text object in the layers, so it is left out");
assert!(note.is_some_and(|w| crate::is_loss(w)), "{:?}", r.warnings);
}
/// A line break inside a paragraph (`\x03` in the text document) stays a line break in it.
#[test]
fn a_soft_return_is_a_line_break_in_its_paragraph() {
let doc = text_document(&[("One\\003Two\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Words", true, &text_object(0))), &doc);
let (d, notes) = crate::ai_alone(&private(&art)).unwrap();
let texts = text_of(&d.layers[1]);
assert_eq!(texts.len(), 1, "{notes:?}");
assert_eq!(texts[0].plain_text(), "One\u{2028}Two");
assert!(texts[0].paras.is_empty(), "one paragraph: {:?}", texts[0].paras);
}
/// A Thai story is one text object however the page splits its type: by word, and its marks
/// above apart from their letters, above the top of its frame.
#[test]
fn a_thai_story_stays_one_text_object() {
let thai = "\u{0E2A}\u{0E38}\u{0E02}\u{0E2A}\u{0E31}\u{0E19}\u{0E15}\u{0E4C} \u{0E27}\u{0E31}\u{0E19}\u{0E40}\u{0E01}\u{0E34}\u{0E14}";
let doc = document_of(&[area_frame(10.0, 30.0, 90.0, 60.0)], &[story(&utf16(&format!("{thai}\r")), &[0], "/0 0", "/1 14.0")]);
let art = native_with_text(&layer("Art", true, &format!("{}\nu\n{}U\n", square(10, 10), frame_object(0, 0))), &doc);
let mut page = pdf_part(10.0, 86.0);
let layer_id = page.layers[0].id;
let style = vectorcraft_doc::CharStyle { size: 14.0, ..Default::default() };
// Doc y 40 is the frame's top: the words on its first line, the marks a little above it.
let pieces = [
("\u{0E2A}\u{0E38}\u{0E02}\u{0E2A}\u{0E19}\u{0E15}", 10.0, 53.0),
("\u{0E27}\u{0E19}\u{0E40}\u{0E01}\u{0E14}", 50.0, 53.0),
("\u{0E31}\u{0E4C}", 24.0, 41.0),
("\u{0E31}\u{0E34}", 58.0, 41.0),
];
for (i, (text, x, y)) in pieces.iter().enumerate() {
let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(*x, *y), text, style.clone());
let n = Node::new(page.alloc_id(), NodeKind::Text(Box::new(t)));
page.insert(Some(layer_id), 1 + i, n).unwrap();
}
let (d, w, read) = crate::layered_ai(&private(&art), page, vec![], false, false, 1.0);
assert!(read, "{w:?}");
assert_eq!(names(&d), ["Art"], "{w:?}");
assert_eq!(all_texts(&d), [thai], "{w:?}");
assert_eq!(lone_texts(&d.layers[0]).last(), Some(&Some(thai.to_string())), "{w:?}");
}
/// A story of `text` on frame 0 whose layout sets glyphs `advance` wide round the outside of a
/// circle of radius `radius`, the circle's length between their centres `fraction` of an advance.
fn round_story(text: &str, advance: f64, radius: f64, fraction: f64) -> String {
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -1033,11 +1033,12 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
A `.svgz` file copied in a file manager pastes as its art, as a copied `.svg` file does.
SVG export (Save As, Export As, Export for Screens, Asset Export and the clipboard share the writer) writes simple shapes as SVG basic shapes, as the reference app does (an ellipse is an `ellipse` tag until an anchor is added, [forum](https://community.adobe.com/t5/illustrator-discussions/trouble-exporting-shape-with-points-to-svg/td-p/10720325); rectangles come out as `<rect>` and `<polygon>`, [forum](https://community.adobe.com/t5/illustrator-discussions/force-svg-export-using-all-lt-rect-gt/td-p/11282378)): an axis-aligned rectangle as `<rect>`, a circle or ellipse drawn with four standard arcs as `<circle>`/`<ellipse>`, a straight line as `<line>`, and other straight-segment paths as `<polygon>`/`<polyline>`, at the written precision (#4652). A rectangle or ellipse with a dashed stroke, whose dashes would start elsewhere, a turned ellipse, curves and compound paths stay `<path>`. `crates/svg/tests/it/basic_shapes.rs` checks each element, that the picture renders as the canvas does and that the geometry reimports.
SVG import keeps attributes in other applications' XML namespaces (`plotter:pen="2"`) on the objects and layers they belong to, as it keeps their `data-*` attributes: copies, duplicates and `.vectorcraft` files keep them, and SVG export writes them back with each namespace declared once on the root (the rest of #522; `crates/svg/tests/it/foreign_attributes.rs`, `tests/attributes.rs`).
In a `.ai` or EPS opened from its editing data, type has its text object's opacity and blend mode, whether it is made from the file's text document or taken from the page. Where such type has transparency and comes in with other art (the rest of its look, or page type and strokes that go with it), a group of them has it. Where the page draws its type, or the strokes around it, with transparency that is neither its text object's nor its text object's combined with that of the objects around it, the page's type and strokes keep their own instead (`crates/eps/src/import/tests_native.rs`). Where the page draws type of no text object over text objects made from the text document, that type is left out only when, for each piece of it, one of those text objects has the piece's Latin letters and digits in its text, in capitals or not; other such type is kept as the page's other type of no text object is.
In a `.ai` or EPS opened from its editing data, type has its text object's opacity and blend mode, whether it is made from the file's text document or taken from the page. Where such type has transparency and comes in with other art (the rest of its look, or page type and strokes that go with it), a group of them has it. Where the page draws its type, or the strokes around it, with transparency that is neither its text object's nor its text object's combined with that of the objects around it, the page's type and strokes keep their own instead (`crates/eps/src/import/tests_native.rs`). Where the page draws type of no text object over text objects made from the text document, with their words in any case, that type is theirs as the page draws it and is left out; the page's other type of no text object goes only to text objects whose type the text document can't make, or else is left out with a warning.
A `.ai` or EPS opened from its editing data whose text document makes the type of every text object that shows takes all its type from the text document: each text object stays in its own group, frame and layer, the page's type isn't matched with them, a file of more than 5,000 text objects still opens with its layers, and type the page draws differently (in another font) keeps the layers, with a warning. A line break inside a paragraph stays a line break in it (`crates/eps/src/import/tests_native.rs`, `crates/eps/src/import/ate.rs`).
In SVG export without Fewer `<tspan>` Elements, each character of live type that justification or Mojikumi moves (the letter gaps of a Justify All line without word spaces, the gaps between the characters of a justified Japanese line, closed-up punctuation, the space between Japanese and Latin letters) starts its own positioned `<tspan>`, and type scaled horizontally on a justified line is stretched to its own width (`crates/svg/tests/it/text_layout.rs`).
SVG export (live text), legacy `.ai` saves (text objects), DXF export (`TEXT` entities) and the CSS Properties panel write area type that Shrink Text to Fit scales down at the size it draws at, and scale its tracking, kerning, leading and baseline shifts the same way where the format holds them.
A PDF saved with Illustrator's editing data opens through it as a PDF-compatible `.ai` does, with its layers and groups and with art reaching past its page unclipped; such a PDF holds the data in `AIPDFPrivateData` streams (part of #868). A shape a PDF or `.ai` paints with a gradient (a clip path round a shading) opens as one path filled with the gradient, not a clip group round a rectangle, so Shape Builder, Live Paint and Pathfinder work on its outline (#1304).
In a `.ai` opened from its editing data, a text object on a layer that shows and doesn't print stays on that layer, made from the file's text document, even where the page doesn't draw it (`crates/eps/src/import/tests_native.rs`). Such a `.ai` opens without the PDF part's warnings that text was kept as outlines when no outlined glyphs come in, and a warning counts the text objects that hold the page's type next to glyphs the page has only as outlines, which don't come in (`crates/engine/src/cmd/fileio/tests_aiimport.rs`). Where the art of a `.ai` or EPS opened from its editing data differs a little from the file's page, the warning gives the share of the page that differs away from type. When that share is 0.2% or less, the warning names the type and gives the share of the page that differs where the type is (`crates/eps/src/import/tests_native.rs`).
In a `.ai` opened from its editing data, a text object on a layer that shows and doesn't print stays on that layer, made from the file's text document, even where the page doesn't draw it (`crates/eps/src/import/tests_native.rs`). Such a `.ai` opens without the PDF part's warnings that text was kept as outlines when no outlined glyphs come in, and a warning counts the text objects that hold the page's type next to glyphs the page has only as outlines, which don't come in (`crates/engine/src/cmd/fileio/tests_aiimport.rs`). Where the art of a `.ai` or EPS opened from its editing data differs a little from the file's page, the warning gives the share of the page that differs away from type. When that share is 0.2% or less, the warning names the type and gives the share of the page that differs where the type is; when all the type is made from the text document, the type's share has a warning of its own beside the art's (`crates/eps/src/import/tests_native.rs`).
PDF text opens with every glyph where the file draws it (#722): the font's kerning or none, whichever the file followed, word and character spacing, `TJ` adjustments and horizontal scaling kept per character as tracking, text rise as baseline shift within its line, vertical glyphs narrower than an em centred on their column (`/W2`, `/W`, `/DW`), justified text boxes with word spacing back as justified area type (`tests_text_placement`, fixtures written from ISO 32000-1 §9).
A `.ai` or PDF whose editing data holds a variable library opens with a warning that its variables and data sets weren't read, with how many of each it has, whether its layers are read or it comes in as its PDF part (`crates/engine/src/cmd/fileio/tests_aiimport.rs`).
A PDF page whose `UserUnit` (PDF 1.6) is above 1 opens at its size in points, its numbers times the `UserUnit` (one above 75,000 counts as 75,000), and `document.pdfInfo` gives its size and boxes in points too. A `.ai` whose editing data has its artboards in the units of such a page opens from that data at the page's size, with its layers, and when the layers can't be read its pages still take the artboard layout of the data, scaled the same way (part of #1482).