Fix #1387: a .ai whose PDF part is only text opens from it

This commit is contained in:
bflatastic 2026-10-12 19:34:49 +07:00
parent bfb173e967
commit 7f5d8a3db1
8 changed files with 88 additions and 18 deletions

View File

@ -222,7 +222,12 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
.flatten()
.map(|e| (e.intact, move || Some(e.data)));
restore_or_import(editing, || match super::pdfimport::import(bytes, opts) {
Ok((doc, warnings)) => Ok(through_editing_data(bytes, opts, doc, warnings)),
Ok(r) => {
// 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).
let alone = r.only_text.then(|| from_editing_data_alone(bytes, opts)).flatten().and_then(|read| read.ok());
Ok(alone.unwrap_or_else(|| through_editing_data(bytes, opts, r.document, r.warnings)))
}
// Saved without its PDF part: the editing copy is all the file has.
Err(e) if e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string() => {
from_editing_data_alone(bytes, opts).unwrap_or(Err(e))

View File

@ -130,10 +130,9 @@ fn choice<T: Choice + Default>(cmd: &str, key: &str, id: Option<&str>) -> Result
.ok_or_else(|| bad(cmd, format!("{key} must be one of {}", T::IDS.join(", "))))
}
/// Import the pages `o` pick → the document and import notes.
pub(super) fn import(bytes: &[u8], o: &LoadOptions) -> Result<(Document, Vec<String>)> {
let r = vectorcraft_pdf::import_with_report(bytes, &o.import_options(bytes)?).map_err(err)?;
Ok((r.document, r.warnings))
/// Import the pages `o` pick → the document, import notes and what else the import tells.
pub(super) fn import(bytes: &[u8], o: &LoadOptions) -> Result<vectorcraft_pdf::ImportReport> {
vectorcraft_pdf::import_with_report(bytes, &o.import_options(bytes)?).map_err(err)
}
/// Page `page` (0-based) of a PDF as a document of one artboard (its `o.crop` box) and one

View File

@ -154,6 +154,52 @@ fn an_ai_file_saved_without_its_pdf_part_opens_from_its_editing_data() {
assert!(e.contains("placeholder"), "{e}");
}
/// A page of text alone, as text-only art has in its PDF part: no path is painted.
const TEXT_ART: &str = "BT /F1 36 Tf 10 40 Td (Text only) Tj ET";
/// The kinds of what the first layer holds: type (or its outlines, without the font) and others.
fn text_and_others(s: &Session) -> (usize, usize) {
let art = s.doc().unwrap().doc.layers[0].children().unwrap();
let text = art.iter().filter(|n| matches!(n.kind, NodeKind::Text(_)) || n.name.as_deref() == Some("<Text Outlines>")).count();
(text, art.len() - text)
}
#[test]
fn an_ai_file_whose_pdf_part_is_only_text_opens_from_it_when_its_editing_data_cant_be_read() {
// #1387. The page states where its art is (an art, trim or bleed box), which the notice
// page of a file saved without its PDF part has no art to do.
let unreadable = b"%AI24_ZStandard_Data\0\0\0\0\0\0\0\0";
for boxed in ["/ArtBox [0 0 200 100]", "/TrimBox [0 0 200 100]", "/BleedBox [0 0 200 100]"] {
let mut s = Session::new();
let r = open(&mut s, "text.ai", &ai::ai_with(unreadable, TEXT_ART, boxed), json!({})).unwrap();
// Then a note on the font, where the machine lacks it.
assert_eq!(r["warnings"][0], json!(vectorcraft_pdf::OFF_ARTBOARD_NOTE), "{boxed}: {r}");
assert_eq!(text_and_others(&s), (1, 0), "{boxed}");
// The PDF part alone, as asked.
open(&mut s, "text.ai", &ai::ai_with(unreadable, TEXT_ART, boxed), json!({"editingData": false})).unwrap();
assert_eq!(text_and_others(&s), (1, 0), "{boxed}");
}
// The same page without a box is the notice: the file still says why it can't open.
let mut s = Session::new();
let e = open(&mut s, "text.ai", &ai::ai(unreadable, TEXT_ART), json!({})).unwrap_err().to_string();
assert!(e.contains("without PDF compatibility") && e.contains("can't be read"), "{e}");
let e = open(&mut s, "text.ai", &ai::ai(unreadable, TEXT_ART), json!({"editingData": false})).unwrap_err().to_string();
assert!(e.contains("placeholder"), "{e}");
// Placed, it is read as its page too.
let r = s.execute("file.new", &json!({})).and_then(|_| {
let data = vectorcraft_format::base64_encode(&ai::ai_with(unreadable, TEXT_ART, "/ArtBox [0 0 200 100]"));
s.execute("file.place", &json!({"name": "text.ai", "dataBase64": data, "link": false}))
});
assert!(r.is_ok(), "{r:?}");
}
#[test]
fn an_ai_file_whose_pdf_part_is_only_text_still_opens_from_editing_data_that_reads() {
let mut s = Session::new();
open(&mut s, "art.ai", &ai::ai_with(&compressed(&ai::sample_data()), TEXT_ART, "/ArtBox [0 0 200 100]"), json!({})).unwrap();
check_sample(&s);
}
#[test]
fn editing_data_false_opens_only_what_the_page_prints() {
let mut s = Session::new();

View File

@ -196,8 +196,9 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
b.warnings = notes;
let cache = InterpreterCache::new();
let mut x = 0.0;
// Every page only a placeholder (text) over private data: the file's art isn't in its PDF part.
let mut placeholder = true;
// Every page only text over private data: a file saved without its PDF part shows a notice
// there, not its art. A page that states where its art is isn't that notice.
let (mut only_text, mut art_boxes) = (true, false);
let mut slots: Vec<Slot> = vec![];
let mut group_art: HashMap<usize, Vec<Arc<Node>>> = HashMap::new();
let mut nesting = Nesting::new();
@ -266,7 +267,8 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
b.warn(PAST_PAGE_NOTE);
}
let children: Vec<Arc<Node>> = parts.iter().flat_map(|(_, v)| v.iter().cloned()).collect();
placeholder &= ai && only_text(&children);
only_text &= ai && is_only_text(&children);
art_boxes |= crate::pages::states_art(page);
let mut right = ab.x1;
if opts.crop == CropTo::Bounding
&& let Some(art) = vectorcraft_doc::live::nodes_bounds(&children)
@ -308,7 +310,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
}
}
}
if placeholder {
if only_text && !art_boxes {
return Err(PdfError::PlaceholderOnly);
}
// A group the pages mark but draw nothing in is an empty layer, as the editor had it.
@ -380,7 +382,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
}
}
}
Ok(ImportReport { document: doc, warnings, native: crate::editing::editing_in(&pdf) })
Ok(ImportReport { document: doc, warnings, native: crate::editing::editing_in(&pdf), only_text })
}
/// The characters of a CID-keyed CFF font embedded without a ToUnicode map (as macOS writes
@ -604,7 +606,7 @@ fn drop_page_fill(parts: &mut Vec<(Option<usize>, Vec<Arc<Node>>)>, page: Rect)
}
/// Is this art text and nothing else (in groups and clips), with some text?
fn only_text(nodes: &[Arc<Node>]) -> bool {
fn is_only_text(nodes: &[Arc<Node>]) -> bool {
fn walk(nodes: &[Arc<Node>], text: &mut bool) -> bool {
nodes.iter().all(|n| match &n.kind {
NodeKind::Path { clipping: true, .. } => true,

View File

@ -163,6 +163,9 @@ pub struct ImportReport {
pub warnings: Vec<String>,
/// The native document the PDF carries (written with Preserve Editing), if any.
pub native: Option<Editing>,
/// Every page shows only text, over an editor's private data: that data, where it reads,
/// is the file's art (a file saved without its PDF part shows a notice there instead).
pub only_text: bool,
}
/// Result of an export with non-fatal warnings (features approximated or dropped).

View File

@ -197,6 +197,14 @@ pub(crate) fn has_private_data(page: &Page<'_>) -> bool {
piece_info_has(page, &[b"AIPrivateData"])
}
/// Does `page` state where its art is: an art, trim or bleed box (ISO 32000-1 §14.11.2, the
/// extent of the page's meaningful content and of the finished page)? A page written from an
/// artboard has them; the notice a file saved without its PDF part shows instead of its art is
/// the same page whatever the document, with no art to state the place of.
pub(crate) fn states_art(page: &Page<'_>) -> bool {
[&b"ArtBox"[..], b"TrimBox", b"BleedBox"].iter().any(|k| page.raw().get::<[f32; 4]>(*k).is_some())
}
/// Does `page`'s `PieceInfo` hold a key starting with one of `prefixes`, up to four dictionaries in?
fn piece_info_has(page: &Page<'_>, prefixes: &[&[u8]]) -> bool {
fn walk(d: &Dict<'_>, prefixes: &[&[u8]], depth: u32) -> bool {

View File

@ -104,7 +104,12 @@ pub fn eps(data: &[u8], page: &str) -> Vec<u8> {
/// `private` (the private data streams' contents, such as `%AI12_CompressedData` and zlib data) in
/// two streams.
pub fn ai(private: &[u8], page: &str) -> Vec<u8> {
with_private(private, page, "AIPrivateData")
ai_with(private, page, "")
}
/// [`ai`] with more entries in the page dictionary (`/ArtBox [0 0 200 100]`).
pub fn ai_with(private: &[u8], page: &str, entries: &str) -> Vec<u8> {
with_private(private, page, "AIPrivateData", entries)
}
/// A PDF saved with Illustrator's editing data: [`ai`]'s file with the two streams under
@ -112,11 +117,12 @@ pub fn ai(private: &[u8], page: &str) -> Vec<u8> {
/// #868, and nothing else of that file is used; ExifTool's PDF tag table lists them too
/// (<https://exiftool.org/TagNames/PDF.html>).
pub fn pdf(private: &[u8], page: &str) -> Vec<u8> {
with_private(private, page, "AIPDFPrivateData")
with_private(private, page, "AIPDFPrivateData", "")
}
/// The file of [`ai`] and [`pdf`], with `private` in the streams `{key}1` and `{key}2`.
fn with_private(private: &[u8], page: &str, key: &str) -> Vec<u8> {
/// The file of [`ai`] and [`pdf`], with `private` in the streams `{key}1` and `{key}2` and
/// `entries` more in the page dictionary.
fn with_private(private: &[u8], page: &str, key: &str, entries: &str) -> Vec<u8> {
let (first, second) = private.split_at(private.len() / 2);
let stream = |part: &[u8]| {
let mut s = format!("<< /Length {} >>\nstream\n", part.len()).into_bytes();
@ -128,7 +134,7 @@ fn with_private(private: &[u8], page: &str, key: &str) -> Vec<u8> {
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R \
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] {entries} /Contents 4 0 R \
/Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> \
/PieceInfo << /{PIECE} << /Private << /{key}1 5 0 R /{key}2 6 0 R /NumBlock 2 >> >> >> >>"
)

File diff suppressed because one or more lines are too long