From 76399175ebb30dd8163794ced848c0afd725d847 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:09:53 +0300 Subject: [PATCH] M4: preserve counted EPS metadata across CRLF line endings --- crates/eps/src/import/data.rs | 20 +++++++++++++++++ crates/eps/src/import/tests.rs | 40 ++++++++++++++++++++++++++++++++++ docs/file-format-parity.md | 1 + 3 files changed, 61 insertions(+) diff --git a/crates/eps/src/import/data.rs b/crates/eps/src/import/data.rs index b1724d39c..e666e8321 100644 --- a/crates/eps/src/import/data.rs +++ b/crates/eps/src/import/data.rs @@ -536,6 +536,7 @@ impl Interp<'_> { break; } Some(b'\r') => { + self.skip_lf(&f); ended = true; break; } @@ -688,6 +689,25 @@ impl Interp<'_> { }) } + /// After `readline` consumes CR, consume only an immediately following LF. PostScript + /// treats CR/LF as one line ending (PLRM §3.8); raw reads must keep every other byte. + /// The preceding `read` has already materialized any pending filter. + fn skip_lf(&mut self, f: &Rc>) { + match &mut *f.borrow_mut() { + Stream::Current => { + if self.lex.rest().first() == Some(&b'\n') { + self.lex.advance(1); + } + } + Stream::Buf { data, pos, .. } => { + if data.get(*pos) == Some(&b'\n') { + *pos += 1; + } + } + Stream::Pending(_) => {} + } + } + /// Up to `n` bytes read as hexadecimal digits from `f` (other characters skipped). fn read_hex(&mut self, f: &Rc>, n: usize) -> Res> { let mut out = Vec::with_capacity(n); diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index 678ae993b..5ace00ac2 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -633,6 +633,46 @@ pub(super) fn check(cond: &str) { blue(&format!("{cond} {{ 0 0 1 setrgbcolor }} {{ 1 0 0 setrgbcolor }} ifelse")); } +/// #3547: a line before counted metadata ends in CR/LF. Leaving LF unread shifts the +/// metadata by one byte and makes its final `>` look like PostScript instead of data. +#[test] +fn readline_keeps_counted_metadata_aligned_for_every_line_ending() { + let tiff = crate::tiff::encode(&Raster { width: 4, height: 4, rgba: [0, 128, 0, 255].repeat(16) }, false, false).unwrap(); + for eol in ["\n", "\r", "\r\n"] { + let ps = eps(&format!( + "/consume {{ currentfile 80 string readline pop pop currentfile 4 string readstring pop pop }} bind def\n\ + consume\nheader{eol}\n0 0 1 setrgbcolor 10 20 30 40 rectfill" + )); + for bytes in [ps.clone(), crate::dos_eps(&ps, &tiff).unwrap()] { + let r = import(&bytes).unwrap(); + assert!(!r.preview, "valid art must not become the low-resolution TIFF preview"); + assert!(r.warnings.is_empty(), "{eol:?}: {:?}", r.warnings); + let art = objects(&r.document); + assert_eq!(art.len(), 1); + assert!(matches!(art[0].kind, NodeKind::Path { .. })); + assert_eq!(fill(&art[0]), Some(&Paint::solid(Color::rgb(0.0, 0.0, 1.0)))); + } + } +} + +/// CR/LF is one terminator in buffered and decoded streams too; lone CR never consumes +/// the next payload byte, EOF is safe, and ordinary `readstring` preserves binary CR/LF. +#[test] +fn readline_streams_preserve_the_next_payload_byte() { + for eol in ["\n", "\r", "\r\n"] { + let bytes = format!("header{eol}XY"); + let hex: String = bytes.bytes().map(|b| format!("{b:02x}")).collect(); + for source in [format!("<{hex}> /ReusableStreamDecode filter"), format!("({hex}>) /ASCIIHexDecode filter")] { + check(&format!( + "/f {source} def f 32 string readline exch (header) eq and \ + f 2 string readstring exch (XY) eq and and" + )); + } + } + check("/f <6865616465720d> /ReusableStreamDecode filter def f 32 string readline exch (header) eq and f read not and"); + check("/f <0d0a5859> /ReusableStreamDecode filter def f 4 string readstring exch <0d0a5859> eq and"); +} + #[test] fn the_generic_category_defines_new_categories() { // #234: the reference app's prolog. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a97e42dd9..4ca55b7c2 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -130,6 +130,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | EPS import: a CR LF ending after `readline` counts as one line ending (PostScript Language Reference §3.8), so counted metadata that follows it is no longer shifted by a byte and the editable vectors import instead of the preview; scores and estimates unchanged. | | 2026-10-21 | minor | PDF export: flattening for PDF/X-1a and PDF/X-3 in a CMYK document draws the areas it rasterizes as ink amounts (a CMYK image), so a CMYK colour under an image with see-through pixels keeps its numbers, K included, instead of going through RGB and back (#2160); own-fixture test; scores and hours unchanged | | 2026-10-21 | minor | EPS and `.ai` editing data: type with an art style of several fills or strokes, effects on a fill (Offset Path, Transform with copies, Drop Shadow, Gaussian Blur, Bas Relief), pattern and gradient fills, per-fill blend modes and a Warp comes in as type with that live appearance instead of its drawn look (outlines), the style read as a tree of filters; other objects keep the rule for plain styles. Point type whose frame has no matrix (written so by newer files) is read; such stories were left out. Where a styled object's drawn look lay, the page isn't held to look like VectorCraft's drawing of the style. `editingData: "always"` also opens an Illustrator EPS from its editing data. Own-fixture tests; a user's file used locally only; scores and hours unchanged | | 2026-10-18 | minor | PDF import: invisible text (rendering mode 3, which carries a scanned page's recognized text) opens as type with no fill or stroke instead of being dropped without a word; when it can't be type (text opened as outlines, Type 3 fonts) it is left out with a warning. PDF export writes characters with no fill or stroke as invisible text in rendering mode 3, so a scanned PDF opened and saved keeps its searchable text, in PDF/X-1a and PDF/A too (#2675); own-fixture tests; scores and hours unchanged |