Merge pull request #5598 from dotCooCoo/fix/ai-open-skips-pdf-part

PDF/.ai import: a file whose type all comes from its text document opens without its PDF part
This commit is contained in:
BFlat
2026-11-04 20:05:22 +07:00
committed by GitHub
13 changed files with 404 additions and 56 deletions
+74 -39
View File
@@ -165,18 +165,18 @@ pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
/// of its art, which it carries: its layers and hidden objects as they were, and the art outside
/// its artboard (its PDF part has only what is on the artboards), as an EPS of the editor is read.
/// Anything else as it was. `unit`: the size in points of a unit of the PDF part's user space (its
/// first page's `UserUnit`) → (document, warnings, whether its layers were read). `private`: the
/// editor's copy ([`editing_copy`]).
/// first page's `UserUnit`) → (document, warnings, whether its layers were read). `layers`: the
/// editor's copy ([`editing_copy`]), as far as it was decoded and read.
fn through_editing_data(
private: Option<&[u8]>,
layers: Option<vectorcraft_eps::AiLayers>,
opts: &LoadOptions,
doc: Document,
warnings: Vec<String>,
unit: f64,
) -> (Document, Vec<String>, bool) {
let Some(private) = private else { return (doc, warnings, false) };
let Some(layers) = layers else { return (doc, warnings, false) };
let outlined = opts.text_as == vectorcraft_pdf::TextAs::Outlines;
let (doc, mut warnings, read) = vectorcraft_eps::layered_ai(private, doc, warnings, outlined, opts.trust_editing_data, unit);
let (doc, mut warnings, read) = vectorcraft_eps::layered_ai_from(layers, doc, warnings, outlined, opts.trust_editing_data, unit);
// The layers weren't read (or couldn't be): the notes of reading the PDF part still hold.
if !read {
return (doc, warnings, false);
@@ -216,6 +216,14 @@ fn reads_editing_copy(opts: &LoadOptions) -> bool {
opts.pages.is_none() && opts.layers && opts.editing_data
}
/// May a file opened with `opts` open from the editor's own copy of its art without its PDF part
/// being imported ([`vectorcraft_eps::ai_layers_alone`]): it reads the editor's copy, on the pages'
/// trim boxes (the crop box asked for, as a file with the editor's copy opens on them), with its
/// type as text?
fn reads_layers_alone(opts: &LoadOptions) -> bool {
reads_editing_copy(opts) && opts.crop == vectorcraft_pdf::CropTo::Crop && opts.text_as != vectorcraft_pdf::TextAs::Outlines
}
/// The editor's own copy of its art that `opened` carries (as [`vectorcraft_pdf::illustrator_data`]
/// reads it), when `opts` read it ([`reads_editing_copy`]).
fn editing_copy(opened: &vectorcraft_pdf::Opened, opts: &LoadOptions) -> Option<Vec<u8>> {
@@ -365,41 +373,68 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
Ok(o) => super::pdfimport::import(bytes, o, opts, format.id != "pdf"),
Err(e) => Err(err(e)),
};
let read = restore_or_import(editing, || match imported() {
Ok(r) => {
// The editor's copy, read once the import is done with the pages.
let private = opened.as_ref().ok().and_then(|o| editing_copy(o, opts));
// Pages of text alone: the editing copy alone where it reads, as for a file
// saved without its PDF part (#1387: else the page, which is the art). So too
// pages that draw nothing at all: there is no page art to check the layers
// against, and the empty page would lose all of it.
// A page is empty only when nothing on it was skipped (an image that can't be
// decoded draws nothing either): its notes are at most the page's own.
let empty = draws_nothing(&r.document) && r.warnings.iter().all(|w| is_page_note(w));
let alone = (r.only_text || empty)
.then(|| private.as_deref().map(editing_copy_alone))
.flatten()
.and_then(|read| read.ok())
.map(|(doc, warnings)| outlined_if_asked(doc, warnings, opts));
let (doc, warnings, read) = match alone {
Some((doc, warnings)) => (doc, warnings, true),
None => through_editing_data(private.as_deref(), opts, r.document, r.warnings, r.user_unit),
};
editing_data = read;
Ok((doc, warnings))
let read = restore_or_import(editing, || {
// Type all from the text document and nothing that needs the PDF part: the layers
// alone, the PDF part not imported. Else what was read of the layers is kept for
// reading them with the PDF part.
let mut layers = None;
if let Some((o, private)) = opened.as_ref().ok().filter(|_| reads_layers_alone(opts)).and_then(|o| Some((o, editing_copy(o, opts)?)))
{
let pages = o.trim_pages();
match vectorcraft_eps::ai_layers_alone(&private, &pages.sizes, pages.user_unit) {
Ok((mut doc, warnings)) => {
doc.setup.bleed = pages.bleed;
editing_data = true;
return Ok((doc, warnings));
}
Err(read) => layers = Some(read),
}
}
match imported() {
Ok(r) => {
// The editor's copy, read once the import is done with the pages, unless
// its layers were read before it.
let private = layers.is_none().then(|| opened.as_ref().ok().and_then(|o| editing_copy(o, opts))).flatten();
// Pages of text alone: the editing copy alone where it reads, as for a file
// saved without its PDF part (#1387: else the page, which is the art). So too
// pages that draw nothing at all: there is no page art to check the layers
// against, and the empty page would lose all of it.
// A page is empty only when nothing on it was skipped (an image that can't be
// decoded draws nothing either): its notes are at most the page's own.
let empty = draws_nothing(&r.document) && r.warnings.iter().all(|w| is_page_note(w));
let copy_alone = || match &private {
Some(private) => Some(editing_copy_alone(private)),
None if layers.is_some() => opened.as_ref().ok().and_then(|o| editing_copy(o, opts)).map(|p| editing_copy_alone(&p)),
None => None,
};
let alone = (r.only_text || empty)
.then(copy_alone)
.flatten()
.and_then(|read| read.ok())
.map(|(doc, warnings)| outlined_if_asked(doc, warnings, opts));
let (doc, warnings, read) = match alone {
Some((doc, warnings)) => (doc, warnings, true),
None => {
let layers = layers.or_else(|| private.as_deref().map(vectorcraft_eps::AiLayers::decode));
through_editing_data(layers, opts, r.document, r.warnings, r.user_unit)
}
};
editing_data = read;
Ok((doc, warnings))
}
// Saved without its PDF part: the editing copy is all the file has (`pages` that
// name every page read it as the whole file).
Err(e) if is_placeholder(&e) => opened
.as_ref()
.ok()
.and_then(|o| from_editing_data_alone(o, opts))
.unwrap_or(Err(e))
.map(|(doc, warnings)| outlined_if_asked(doc, warnings, opts))
.inspect(|_| editing_data = true),
// A PDF part that can't be read (a stream the import refuses): the editing copy
// alone where it reads, else the error.
Err(e) => without_pdf_part(bytes, opts, &e).inspect(|_| editing_data = true).ok_or(e),
}
// Saved without its PDF part: the editing copy is all the file has (`pages` that
// name every page read it as the whole file).
Err(e) if is_placeholder(&e) => opened
.as_ref()
.ok()
.and_then(|o| from_editing_data_alone(o, opts))
.unwrap_or(Err(e))
.map(|(doc, warnings)| outlined_if_asked(doc, warnings, opts))
.inspect(|_| editing_data = true),
// A PDF part that can't be read (a stream the import refuses): the editing copy
// alone where it reads, else the error.
Err(e) => without_pdf_part(bytes, opts, &e).inspect(|_| editing_data = true).ok_or(e),
})?;
// The File Info of a file read from its pages or its editing copy.
if !read.2 {
+120 -4
View File
@@ -1221,10 +1221,16 @@ fn half_outlined_line(y: f64, rest: &str, mode: u8) -> String {
/// text document (a line starting at (14, 65)), then `more` of a story the text document doesn't
/// have. Without `story` the file has no text document.
fn half_outlined_ai(story: Option<&str>, more: usize, type_content: &str) -> Vec<u8> {
half_outlined_ai_on(story, more, type_content, "", |page| page)
}
/// [`half_outlined_ai`] with the editing data's comments `head` before its art, whose page is
/// `page_of` the 200 × 100 pt page it draws on (other boxes, more entries).
fn half_outlined_ai_on(story: Option<&str>, more: usize, type_content: &str, head: &str, page_of: impl FnOnce(PdfPage) -> PdfPage) -> Vec<u8> {
let text = |story: u32| format!("/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n{story} /StoryIndex ,\n;\n");
let texts: String = std::iter::once(text(0)).chain((0..more).map(|_| text(99))).collect();
let mut body =
format!("%AI3_TemplateBox: 100 50 100 50\n{}", ai::layer("Art", &format!("0.5 g\n{}f\n{texts}", ai::rect(120.0, 20.0, 160.0, 60.0))));
format!("{head}%AI3_TemplateBox: 100 50 100 50\n{}", ai::layer("Art", &format!("0.5 g\n{}f\n{texts}", ai::rect(120.0, 20.0, 160.0, 60.0))));
if let Some(story) = story {
// The story's one line starts at the canvas center (8191.5, the template box's center) moved
// to (14, 65): its y runs down the page.
@@ -1255,7 +1261,7 @@ fn half_outlined_ai(story: Option<&str>, more: usize, type_content: &str) -> Vec
};
let cmap = "begincmap\n1 begincodespacerange\n<00> <FF>\nendcodespacerange\n1 beginbfchar\n<41> <FFFD>\nendbfchar\nendcmap\n";
pdf_with(
&[page],
&[page_of(page)],
&[&format!("<< /Length {} >>\nstream\n{data}\nendstream", data.len()), &format!("<< /Length {} >>\nstream\n{cmap}endstream", cmap.len())],
None,
)
@@ -1287,6 +1293,12 @@ fn texts(s: &Session) -> Vec<String> {
/// (art x 10 to 90 and 110 to 190, y 10 to 90), then a band across the first frame (y 30 to 70),
/// made a wrap object with no offset when `wrap` is set.
fn wrapped_thread_ai(wrap: bool) -> Vec<u8> {
ai::ai(&wrapped_thread_data(wrap.then_some(0.0)), PLACEHOLDER)
}
/// The editing data of [`wrapped_thread_ai`], compressed, with the band a wrap object of `offset`
/// when one is given.
fn wrapped_thread_data(offset: Option<f64>) -> Vec<u8> {
// The template box is centred at (100, 50), the canvas centre 8191.5: y runs down the canvas.
let canvas = |x: f64, y: f64| (x + 8091.5, 8241.5 - y);
let rect = |x0: f64, y0: f64, x1: f64, y1: f64| {
@@ -1306,14 +1318,47 @@ fn wrapped_thread_ai(wrap: bool) -> Vec<u8> {
);
let lines: Vec<String> = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
let frame = |f: u32| format!("/AI11Text :\n0 /FreeUndo ,\n{f} /FrameIndex ,\n0 /StoryIndex ,\n;\n");
let dict = if wrap { "%_/ArtDictionary :\n%_0 /Real (AI11TextWrapOffset) ,\n%_0 /Bool (AI11TextWrapInvert) ,\n%_;\n%_\n" } else { "" };
let dict = offset
.map(|o| format!("%_/ArtDictionary :\n%_{o} /Real (AI11TextWrapOffset) ,\n%_0 /Bool (AI11TextWrapInvert) ,\n%_;\n%_\n"))
.unwrap_or_default();
let art = format!("{}{}0.5 g\n{}f\n{dict}", frame(0), frame(1), ai::rect(10.0, 30.0, 90.0, 70.0));
let body = format!(
"%AI3_TemplateBox: 100 50 100 50\n{}%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n",
ai::layer("Art", &art),
lines.join("\n")
);
ai::ai(&compressed(ai::editing_data(200.0, 100.0, &body).as_bytes()), PLACEHOLDER)
compressed(ai::editing_data(200.0, 100.0, &body).as_bytes())
}
/// The wrap offset of the first frame of `doc`'s threaded type, as its text resolves it.
fn first_frame_wrap_offset(doc: &vectorcraft_doc::Document) -> f64 {
match &doc.node(doc.text_threads[0][0]).unwrap().kind {
NodeKind::Text(t) => t.wrap[0].wrap.offset,
_ => panic!("area type"),
}
}
/// A `.ai` in units of 10 points whose threaded area type flows around a wrap object of a 2 unit
/// offset opens with the type flowing round it 20 pt off, its PDF part read or not, and when its
/// PDF part can't be read. The default graphic styles of a file without its own aren't scaled.
#[test]
fn wrapped_type_in_units_of_ten_points_flows_round_the_wrap_at_its_size_in_points() {
let private = wrapped_thread_data(Some(2.0));
let page = "0.5 g 10 30 80 40 re f";
let refused = ai::ai_with_entries_and_content(&private, page, "/UserUnit 10", REFUSED_CONTENT);
// On the trim boxes the PDF part is imported, and the refused stream stops it.
let trim = LoadOptions { crop: vectorcraft_pdf::CropTo::Trim, ..Default::default() };
let files = [(ai::ai_with(&private, page, "/UserUnit 10"), LoadOptions::default()), (refused, trim)];
for (file, opts) in &files {
let loaded = load_with("thread.ai", file, opts).unwrap();
assert!(loaded.editing_data, "{:?}", loaded.warnings);
assert!((first_frame_wrap_offset(&loaded.doc) - 20.0).abs() < 0.01, "{:?}", loaded.warnings);
}
let loaded = load_with("thread.ai", &files[1].0, &files[1].1).unwrap();
assert!(loaded.warnings.iter().any(|w| w.starts_with("the PDF part couldn't be read")), "{:?}", loaded.warnings);
// The file has no graphic styles of its own: the default ones aren't scaled either way.
let read = load_with("thread.ai", &files[0].0, &files[0].1).unwrap();
assert_eq!(loaded.doc.graphic_styles, read.doc.graphic_styles);
}
/// Threaded area type below a wrap object flows around it when the file opens: the text the wrap
@@ -1358,6 +1403,77 @@ fn type_from_the_text_document_opens_without_the_pdf_parts_note_on_outlines() {
assert!(!r["warnings"].as_array().unwrap().contains(&without_outlines_note(1)), "{r}");
}
/// A `.ai` whose text document makes all of its type, with nothing unread, opens from its layers
/// without its PDF part being imported: a page that draws more than the layers isn't compared with
/// them. On its trim box it takes the pages' bleed, and pages in units of 10 points draw it ten
/// times as large, as when its PDF part is imported. With type the text document can't make,
/// editing data on a large canvas, its type asked for as outlines, another box or pages picked, the
/// PDF part is imported as before.
#[test]
fn type_all_from_the_text_document_opens_without_importing_the_pdf_part() {
let line = half_outlined_line(65.0, "rst draft", 0);
// The page draws a red square the layers don't have.
let differs = half_outlined_ai(Some("first draft"), 0, &format!("{line} 1 0 0 rg 20 20 30 30 re f"));
let mut s = Session::new();
let before = vectorcraft_pdf::imports_of(&differs);
let loaded = load_with("draft.ai", &differs, &LoadOptions::default()).unwrap();
assert_eq!(vectorcraft_pdf::imports_of(&differs), before, "{:?}", loaded.warnings);
assert!(loaded.editing_data, "{:?}", loaded.warnings);
assert!(!loaded.warnings.iter().any(|w| w.contains("differently")), "{:?}", loaded.warnings);
let r = open(&mut s, "draft.ai", &differs, json!({})).unwrap();
assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "{r}");
assert_eq!(texts(&s), ["first draft"], "{r}");
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2, "the square and the type, without the page's red square: {r}");
// A 10 pt bleed round the trim box, the art drawn in the trim box.
let bled = half_outlined_ai_on(Some("first draft"), 0, &line, "", |p| PdfPage {
media: [0.0, 0.0, 220.0, 120.0],
bleed: Some([0.0, 0.0, 220.0, 120.0]),
trim: Some([10.0, 10.0, 210.0, 110.0]),
content: format!("1 0 0 1 10 10 cm {}", p.content),
..p
});
let before = vectorcraft_pdf::imports_of(&bled);
let loaded = load_with("bled.ai", &bled, &LoadOptions::default()).unwrap();
assert_eq!(vectorcraft_pdf::imports_of(&bled), before);
assert_eq!(loaded.doc.setup.bleed, [10.0; 4]);
// Pages of 10 pt units.
let large = half_outlined_ai_on(Some("first draft"), 0, &line, "", |p| PdfPage { entries: format!("{} /UserUnit 10", p.entries), ..p });
let before = vectorcraft_pdf::imports_of(&large);
let loaded = load_with("large.ai", &large, &LoadOptions::default()).unwrap();
assert_eq!(vectorcraft_pdf::imports_of(&large), before);
let board = loaded.doc.artboards[0].rect;
assert!((board.width() - 2000.0).abs() < 0.01 && (board.height() - 1000.0).abs() < 0.01, "{board:?}");
// Opened on the trim boxes with the PDF part imported and compared, as before, both files have
// the same artboards and bleed.
for file in [&bled, &large] {
let alone = load_with("draft.ai", file, &LoadOptions::default()).unwrap();
let compared = load_with("draft.ai", file, &LoadOptions { crop: vectorcraft_pdf::CropTo::Trim, ..Default::default() }).unwrap();
assert!(compared.editing_data, "{:?}", compared.warnings);
let boards = |d: &vectorcraft_doc::Document| d.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
assert_eq!(boards(&alone.doc), boards(&compared.doc));
assert_eq!(alone.doc.setup.bleed, compared.doc.setup.bleed);
assert_eq!(names(&alone.doc.layers), names(&compared.doc.layers));
// The file has no graphic styles of its own: the default ones aren't scaled.
assert_eq!(alone.doc.graphic_styles, compared.doc.graphic_styles);
}
// The PDF part is imported, once.
let unmade = half_outlined_ai(Some("first draft"), 1, &line);
// Editing data on a large canvas reads at its own scale when the file has no PDF part.
let canvas = half_outlined_ai_on(Some("first draft"), 0, &line, "%AI24_LargeCanvasScale: 2\n", |p| p);
let cases = [
(&unmade, LoadOptions::default()),
(&canvas, LoadOptions::default()),
(&differs, LoadOptions { text_as: vectorcraft_pdf::TextAs::Outlines, ..Default::default() }),
(&differs, LoadOptions { crop: vectorcraft_pdf::CropTo::Trim, ..Default::default() }),
(&differs, LoadOptions { pages: Some("1".into()), ..Default::default() }),
];
for (file, opts) in cases {
let before = vectorcraft_pdf::imports_of(file);
load_with("draft.ai", file, &opts).unwrap();
assert_eq!(vectorcraft_pdf::imports_of(file) - before, 1, "{opts:?}");
}
}
/// A text object that takes the page's type lacks the glyphs next to it that the page kept as
/// outlines: a note counts such objects, in place of the PDF part's notes. Stroked glyphs that
/// come in with the stroked type keep the PDF part's notes.
+1 -1
View File
@@ -71,7 +71,7 @@ use interp::{Fault, Interp};
use obj::PsError;
pub(crate) use native::scale_document;
pub use native::{ai_alone, ai_alone_on_page, is_loss, layered_ai};
pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, is_loss, layered_ai, layered_ai_from};
pub use text::family_style;
/// The page of a PostScript file without a bounding box (US Letter).
+4 -2
View File
@@ -23,7 +23,9 @@
//! before; on a layer that doesn't show it is left out of that layer, with a warning. The page is
//! also the safety net: when the layers' art looks different from it (one draws an object the
//! other doesn't, or much of it differs; an EPS whose page is its art's box has nothing printed
//! outside it), the page is used.
//! outside it), the page is used. A `.ai` whose text document makes all of its type, with no
//! object unread and nothing else that the page would decide, opens from its layers without its
//! PDF part being imported, so nothing is compared ([`ai_layers_alone`]).
//!
//! Where a file doesn't match what this module expects, it is left alone and its page is imported.
@@ -52,7 +54,7 @@ use build::{Frame, MADE, prune_unseen};
use compare::{
MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, PageView, Shares, compare, compared, comparison_render, comparison_samples, differ, differ_in_colour,
};
pub use layers::{ai_alone, ai_alone_on_page, layered_ai};
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 use slots::is_loss;
#[cfg(test)]
+86 -2
View File
@@ -76,14 +76,98 @@ pub(super) fn build_on_page(data: &[u8], page: (f64, f64), unit: f64) -> Result<
};
let in_units = |r: Option<Rect>, k: f64| k.is_finite() && k > 1.0 && fits(r, k) && !fits(r, 1.0);
let scale = [unit, structure.canvas_scale].into_iter().find(|&k| in_units(board, k) || (!fits(board, 1.0) && in_units(art, k)));
let styles = structure.styles_read;
let (mut doc, notes) = build_from(structure, data, None, Page::Artboard, false, false, 1.0)?;
if let Some(k) = scale {
// The graphic styles here are all the file's, drawn in its units too.
scale_document(&mut doc, k, true);
// The graphic styles read from the file are drawn in its units too.
scale_document(&mut doc, k, styles);
relayout_wrapped(&mut doc);
}
Ok((doc, notes))
}
/// A document read from the editing copy and its notes, or why it can't be read.
pub(super) type Built = Result<(Document, Vec<String>), String>;
/// [`build`] of a `.ai` without importing its PDF part, whose pages (each its trim box in points,
/// width and height) are `pages` and are in units of `unit` points: the document read from
/// `structure` (the editing copy `data` read) with nothing compared with the pages, when the file's
/// text document makes the type of every text object (at least one shows, and none is type that a
/// look draws), no object is unread or has another placement or drawn art that the page would
/// choose, and its artboards are the pages, one for one and of their size, in points or in the pages'
/// units ([`page_scale_alone`]). The document is drawn at that scale. Else `Err(structure)`, for
/// [`build_from`] with the page.
pub(super) fn build_without_page(structure: Structure, data: &[u8], pages: &[(f64, f64)], unit: f64) -> Result<Built, Box<Structure>> {
let Some(scale) = page_scale_alone(&structure, pages, unit).filter(|_| nothing_waits_on_page(&structure) && type_all_made(&structure, data))
else {
return Err(Box::new(structure));
};
let styles = structure.styles_read;
let built = build_from(structure, data, None, Page::Artboard, false, false, 1.0).map(|(mut doc, notes)| {
if scale != 1.0 {
// The graphic styles read from the file are drawn in its units too.
scale_document(&mut doc, scale, styles);
relayout_wrapped(&mut doc);
}
(doc, notes)
});
Ok(built)
}
/// Is nothing in `structure` waiting on the page: no object unread (the page's art takes its
/// place), no placed file with another placement, no live envelope with the drawn art it replaced
/// (the comparison with the page chooses between them)?
fn nothing_waits_on_page(structure: &Structure) -> bool {
structure.unread.is_empty()
&& structure.unread_elsewhere.is_empty()
&& structure.placed_elsewhere.is_empty()
&& structure.envelopes_drawn.is_empty()
}
/// Does the text document of `data` (read as `structure`) make the type of every text object, those
/// that don't show included, with at least one that shows, and none of them type that a look draws?
fn type_all_made(structure: &Structure, data: &[u8]) -> bool {
let mut infos = vec![];
collect_slots(&structure.doc.layers, true, &mut infos);
if !infos.iter().any(|i| i.shown) || infos.iter().any(|i| i.look.is_some()) {
return false;
}
let frames = ai::text_frames(data);
for info in &mut infos {
info.art_path = info.story.and_then(|s| frames.get(&(s, info.frame))).map(|p| PathData::from_bezpath(&(structure.to_doc * p.clone())));
}
let Some(texts) = Texts::read(data) else { return false };
let mut read: std::collections::BTreeMap<u32, Option<Arc<Story>>> = Default::default();
let stories: Vec<Option<Arc<Story>>> = infos
.iter()
.map(|i| {
let n = i.story?;
read.entry(n).or_insert_with(|| texts.story(n as usize).map(Arc::new)).clone()
})
.collect();
// A slot without its story is made from nothing; past the text `build_from` reads, nothing is
// made either: it refuses the file.
if stories.iter().any(Option::is_none) || stories.iter().flatten().map(|s| s.len()).sum::<usize>() > MAX_TEXT {
return false;
}
let template = structure.template.map(|t| ((t[0] + t[2]) / 2.0, (t[1] + t[3]) / 2.0));
make_slots(&infos, &stories, template, structure.to_doc, &mut vec![]).iter().all(Option::is_some)
}
/// The scale at which the artboards of `structure` are the pages `pages` (each its width and height
/// in points), one for one and in order, each within half a point: 1 when they are in points, else
/// `unit` (the pages' `UserUnit`) when they are in those units, as [`page_scale`] finds it with the
/// page. `None` when they aren't the pages, and for a file that declares a large canvas scale (a
/// `.ai` saved without its PDF part reads one at that scale, [`super::ai_alone`]).
fn page_scale_alone(structure: &Structure, pages: &[(f64, f64)], unit: f64) -> Option<f64> {
let boards = &structure.doc.artboards;
if boards.is_empty() || boards.len() != pages.len() || structure.canvas_scale != 1.0 {
return None;
}
let fits = |k: f64| boards.iter().zip(pages).all(|(b, (w, h))| (b.rect.width() - w / k).abs() <= 0.5 && (b.rect.height() - h / k).abs() <= 0.5);
[1.0, unit].into_iter().find(|&k| k.is_finite() && k >= 1.0 && fits(k))
}
/// [`build`] from `structure`, the editing copy `data` already read.
pub(super) fn build_from(
structure: Structure,
+64 -3
View File
@@ -1,4 +1,4 @@
use super::build::{PAGE_MOVE, Page, build, build_from, build_on_page};
use super::build::{PAGE_MOVE, Page, build, build_from, build_on_page, build_without_page};
use super::slots::LAYERS_UNREAD;
use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect, Vec2, ai};
@@ -110,11 +110,24 @@ fn layer_tree(nodes: &[Arc<Node>]) -> Vec<Arc<Node>> {
/// units comes in `unit` times as large, and so does the artboard layout `place_pages` takes from
/// it.
pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outlined: bool, trust: bool, unit: f64) -> (Document, Vec<String>, bool) {
layered_ai_from(AiLayers::decode(private), visible, warnings, outlined, trust, unit)
}
/// [`layered_ai`] of the editing copy `layers`, as far as it was already decoded and read.
pub fn layered_ai_from(
layers: AiLayers,
visible: Document,
warnings: Vec<String>,
outlined: bool,
trust: bool,
unit: f64,
) -> (Document, Vec<String>, bool) {
let unread = |mut all: Vec<String>, why: &str| {
all.push(format!("{LAYERS_UNREAD} from its editing data ({why}): they come from its PDF part"));
all
};
let data = match ai::decode_private(private) {
let AiLayers { data, structure } = layers;
let data = match data {
Ok(data) => data,
Err(why) => return (visible, unread(warnings, &why), false),
};
@@ -137,7 +150,10 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outl
use_variables(&data, &mut visible, &mut all);
(visible, all, false)
};
match build(&data, Some(&visible), Page::Artboard, outlined, trust, unit) {
let built = structure
.unwrap_or_else(|| ai::read(&data, false).map(Box::new))
.and_then(|structure| build_from(*structure, &data, Some(&visible), Page::Artboard, outlined, trust, unit));
match built {
// An image too large for the editing data's reader would be missing from the layers, while
// the PDF part has it: the file comes in as its PDF part instead (#1479).
Ok((_, notes)) if notes.iter().any(|n| n == ai::IMAGE_TOO_LARGE) => {
@@ -153,6 +169,51 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outl
}
}
/// The `.ai` whose editing copy is `private` (as [`layered_ai`] takes it) from its layers alone,
/// without its PDF part, whose pages (each its trim box in points, width and height) are `pages`
/// and are in units of `unit` points (its first page's `UserUnit`): the document and the notes of
/// reading it, when the file's text document makes all of its type and nothing in its layers needs
/// the PDF part (see `build_without_page`). Nothing is compared with the PDF part. Else, as the
/// error, what was decoded and read of the editing copy, for [`layered_ai_from`] once the PDF part
/// is imported.
pub fn ai_layers_alone(private: &[u8], pages: &[(f64, f64)], unit: f64) -> Result<(Document, Vec<String>), AiLayers> {
let data = match ai::decode_private(private) {
Ok(data) => data,
Err(why) => return Err(AiLayers { data: Err(why), structure: None }),
};
if !has_layers(&data) {
return Err(AiLayers { data: Ok(data), structure: None });
}
let structure = match ai::read(&data, false) {
Ok(structure) => structure,
Err(why) => return Err(AiLayers { data: Ok(data), structure: Some(Err(why)) }),
};
match build_without_page(structure, &data, pages, unit) {
// An image too large for the editing data's reader is missing from the layers: the PDF
// part has it (as in `layered_ai`).
Ok(Ok((doc, notes))) if !notes.iter().any(|n| n == ai::IMAGE_TOO_LARGE) => Ok((doc, notes)),
Ok(_) => Err(AiLayers { data: Ok(data), structure: None }),
Err(structure) => Err(AiLayers { data: Ok(data), structure: Some(Ok(structure)) }),
}
}
/// A `.ai`'s editing copy decoded, and its layers read when they were, for [`layered_ai_from`]:
/// what [`ai_layers_alone`] read isn't decoded or read again.
pub struct AiLayers {
/// The editing copy decoded, or why it can't be.
data: Result<Vec<u8>, String>,
/// Its layers read with [`ai::read`] as [`layered_ai`] reads them, or why they can't be; `None`
/// when they are still to be read.
structure: Option<Result<Box<ai::Structure>, String>>,
}
impl AiLayers {
/// The editing copy `private` (as [`layered_ai`] takes it), decoded.
pub fn decode(private: &[u8]) -> Self {
Self { data: ai::decode_private(private), structure: None }
}
}
/// `doc` (a file read as its page or PDF part, in place of its layers) with the variables, data
/// sets, active data set and lock of the variable library of its editing data `data`, as [`build`]
/// gives a document read from the layers, and the note about what of the library wasn't read
+2 -1
View File
@@ -44,7 +44,8 @@ mod tiff;
pub use ai_editing::{AiEditingOptions, AiEditingOutput, CREATOR_VERSION, export_ai_editing, private_data as ai_private_data};
pub use import::{
Imported, POSTSCRIPT_ERRORS, ai_alone, ai_alone_on_page, family_style, import, import_trusting, import_with, is_loss, layered_ai, page,
AiLayers, Imported, POSTSCRIPT_ERRORS, ai_alone, ai_alone_on_page, ai_layers_alone, family_style, import, import_trusting, import_with, is_loss,
layered_ai, layered_ai_from, page,
};
pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai};
pub use print::{PrintJob, PrintPage, print};
+1 -1
View File
@@ -9,7 +9,7 @@ description = "VectorCraft PDF export and import"
workspace = true
[features]
# `vectorcraft_pdf::reads_of` for other crates' tests (dev-dependencies only).
# `vectorcraft_pdf::reads_of` and `imports_of` for other crates' tests (dev-dependencies only).
read-count = []
[dependencies]
+2
View File
@@ -38,6 +38,8 @@ mod state;
#[cfg(test)]
pub(crate) use cid::{CidText, font_chars, identify_glyph, unify_radical};
#[cfg(any(test, feature = "read-count"))]
pub use entry::imports_of;
pub use entry::{
INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, holds_outlines,
import, import_opened, import_with_report, is_glyph_outlines, is_outlines_note,
+15
View File
@@ -221,6 +221,16 @@ fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_p
read_opened(bytes, opts, reader, &original)
}
/// How many times each buffer's pages have been imported, by its address (tests).
#[cfg(any(test, feature = "read-count"))]
static IMPORTS: std::sync::Mutex<std::collections::BTreeMap<usize, usize>> = std::sync::Mutex::new(std::collections::BTreeMap::new());
/// How many times the pages of `bytes`, at its address, have been imported on any thread (tests).
#[cfg(any(test, feature = "read-count"))]
pub fn imports_of(bytes: &[u8]) -> usize {
IMPORTS.lock().ok().and_then(|i| i.get(&(bytes.as_ptr() as usize)).copied()).unwrap_or(0)
}
/// [`read_with_reader_options`] of `original`, the PDF `bytes` read with `reader`.
fn read_opened(
bytes: &[u8],
@@ -228,6 +238,11 @@ fn read_opened(
reader: &craft_pdf::OpenOptions,
original: &craft_pdf::syntax::Pdf,
) -> Result<ImportReport, PdfError> {
#[cfg(any(test, feature = "read-count"))]
if let Ok(mut imports) = IMPORTS.lock() {
let n = imports.entry(bytes.as_ptr() as usize).or_default();
*n = n.saturating_add(1);
}
let picked = crate::pages::picked(opts, original.pages().len())?;
let mut ocgs = Ocgs::read(original);
let route = opts.layers && !ocgs.list.is_empty();
+4 -2
View File
@@ -76,6 +76,8 @@ pub use editing::{EDITING_FILE, Editing, LEGACY_EDITING_FILE, editing, editing_w
pub use encrypt::Encryption;
pub use export::{export, export_with_report, page_areas};
pub use file_info::{FileInfo, file_info};
#[cfg(any(test, feature = "read-count"))]
pub use import::imports_of;
pub use import::{
INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, holds_outlines,
import, import_opened, import_with_report, is_glyph_outlines, is_outlines_note,
@@ -83,8 +85,8 @@ pub use import::{
#[cfg(any(test, feature = "read-count"))]
pub use pages::reads_of;
pub use pages::{
IllustratorData, Opened, PageInfo, PdfInfo, has_illustrator_data, illustrator_data, illustrator_data_without_pdf_part, info, is_postscript,
open_pdf,
IllustratorData, Opened, PageInfo, PdfInfo, TrimPages, has_illustrator_data, illustrator_data, illustrator_data_without_pdf_part, info,
is_postscript, open_pdf,
};
pub use piece_info::{PRIVATE_CHUNK, PrivateData};
pub use post::{THUMBNAIL_SIZE, Thumbnail};
+30
View File
@@ -280,6 +280,36 @@ impl Opened {
pub fn page_count(&self) -> usize {
self.pdf.pages().len()
}
/// The pages' trim boxes and bleed, as an import of every page on its trim box takes them,
/// without importing any page's art.
pub fn trim_pages(&self) -> TrimPages {
let pages = self.pdf.pages();
let mut bleed = [0.0; 4];
for page in pages.iter() {
for (side, b) in bleed.iter_mut().zip(self::bleed(page)) {
*side = f64::max(*side, b);
}
}
TrimPages {
sizes: pages.iter().map(|page| frame(page, CropTo::Trim).1).map(|r| (r.width(), r.height())).collect(),
user_unit: pages.first().map_or(1.0, |page| f64::from(page.user_unit())),
bleed,
}
}
}
/// A PDF's pages as an import on their trim boxes sizes them ([`Opened::trim_pages`]).
#[derive(Clone, Debug, PartialEq)]
pub struct TrimPages {
/// Each page's trim box (its crop box when it has none) in points, rotated as the page shows:
/// width and height.
pub sizes: Vec<(f64, f64)>,
/// The size in points of a unit of the first page's user space (its `UserUnit`).
pub user_unit: f64,
/// The bleed of the pages, `[top, bottom, left, right]` in points: on each side, the widest
/// of the pages' bleed, as the import makes it the document's bleed.
pub bleed: [f64; 4],
}
/// Preserve the application's existing accepted file sizes. Ordinary stream decoding still
File diff suppressed because one or more lines are too long