From 766ff9d05a51e943396d42de1dcf8d5c12ce4fbd Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 20 Oct 2026 14:47:52 +0500 Subject: [PATCH 1/2] Render: a doubled sign is one number, so a cm with it still has a matrix A number written with a doubled sign was read by the renderer's lexer as two numbers. `--280` set the sign, found no digit before the second `-`, and returned 0, leaving `-280` to be read next: a `cm` carrying it got seven operands, kept the last six, and the matrix `0 0 1 0 -280 10` hid everything drawn under it. The page rendered blank while text extraction still reported the text, because the glyphs were there and only the matrix was wrong. Measured on the report's own file, at 72 dpi, counting pixels darker than mid-grey: double_minus.pdf before: 0 dark px (blank) after: 581, leftmost at x=121 control.pdf (-280) 581, leftmost at x=121 -- unchanged so the two files now render identically, which is what the report asks for and what MuPDF does. One extra sign is absorbed and the value is negative if either sign was a minus, matching the COS lexer since #3299 (`--280` and `+-280` are both -280). `Number::skip` already consumed the whole token, so read and skip had disagreed about where the number ended. Fixes #3565. #3299 fixed the same input in the COS lexer only. --- vendor/hayro-syntax/src/object/number.rs | 44 ++++++++++++++++++------ 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/vendor/hayro-syntax/src/object/number.rs b/vendor/hayro-syntax/src/object/number.rs index bd37b19c3..7d3f05b4e 100644 --- a/vendor/hayro-syntax/src/object/number.rs +++ b/vendor/hayro-syntax/src/object/number.rs @@ -113,17 +113,24 @@ impl Readable<'_> for Number { #[inline(always)] fn read_inner(r: &mut Reader<'_>) -> Option { - let negative = match r.peek_byte()? { - b'-' => { - r.forward(); - true - } - b'+' => { - r.forward(); - false - } - _ => false, - }; + // PdfCraft patch: a doubled sign is one number, not two. `--280` left `negative` set with no + // digits read, so it returned 0 and the reader stopped on `-280`, which became a second + // number: a `cm` with it got seven operands, kept the last six, and the matrix + // `0 0 1 0 -280 10` hid everything drawn under it (#3565). `Number::skip` above already + // consumes the whole token, so read and skip disagreed about where the number ended. + // + // One extra sign is absorbed and the value is negative if either sign was a minus, which is + // what the COS lexer does since #3299 (`--280` and `+-280` are both -280). A third sign is + // left to the digit loop's trailing-minus arm, as before. + let mut negative = false; + let mut signs = 0; + while signs < 2 + && let Some(b @ (b'-' | b'+')) = r.peek_byte() + { + r.forward(); + negative |= b == b'-'; + signs += 1; + } // PdfCraft patch: high-precision float mantissa overflow (jsPDF double-precision coordinates). // Digits that don't fit in `mantissa` are dropped: past the dot they are only precision; @@ -348,6 +355,21 @@ mod tests { use crate::reader::Reader; use crate::reader::ReaderExt; + /// PdfCraft patch: a doubled sign is one number. `--280` returned 0 and left `-280` to be + /// read as a second number, so a `cm` carrying it got seven operands and a degenerate matrix + /// (#3565). The COS lexer reads these the same way since #3299. + #[test] + fn a_doubled_sign_is_one_number() { + for (src, want) in [("--280", -280), ("+-280", -280), ("-+280", -280), ("++280", 280), ("-280", -280), ("+280", 280)] { + let mut r = Reader::new(src.as_bytes()); + assert_eq!(r.read_without_context::().unwrap(), want, "{src}"); + assert!(r.at_end(), "{src}: the whole number was read, leaving nothing for a second one"); + } + // The fraction too, and a lone sign is still zero. + assert_eq!(Reader::new("--280.5".as_bytes()).read_without_context::().unwrap(), -280.5); + assert_eq!(Reader::new("-".as_bytes()).read_without_context::().unwrap(), 0); + } + #[test] fn int_1() { assert_eq!( From 386c92c034d03d3fbfa1c589c8583ec02249ad87 Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 20 Oct 2026 17:12:11 +0500 Subject: [PATCH 2/2] Render: skipping a number covers the signs reading accepts `read_inner` now reads `-+280` and `++280` as well as `--280`, but `Number::skip` consumed one sign and then required a digit, a dot or a further `-`. So a skip failed where a read succeeded, and `ReaderExt::skip` puts the reader back where it started -- `Object::skip` would leave the number in place rather than step over it. `--280` was already covered, by the `b'-'` arm. One more sign is consumed, and the six sign forms are asserted skippable to the end of the token. Without this the test says `-+280: not skipped`. A third sign is still read and skipped differently (`---280` reads as 0 and skips whole), which was already so before this branch and is left alone. Found in review of #3630. --- vendor/hayro-syntax/src/object/number.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/vendor/hayro-syntax/src/object/number.rs b/vendor/hayro-syntax/src/object/number.rs index 7d3f05b4e..9b89ed709 100644 --- a/vendor/hayro-syntax/src/object/number.rs +++ b/vendor/hayro-syntax/src/object/number.rs @@ -70,6 +70,13 @@ impl Number { impl Skippable for Number { fn skip(r: &mut Reader<'_>, _: bool) -> Option<()> { let has_sign = r.forward_if(|b| b == b'+' || b == b'-').is_some(); + // PdfCraft patch: one more sign, so that skipping a number covers what `read_inner` + // reads. `--280` was already covered by the `b'-'` arm below, but `-+280` and `++280` + // were not, and a skip that fails where a read succeeds puts the reader back where it + // started (#3630 review). + if has_sign { + r.forward_if(|b| b == b'+' || b == b'-'); + } // Some PDFs have weird trailing minuses, so try to accept those as well. match r.peek_byte()? { @@ -368,6 +375,15 @@ mod tests { // The fraction too, and a lone sign is still zero. assert_eq!(Reader::new("--280.5".as_bytes()).read_without_context::().unwrap(), -280.5); assert_eq!(Reader::new("-".as_bytes()).read_without_context::().unwrap(), 0); + + // Skipping covers what reading accepts: a skip that fails where a read succeeds puts the + // reader back where it started, so `Object::skip` would leave the number in place + // (#3630 review). + for src in ["--280", "+-280", "-+280", "++280", "-280", "+280", "--280.5"] { + let mut r = Reader::new(src.as_bytes()); + assert!(r.skip::(false).is_some(), "{src}: not skipped"); + assert!(r.at_end(), "{src}: only part of the number was skipped"); + } } #[test]