diff --git a/ROADMAP.md b/ROADMAP.md
index e86a8572..474d4f00 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,6 +2,7 @@
**Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away
+> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3 #1364 candidate: consent before absolute local GoToR access; estimates unchanged); earlier: minor (M1 #1339: an unchanged incremental save of a classic cross-reference table writes a zero-length subsection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M5 #1398: stamps read upright on rotated pages; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (gap 6: cloudy comments stay when flatten cannot draw them, a refused text-box edit keeps its properties, arrow defaults stay off lines, a custom match stops at a separator, and the vertical-text redaction error no longer claims an overlap; #1317, #1327, #1302, #1315, #1340; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -145,6 +146,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
+- **2026-10-12 (M2, TIFF predictor rows):** Packed RGB images using TIFF horizontal differencing keep every row when samples do not fill the last byte. The renderer aligns each decoded row before moving to the next. Synthetic decoding tests cover 1/2/4-bit samples, horizontal differencing and an 8-bit control; a red/green page-render regression verifies visible output. Estimates unchanged.
- **2026-10-12 (M3, #1364 candidate):** Absolute local GoToR links wait for Allow/Cancel before target-file access. Pending requests preserve their source and page/view destination; relative links retain their existing behavior. Headless `link_open` requires explicit `allow_absolute: true`, preserving existing root, symlink, file and password validation. Estimates unchanged.
- **2026-10-12 (community, M7, inline Arabic ActualText):** A single-glyph lam-alef `/ActualText` span joins its surrounding word in Edit text instead of splitting it into separate lines. Pure RTL ligature text is put in visual order before line assembly and recovered to typing order with its neighbours. Multi-glyph and mixed-direction replacements retain their authoritative order. Synthetic regressions cover lam-alef variants, marks, two-byte glyph codes, nested spans, replacement and save/reopen. Estimates unchanged.
diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs
index e97d7e5e..4ad71966 100644
--- a/crates/render/src/raster.rs
+++ b/crates/render/src/raster.rs
@@ -3472,6 +3472,78 @@ trailer << /Root 1 0 R >>
page(&format!("q 20 0 0 20 5 5 cm /Im1 Do Q {red}"), fax);
}
+ #[test]
+ fn tiff_predictor_packed_rgb_rows_keep_their_byte_boundaries() {
+ use hayro_syntax::object::{FromBytes, Object};
+ use std::io::Write;
+
+ // Red then green, with only the real sample bits compared: row padding may change.
+ // The three-column case also checks horizontal differencing: red/green/blue and
+ // green/red/blue, whose one-bit component differences wrap modulo two.
+ struct RowCase {
+ bpc: u8,
+ columns: u32,
+ encoded: &'static [u8],
+ expected: &'static [u8],
+ masks: &'static [u8],
+ }
+ let cases = [
+ RowCase { bpc: 1, columns: 1, encoded: &[0x80, 0x40], expected: &[0x80, 0x40], masks: &[0xe0] },
+ RowCase { bpc: 2, columns: 1, encoded: &[0xc0, 0x30], expected: &[0xc0, 0x30], masks: &[0xfc] },
+ RowCase { bpc: 4, columns: 1, encoded: &[0xf0, 0, 0x0f, 0], expected: &[0xf0, 0, 0x0f, 0], masks: &[0xff, 0xf0] },
+ RowCase { bpc: 1, columns: 3, encoded: &[0x99, 0x80, 0x5a, 0x80], expected: &[0x88, 0x80, 0x50, 0x80], masks: &[0xff, 0x80] },
+ RowCase { bpc: 8, columns: 1, encoded: &[255, 0, 0, 0, 255, 0], expected: &[255, 0, 0, 0, 255, 0], masks: &[0xff, 0xff, 0xff] },
+ ];
+ for RowCase { bpc, columns, encoded, expected, masks } in cases {
+ let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
+ encoder.write_all(encoded).unwrap();
+ let compressed = encoder.finish().unwrap();
+ let mut bytes = format!(
+ "<< /Filter /FlateDecode /DecodeParms << /Predictor 2 /Colors 3 /BitsPerComponent {bpc} /Columns {columns} >> /Length {} >> stream\n",
+ compressed.len()
+ )
+ .into_bytes();
+ bytes.extend_from_slice(&compressed);
+ bytes.extend_from_slice(b"\nendstream");
+ let Object::Stream(stream) = Object::from_bytes(&bytes).unwrap() else { panic!("stream expected") };
+ let decoded = stream.decoded().unwrap();
+ assert_eq!(decoded.len(), expected.len());
+ for ((actual, expected), mask) in decoded.iter().zip(expected).zip(masks.iter().cycle()) {
+ assert_eq!(actual & mask, *expected, "{bpc}-bit RGB, {columns} columns");
+ }
+ }
+ }
+
+ #[test]
+ fn tiff_predictor_packed_rgb_image_renders_every_row() {
+ use std::io::Write;
+
+ let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
+ encoder.write_all(&[0x80, 0x40]).unwrap();
+ let compressed = encoder.finish().unwrap();
+ let content = "q 40 0 0 40 0 0 cm /Im1 Do Q";
+ let mut pdf = format!(
+ "%PDF-1.7\n\
+1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
+2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
+3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n\
+4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 1 /Filter /FlateDecode /DecodeParms << /Predictor 2 /Colors 3 /BitsPerComponent 1 /Columns 1 >> /Length {} >> stream\n",
+ content.len(),
+ compressed.len()
+ )
+ .into_bytes();
+ pdf.extend_from_slice(&compressed);
+ pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
+ let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
+ let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
+ assert!(page.error.is_none(), "{:?}", page.error);
+ assert_eq!((page.width, page.height), (40, 40));
+ let pixel = |y: usize| &page.rgba[((y * 40 + 20) * 4)..][..4];
+ assert_eq!(pixel(10), &[255, 0, 0, 255]);
+ assert_eq!(pixel(30), &[0, 255, 0, 255]);
+ }
+
/// From the nightly `cargo xtask fuzz`: a FlateDecode predictor with `/Columns
/// 9223372036854775807` wrapped to a 2^61-byte row allocation (an abort on any machine), and
/// a line width of 9223372036854775807 made stroke expansion allocate 10 GB. Vendored hayro
diff --git a/docs/roadmap.md b/docs/roadmap.md
index aaf5c35d..76f3a374 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,5 +1,6 @@
# PdfCraft roadmap: milestones and what's next
+> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M3 #1364 candidate: consent before absolute local GoToR access; estimates unchanged); earlier: minor (M1 #1339: unchanged incremental saves of a classic cross-reference table write a zero-length subsection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M5 #1398: stamps read upright on rotated pages; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (gap 6: #1317, #1327, #1302, #1315, #1340; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -85,6 +86,7 @@ total matches the parity estimate.
|---|---|---|---|---|---|
| M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates |
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs |
+| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. A custom text match that ends on a reconstructed separator stops before the next glyph (#1315). Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
@@ -155,6 +157,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| Date | Change | Summary |
|---|---|---|
+| 2026-10-12 | minor | M2: packed TIFF-predictor RGB row boundaries, with decoding and page-render regressions; estimates unchanged |
| 2026-10-12 | minor | M3/#1364 candidate: absolute local GoToR links require desktop consent or headless allow_absolute; existing relative navigation and validation retained. Estimates unchanged. |
| 2026-10-12 | minor | M7: single-glyph RTL ActualText ligatures join their word in Edit text. Regressions cover marks, two-byte codes, replacement and save/reopen; multi-glyph logical spans retain their order. Estimates unchanged. |
| 2026-10-12 | minor | M5 #1398: rubber stamps, explicit image rectangles and PDF-page stamps read upright on rotated pages. Estimates unchanged. |
diff --git a/vendor/README.md b/vendor/README.md
index 919f2385..c433193f 100644
--- a/vendor/README.md
+++ b/vendor/README.md
@@ -32,6 +32,13 @@ Later rendering errors, device loss, and non-winit renderer construction are unc
Temporary: hayro is the bootstrap renderer and lopdf the bootstrap inspector (ADR-0004); M2.6 replaces them with our own devices and `cos`/`model`.
+TIFF predictor decoding: hayro-syntax aligns the output writer to a byte boundary after each
+row before splitting off its buffer. Packed RGB samples at 1, 2 or 4 bits per component can
+leave padding bits; without alignment later rows overwrite the preceding row or decode empty.
+Covered by `raster::tests::tiff_predictor_packed_rgb_rows_keep_their_byte_boundaries` (including
+horizontal differencing and an 8-bit control) and
+`raster::tests::tiff_predictor_packed_rgb_image_renders_every_row`.
+
Text extraction: hayro-interpret exposes `OutlineGlyph::is_vertical` from a Type0 font’s CMap writing mode. PdfCraft follows negative glyph-space y for WMode 1 so upright vertical columns can be searched (#126).
Lab Bradford follow-up to #1237: Lab adapts XYZ to D65 with white-point ratios
diff --git a/vendor/hayro-syntax/src/filter/lzw_flate.rs b/vendor/hayro-syntax/src/filter/lzw_flate.rs
index ac1c19d6..f9da1590 100644
--- a/vendor/hayro-syntax/src/filter/lzw_flate.rs
+++ b/vendor/hayro-syntax/src/filter/lzw_flate.rs
@@ -1087,6 +1087,9 @@ fn apply_predictor(data: Vec, params: &PredictorParams) -> Option> {
return None;
}
+ // PdfCraft patch: packed TIFF samples can leave padding bits at the end
+ // of a row. split_off requires byte alignment or the next row overwrites it.
+ writer.align();
let (data, new_writer) = writer.split_off();
writer = new_writer;
prev_row = BitChunks::new(data, bit_size, chunk_len)?;