From 2d2ac65b2c44c155ba764b7c95ecf817859bf17b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 21:30:06 -0800 Subject: [PATCH 1/2] Place: DCS links reopen with their plates under the low-resolution EPS proxy With the lowResProxyEps preference on, opening a document kept the saved preview of a linked DCS file, so the link showed as EPS without its plates. Opening now reads the first 1 MiB of each such EPS file and reads a DCS file in full. The whole file is read only when a binary EPS header puts the PostScript past those bytes or the header comments run past them. Other EPS files keep their preview. --- crates/engine/src/cmd/links.rs | 23 ++++++++++++ crates/engine/src/cmd/place/dcs.rs | 40 ++++++++++++++++++++ crates/engine/src/tests/dcs.rs | 59 ++++++++++++++++++++++++++++++ docs/mcp.md | 3 +- 4 files changed, 124 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 720a906462..3d07f07992 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -387,6 +387,26 @@ fn keep_eps_preview(low_res_proxy_eps: bool, g: &Group, d: &Document) -> bool { low_res_proxy_eps && g.document.is_none() && linked_eps(&g.link) && previewing(d, &g.key) } +/// Is the linked file `f` found a DCS file? Only the start of the file is read, unless a binary EPS +/// header puts its PostScript further on; a DCS file read in full stays in `f`. A file that can't +/// be read is not one. +fn is_dcs_file(f: &mut Found) -> bool { + use super::place::dcs; + if let Some(b) = &f.bytes { + return dcs::is_dcs(b); + } + let Ok(head) = fileio::read_file_head(&f.path, dcs::HEAD_BYTES) else { return false }; + if let Some(is) = dcs::head_is_dcs(&head) { + return is; + } + let Ok(b) = f.read() else { return false }; + let is = dcs::is_dcs(&b); + if is { + f.bytes = Some(b); + } + is +} + /// Does group `g` of `d` show something other than its file: an image its preview, a placed /// document nothing (its file's bytes are gone)? fn stale(d: &Document, g: &Group) -> bool { @@ -1159,6 +1179,9 @@ pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps } (Status::Ok, Some(mut f)) => { let preview = previewing(d, &g.key); + // A DCS file is read in full even with the proxy: its plates print and separate on + // their own inks. + let eps_preview = eps_preview && !is_dcs_file(&mut f); if let Some((blob, hash)) = read_images.get(&f.path).filter(|_| preview && !eps_preview).cloned() { store_image(d, &g.key, blob, true); let link = f.link_hashed(hash, &g.link); diff --git a/crates/engine/src/cmd/place/dcs.rs b/crates/engine/src/cmd/place/dcs.rs index b540221a23..bd588eaa11 100644 --- a/crates/engine/src/cmd/place/dcs.rs +++ b/crates/engine/src/cmd/place/dcs.rs @@ -39,6 +39,46 @@ pub(crate) fn is_dcs(bytes: &[u8]) -> bool { vectorcraft_eps::dcs::read(bytes).is_some() } +/// The most bytes read from the start of a file to look at its DCS header. +pub(crate) const HEAD_BYTES: usize = 1 << 20; + +/// Does the header of the PostScript in file `head` (the first bytes of a file, at most +/// [`HEAD_BYTES`]) name DCS plates? `None` when `head` can't tell: that PostScript doesn't start +/// within it (a binary EPS header that puts it further on), or its header goes on past it. +pub(crate) fn head_is_dcs(head: &[u8]) -> Option { + const DOS_MAGIC: [u8; 4] = [0xC5, 0xD0, 0xD3, 0xC6]; + let (ps, whole) = if head.starts_with(&DOS_MAGIC) { + let word = |at: usize| head.get(at..at + 4).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(|b| u32::from_le_bytes(b) as usize); + let (start, len) = (word(4)?, word(8)?); + let ps = head.get(start..).filter(|ps| !ps.is_empty())?; + (ps.get(..len).unwrap_or(ps), start.checked_add(len).is_some_and(|end| end <= head.len())) + } else { + (head, head.len() < HEAD_BYTES) + }; + if is_dcs(ps) { + return Some(true); + } + (whole || header_ended(ps)).then_some(false) +} + +/// Does `ps`, the start of some PostScript, hold all of the header [`vectorcraft_eps::dcs::read`] +/// looks at (up to `%%EndComments`, a line that isn't a comment, or its 10,000th line)? Its last +/// line may be cut off, so it doesn't count. +fn header_ended(ps: &[u8]) -> bool { + let mut lines = ps.split(|b| matches!(b, b'\n' | b'\r')).filter(|l| !l.is_empty()).peekable(); + let mut n = 0; + while let Some(line) = lines.next() { + if lines.peek().is_none() { + return false; + } + n += 1; + if line.starts_with(b"%%EndComments") || !line.starts_with(b"%") || n >= 10_000 { + return true; + } + } + false +} + /// DCS file `bytes`, read from `path` (when there is one: plate files of their own are found in its /// folder), with every plate. pub(crate) fn read(bytes: &[u8], path: Option<&str>) -> Result { diff --git a/crates/engine/src/tests/dcs.rs b/crates/engine/src/tests/dcs.rs index 2801432a0a..0e6410585c 100644 --- a/crates/engine/src/tests/dcs.rs +++ b/crates/engine/src/tests/dcs.rs @@ -632,6 +632,65 @@ fn a_saved_document_reads_the_plates_again_and_prints_the_spot_ink() { assert_eq!(inks, ["Cyan", "Magenta", "Yellow", "Black", "PANTONE 185 C"]); } +#[test] +fn a_saved_document_reads_the_plates_again_with_the_low_resolution_eps_proxy() { + let dir = temp_dir("saved-proxy"); + // Plates wider than a preview, so the saved document keeps a preview of the image. + let wide = |gray: u8| { + let hex = format!("{gray:02x}").repeat(300); + format!( + "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EndComments\n20 10 scale\n300 1 8 [300 0 0 -1 0 1] <{hex}> image\nshowpage\n%%Trailer\n%%EOF\n" + ) + }; + let file = single_file(&[("Cyan", wide(0)), ("PANTONE 185 C", wide(128))], "%%CMYKCustomColor: 0 .91 .76 0 (PANTONE 185 C)\n"); + let path = write(&dir, "pic.eps", &file); + let saved = dir.join("doc.vectorcraft").to_string_lossy().into_owned(); + let mut s = session(); + s.execute("file.place", &json!({ "path": path })).unwrap(); + s.execute("document.save", &json!({ "path": saved })).unwrap(); + let mut s = Session::new(); + s.execute("prefs.set", &json!({"key": "lowResProxyEps", "value": true})).unwrap(); + s.execute("document.open", &json!({ "path": saved })).unwrap(); + let list = s.execute("links.list", &json!({})).unwrap(); + assert_eq!(list["links"][0]["format"], "DCS", "{list}"); + let id = NodeId(list["links"][0]["id"].as_u64().unwrap()); + let NodeKind::Image(im) = &s.doc().unwrap().doc.node(id).unwrap().kind else { not_an_image() }; + assert!(s.doc().unwrap().doc.images[&im.key].separated().is_some(), "the plates, read from the file again"); +} + +#[test] +fn a_dcs_header_is_found_at_the_start_of_a_file_or_behind_a_binary_eps_header() { + use crate::cmd::place::dcs::head_is_dcs; + let ps = two_plates(); + assert_eq!(head_is_dcs(&ps), Some(true)); + assert_eq!(head_is_dcs(b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EndComments\n"), Some(false)); + let binary = |start: u32| { + let mut b = vec![0xC5, 0xD0, 0xD3, 0xC6]; + b.extend(start.to_le_bytes()); + b.extend((ps.len() as u32).to_le_bytes()); + b.resize(start as usize, 0); + b.extend(&ps); + b + }; + assert_eq!(head_is_dcs(&binary(30)), Some(true)); + // PostScript whose length runs past the bytes read: its header is read from the part there is. + let mut cut = binary(30); + cut[8..12].copy_from_slice(&(64u32 << 20).to_le_bytes()); + assert_eq!(head_is_dcs(&cut), Some(true)); + // PostScript that starts past the bytes read: the whole file is needed. + assert_eq!(head_is_dcs(&binary(64)[..40]), None); + // A header that goes on past the bytes read may name its plates further on. + let mut long = b"%!PS-Adobe-3.0 EPSF-3.0\n".to_vec(); + while long.len() < crate::cmd::place::dcs::HEAD_BYTES { + long.extend(format!("%%Title: {}\n", "x".repeat(190)).as_bytes()); + } + long.truncate(crate::cmd::place::dcs::HEAD_BYTES); + assert_eq!(head_is_dcs(&long), None); + // One that ended within them names none. + long[30..45].copy_from_slice(b"\n%%EndComments\n"); + assert_eq!(head_is_dcs(&long), Some(false)); +} + #[test] fn dcs_transparency_marks_transparent_and_overlapped_dcs_images() { let dir = temp_dir("transparency"); diff --git a/docs/mcp.md b/docs/mcp.md index 20e37222c6..c64af2734d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5972,7 +5972,8 @@ stays as a plain group of the image, with its id, name, visibility and transpare even-odd fill rule stays as it is). The link shows as modified when a plate file of its own changes or is gone, as it does when the DCS file changes, and the app's link watch acts on a changed plate file as on a changed DCS file. `links.relink {copy: true}` fails for a DCS file that reads plate files of its own, and copies -nothing. These rules are VectorCraft's own: the screen, composite print and exports show the image as its plates +nothing. Opening a document reads a DCS file and its plates again even with the `lowResProxyEps` preference, which +keeps the saved preview of other linked EPS files. These rules are VectorCraft's own: the screen, composite print and exports show the image as its plates mixed into CMYK (each ink amount times the ink's CMYK values, plates combined as `1 − (1 − a)(1 − b)`); Separations Preview and `file.print` separations show each plate on its own ink, and a spot plate prints as the document's swatch of its ink (else its CMYK values) with `spotsToProcess`. Separations Preview shows a reduced image's plates as the From 0676016741082addb095e62ee4f0e3d9d61649d9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 21:59:28 -0800 Subject: [PATCH 2/2] Place: the DCS check on open agrees with a full read for short or corrupt files The check that decides whether a linked EPS file is DCS now gets the file's size. When the bytes read hold the whole file, it reads them as a full read does. A binary EPS header whose PostScript or TIFF preview ends past the end of the file names no plates, so such a file keeps its preview with lowResProxyEps on instead of going missing. --- crates/engine/src/cmd/links.rs | 2 +- crates/engine/src/cmd/place/dcs.rs | 19 +++++++++---- crates/engine/src/tests/dcs.rs | 43 ++++++++++++++++++++++-------- 3 files changed, 47 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 3d07f07992..019f9baf73 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -396,7 +396,7 @@ fn is_dcs_file(f: &mut Found) -> bool { return dcs::is_dcs(b); } let Ok(head) = fileio::read_file_head(&f.path, dcs::HEAD_BYTES) else { return false }; - if let Some(is) = dcs::head_is_dcs(&head) { + if let Some(is) = dcs::head_is_dcs(&head, f.size) { return is; } let Ok(b) = f.read() else { return false }; diff --git a/crates/engine/src/cmd/place/dcs.rs b/crates/engine/src/cmd/place/dcs.rs index bd588eaa11..69a397c5d1 100644 --- a/crates/engine/src/cmd/place/dcs.rs +++ b/crates/engine/src/cmd/place/dcs.rs @@ -42,18 +42,27 @@ pub(crate) fn is_dcs(bytes: &[u8]) -> bool { /// The most bytes read from the start of a file to look at its DCS header. pub(crate) const HEAD_BYTES: usize = 1 << 20; -/// Does the header of the PostScript in file `head` (the first bytes of a file, at most -/// [`HEAD_BYTES`]) name DCS plates? `None` when `head` can't tell: that PostScript doesn't start -/// within it (a binary EPS header that puts it further on), or its header goes on past it. -pub(crate) fn head_is_dcs(head: &[u8]) -> Option { +/// Does the header of the PostScript in file `head` (the first bytes of a file `size` bytes long, +/// at most [`HEAD_BYTES`]) name DCS plates? `None` when `head` can't tell: that PostScript doesn't +/// start within it (a binary EPS header that puts it further on), or its header goes on past it. A +/// `head` that holds the whole file is read as [`is_dcs`] reads it, and a binary EPS header whose +/// PostScript or TIFF preview ends past `size` names none, as [`is_dcs`] finds for the whole file. +pub(crate) fn head_is_dcs(head: &[u8], size: u64) -> Option { const DOS_MAGIC: [u8; 4] = [0xC5, 0xD0, 0xD3, 0xC6]; + if head.len() < HEAD_BYTES || head.len() as u64 >= size { + return Some(is_dcs(head)); + } let (ps, whole) = if head.starts_with(&DOS_MAGIC) { let word = |at: usize| head.get(at..at + 4).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(|b| u32::from_le_bytes(b) as usize); + let fits = |at: usize| Some((word(at)? as u64).checked_add(word(at + 4)? as u64).is_some_and(|end| end <= size)); + if !fits(4)? || (word(24)? != 0 && !fits(20)?) { + return Some(false); + } let (start, len) = (word(4)?, word(8)?); let ps = head.get(start..).filter(|ps| !ps.is_empty())?; (ps.get(..len).unwrap_or(ps), start.checked_add(len).is_some_and(|end| end <= head.len())) } else { - (head, head.len() < HEAD_BYTES) + (head, false) }; if is_dcs(ps) { return Some(true); diff --git a/crates/engine/src/tests/dcs.rs b/crates/engine/src/tests/dcs.rs index 0e6410585c..9853ed5da7 100644 --- a/crates/engine/src/tests/dcs.rs +++ b/crates/engine/src/tests/dcs.rs @@ -660,10 +660,16 @@ fn a_saved_document_reads_the_plates_again_with_the_low_resolution_eps_proxy() { #[test] fn a_dcs_header_is_found_at_the_start_of_a_file_or_behind_a_binary_eps_header() { - use crate::cmd::place::dcs::head_is_dcs; + use crate::cmd::place::dcs::{HEAD_BYTES, head_is_dcs, is_dcs}; + // A whole file, as `is_dcs` reads it. + let whole = |b: &[u8]| { + let is = head_is_dcs(b, b.len() as u64); + assert_eq!(is, Some(is_dcs(b))); + is + }; let ps = two_plates(); - assert_eq!(head_is_dcs(&ps), Some(true)); - assert_eq!(head_is_dcs(b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EndComments\n"), Some(false)); + assert_eq!(whole(&ps), Some(true)); + assert_eq!(whole(b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EndComments\n"), Some(false)); let binary = |start: u32| { let mut b = vec![0xC5, 0xD0, 0xD3, 0xC6]; b.extend(start.to_le_bytes()); @@ -672,23 +678,38 @@ fn a_dcs_header_is_found_at_the_start_of_a_file_or_behind_a_binary_eps_header() b.extend(&ps); b }; - assert_eq!(head_is_dcs(&binary(30)), Some(true)); - // PostScript whose length runs past the bytes read: its header is read from the part there is. + assert_eq!(whole(&binary(30)), Some(true)); + // A binary header whose PostScript or TIFF preview ends past the end of the file names none. let mut cut = binary(30); cut[8..12].copy_from_slice(&(64u32 << 20).to_le_bytes()); - assert_eq!(head_is_dcs(&cut), Some(true)); + assert_eq!(whole(&cut), Some(false)); + let mut tiff = binary(30); + tiff[20..24].copy_from_slice(&30u32.to_le_bytes()); + tiff[24..28].copy_from_slice(&(64u32 << 20).to_le_bytes()); + assert_eq!(whole(&tiff), Some(false)); + // A file longer than the bytes read, its PostScript running past them: its header is read from + // the part there is, unless the header puts the PostScript or the preview past the file's end. + let mut big = binary(30); + big.resize(HEAD_BYTES, b' '); + big[8..12].copy_from_slice(&(64u32 << 20).to_le_bytes()); + assert_eq!(head_is_dcs(&big, 30 + (64 << 20)), Some(true)); + assert_eq!(head_is_dcs(&big, 30 + (64 << 20) - 1), Some(false)); + big[20..24].copy_from_slice(&30u32.to_le_bytes()); + big[24..28].copy_from_slice(&(65u32 << 20).to_le_bytes()); + assert_eq!(head_is_dcs(&big, 30 + (64 << 20)), Some(false)); // PostScript that starts past the bytes read: the whole file is needed. - assert_eq!(head_is_dcs(&binary(64)[..40]), None); + let far = binary(HEAD_BYTES as u32 + 10); + assert_eq!(head_is_dcs(&far[..HEAD_BYTES], far.len() as u64), None); // A header that goes on past the bytes read may name its plates further on. let mut long = b"%!PS-Adobe-3.0 EPSF-3.0\n".to_vec(); - while long.len() < crate::cmd::place::dcs::HEAD_BYTES { + while long.len() < HEAD_BYTES { long.extend(format!("%%Title: {}\n", "x".repeat(190)).as_bytes()); } - long.truncate(crate::cmd::place::dcs::HEAD_BYTES); - assert_eq!(head_is_dcs(&long), None); + long.truncate(HEAD_BYTES); + assert_eq!(head_is_dcs(&long, HEAD_BYTES as u64 + 1), None); // One that ended within them names none. long[30..45].copy_from_slice(b"\n%%EndComments\n"); - assert_eq!(head_is_dcs(&long), Some(false)); + assert_eq!(head_is_dcs(&long, HEAD_BYTES as u64 + 1), Some(false)); } #[test]