fix(render): JBIG2 refinement reads 0 past its reference's width (hayro-jbig2 patch 13) (#5999)

* M2: a JBIG2 refinement reads 0 past its reference's width

hayro-jbig2 keeps each row of a bitmap in 32-bit words and filled a new
bitmap whole, so with a default pixel of 1 the bits past its width in
each row's last word were 1s too. A refinement reads its reference a word
at a time, those bits included, where ISO/IEC 14492 6.3.5.2 says a pixel
outside the reference is 0. A refinement of the page, or of an
intermediate text or halftone region, whose default pixel is 1 and whose
width isn't a multiple of 32 decoded to other pixels than were coded.

Vendored patch 13: Bitmap::clear_padding clears those bits when
new_with or reinitialize fills a bitmap with 1s, as cutting symbols and
patterns out of a collective bitmap already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

* M2: the doc on a JBIG2 bitmap's words says their padding is 0

Three places in hayro-jbig2 now clear the bits past a row's width, and
the field's doc said only that rows are padded. It now says the padding
is 0 and why: generic and refinement decoding read the rows above their
own pixels a word at a time, as a refinement reads its reference, so
whatever next writes whole words keeps it 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Piohrun
2026-11-04 12:49:49 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent bc7c60bb41
commit e38b9d182e
4 changed files with 130 additions and 3 deletions
+83
View File
@@ -214,4 +214,87 @@ mod tests {
let _ = decode_jbig2(&data[..end], None, w, h, usize::MAX);
}
}
/// Pixels past a bitmap's edge are 0 (ISO/IEC 14492 6.3.5.2). A page whose default pixel is 1,
/// and an intermediate text or halftone region whose default pixel is 1, kept 1s past their
/// width in their last word of each row, and a refinement, which reads its reference a word at
/// a time, read them as pixels: of rows not a multiple of 32 wide, it decoded to other pixels
/// than were coded. Each stream is a refinement coded by the standard's procedures from a
/// reference read as 0 past its edge, and decodes to the picture given, `#` for black.
/// Vendored hayro-jbig2 patch 13.
#[test]
fn a_refinement_reads_nothing_past_its_references_width() {
// A page of 7 × 6, then of 33 × 6, whose default pixel is 1, refined whole.
const PAGE_7: &str = "000000003000010000001300000007000000060000000000000000460000000000012a00010000001e000000070000000600000000000000000400ffffffffe42d7e889ebcffac";
const PAGE_33: &str = "000000003000010000001300000021000000060000000000000000460000000000012a000100000034000000210000000600000000000000000400ffffffff314a8d598037eb3f915ab3d526ccae915ad6a6a7b814111b124ae60fffac";
// An intermediate text region of 31 × 6 whose default pixel is 1, then one of 7 × 6 on a
// page of 40, each refined into the page.
const TEXT_31: &str = "00000000300001000000130000001f0000000600000000000000004200000000000100000100000030000003fffdff02fefefe00000001000000014f96dceb76c7f1ff585971969cd9f88e538261a6f713be68d77e8e1fffac00000002042001010000001b0000001f00000006000000000000000000029000000001a943ffac000000032a2002010000002f0000001f0000000600000000000000000400ffffffff9ac311688adbc8f570a2f96aef93d332e3b1f4b27b704bffac";
const TEXT_7: &str = "000000003000010000001300000028000000060000000000000000420000000000010000010000001d000003fffdff02fefefe00000001000000014fe1f79f4a95c5b77fffac00000002042001010000001b0000000700000006000000000000000000029000000001a943ffac000000032a2002010000001e000000070000000600000000000000000400ffffffffa71dae12bd7fffac";
// An intermediate halftone region of 33 × 6 whose default pixel is 1, refined into the page.
const HALFTONE_33: &str = "000000003000010000001300000021000000060000000000000000420000000000011000010000000a000101000000011fffac00000002142001010000004100000021000000060000000000000000009000000021000000060000000000000000010000004ea6618f551d48ff24c2c5c98124d449f2adea6fca76e4297fffac000000032a20020100000032000000210000000600000000000000000400ffffffffe87db809393a3251e565e7a749e6470ab37f7aef186ceaf5167bffac";
let cases: [(&str, &str, [&str; 6]); 5] = [
("page 7", PAGE_7, ["###.#.#", ".......", "#.####.", "######.", "#.#...#", ".#...##"]),
(
"page 33",
PAGE_33,
[
".#..##........###..####....#.##.#",
".#.###.###.##.##..#..#...#.....##",
".....##..#....#.##.######.###..#.",
"##...#..##.###..###.##..#.#.#.#.#",
".##.#........##.#.#.....###......",
"...#.#####.#..#.###......###..###",
],
),
(
"text 31",
TEXT_31,
[
"....##....###......#.##.#.#....",
".#.....##.......##..###.#...#..",
".#.#.#.####...#####.####...#.#.",
"......#...#...#.##...##...#..#.",
".#..##.###.#.###..####...#...#.",
"#...#...#..#.#....#.##.#.....#.",
],
),
(
"text 7 on a page of 40",
TEXT_7,
[
"......#.................................",
".....##.................................",
"##...##.................................",
"..#.....................................",
"##......................................",
"..#.....................................",
],
),
(
"halftone 33",
HALFTONE_33,
[
"##...#.###.#.###.....#.####..####",
"##.##.#...#.##.##.##.#...#.#.###.",
"###..#.#....##.###...#..##.##..##",
"##..#...###...##..#.####.###.#...",
"####.###.###.#..#..#.#.####...###",
"#.#..#........#....#.#.##.##..#.#",
],
),
];
let hex = |text: &str| (0..text.len() / 2).map(|i| u8::from_str_radix(&text[i * 2..i * 2 + 2], 16).unwrap()).collect::<Vec<u8>>();
for (form, stream, picture) in cases {
let w = picture[0].len();
// As the filter gives it: black as 0, each row padded to a byte with 0s.
let mut rows = vec![0u8; w.div_ceil(8) * picture.len()];
for (y, row) in picture.iter().enumerate() {
for (x, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'.') {
rows[y * w.div_ceil(8) + x / 8] |= 0x80 >> (x % 8);
}
}
assert_eq!(decode_jbig2(&hex(stream), None, w as u32, 6, usize::MAX).unwrap(), rows, "{form}");
}
}
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+33 -2
View File
@@ -93,6 +93,11 @@ pub(crate) struct Bitmap {
pub(crate) stride: u32,
/// Packed pixel data, one bit per pixel, row-major order.
/// Each row is padded to a word boundary.
///
/// PdfCraft patch: the padding is 0s, as pixels past the edge are (6.2.5.2, 6.3.5.2). Generic
/// and refinement decoding read the rows above their own pixels a word at a time, padding
/// included, and a refinement reads its reference the same way: whatever writes whole words
/// keeps the padding 0 (see `clear_padding`).
pub(crate) data: Vec<Word>,
/// "This four-byte field gives the horizontal offset in pixels of the bitmap
/// encoded in this segment relative to the page bitmap." (7.4.1.3)
@@ -134,14 +139,37 @@ impl Bitmap {
let stride = width.div_ceil(WORD_BITS);
let default_word: Word = if default_pixel { !0 } else { 0 };
let data = vec![default_word; stride as usize * height as usize];
Ok(Self {
let mut bitmap = Self {
width,
height,
stride,
data,
x_location,
y_location,
})
};
if default_pixel {
bitmap.clear_padding();
}
Ok(bitmap)
}
/// PdfCraft patch: clear the bits past `width` in each row's last word. "All pixels lying
/// outside the bounds of the actual bitmap or the reference bitmap have the value 0" (6.3.5.2),
/// and a refinement reads its reference a word at a time, those bits included: filled whole
/// with a default pixel of 1, they read as 1s, so a refinement of the page, or of an
/// intermediate text or halftone region, decoded to other pixels than were coded.
fn clear_padding(&mut self) {
let trailing = self.width % WORD_BITS;
// A width of 0 has no words to clear, and `chunks_exact_mut` would take a stride of 0.
if trailing == 0 {
return;
}
let mask: Word = !0 << (WORD_BITS - trailing);
for row in self.data.chunks_exact_mut(self.stride as usize) {
if let Some(last) = row.last_mut() {
*last &= mask;
}
}
}
pub(crate) fn reinitialize(
@@ -162,6 +190,9 @@ impl Bitmap {
self.data.clear();
self.data
.resize(stride as usize * height as usize, default_word);
if default_pixel {
self.clear_padding();
}
Ok(())
}
+13
View File
@@ -858,4 +858,17 @@ mod tests {
page.retained_words = MAX_RETAINED_WORDS;
assert_eq!(page.retained_room(), 0);
}
/// Pixels past a bitmap's edge are 0 (6.3.5.2), and a refinement reads its reference a word at
/// a time, so a bitmap filled with 1s has none past its width: made, and a page made again for
/// the next image.
#[test]
fn a_bitmap_of_1s_has_nothing_past_its_width() {
assert_eq!(Bitmap::new_with(33, 2, 0, 0, true).unwrap().data, [!0, 1 << 31, !0, 1 << 31]);
let mut page = Bitmap::new_with(40, 1, 0, 0, false).unwrap();
page.reinitialize(7, 2, true).unwrap();
assert_eq!(page.data, [0xFE00_0000, 0xFE00_0000]);
assert_eq!(Bitmap::new_with(64, 1, 0, 0, true).unwrap().data, [!0, !0], "words filled whole");
assert!(Bitmap::new_with(0, 3, 0, 0, true).unwrap().data.is_empty(), "no width");
}
}