From 2d2ac65b2c44c155ba764b7c95ecf817859bf17b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 21:30:06 -0800 Subject: [PATCH] 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