mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
docs/roadmap.md: main's M2 notes with the PR's sentence appended ROADMAP.md: the PR's progress-log line moved to the top (newest first)
This commit is contained in:
@@ -174,6 +174,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-22 (M2, #3107 / PR #3438):** Inline-image parsing falls back to the existing EI heuristic when missing declared samples, headers or terminators exceed the content-window budget, instead of waiting for an impossible allocation. Exact-length parsing for available samples and ordinary large streamed images is retained. Estimates unchanged.
|
||||
- **2026-10-20 (M6, calculation order page):** Prepare a form ▸ Calculation order, a page of its own, lists the calculated fields in the order they calculate with their formulas and values, moved with Up/Down arrows (as Acrobat's Set Field Calculation Order), Alt+↑/↓ or by dragging. A card says what is wrong with the order and holds Fix order; a loop has a card of its own that opens one of its formulas. On the page, each calculated field shows its step, and arrows show what the field in focus reads and what reads it. Estimates unchanged.
|
||||
- **2026-10-20 (M6, calculation order):** The order a form's fields calculate in (`/CO`) is read, checked, fixed and set headlessly with `form_calculation_order`: it says which fields read one calculated later (they show stale values), which read each other in a loop, and which the PDF's order leaves out; Fix order moves only what must move, in one undo step, and the form recalculates. Editing a formula no longer moves its field to the end of the order, and a field the order lists twice calculates once. Estimates unchanged.
|
||||
- **2026-10-20 (M2, #3574):** Forms without Resources use page Properties for ActualText and layer visibility. Synthetic tests cover boundaries, nested/shared forms, and save/reopen. Estimates unchanged.
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Regression tests for inline images whose unfiltered samples contain `"EI "` (#3107).
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
|
||||
|
||||
fn build_pdf(pages: &[Vec<u8>], extra_objects: &[(usize, Vec<u8>)], page_xobjects: &str) -> Vec<u8> {
|
||||
let n = pages.len();
|
||||
let font = 3 + 2 * n;
|
||||
let mut objs: Vec<(usize, Vec<u8>)> = Vec::new();
|
||||
objs.push((1, b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()));
|
||||
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 3 + 2 * i)).collect();
|
||||
objs.push((2, format!("<< /Type /Pages /Kids [{}] /Count {n} >>", kids.join(" ")).into_bytes()));
|
||||
for (i, content) in pages.iter().enumerate() {
|
||||
let page_num = 3 + 2 * i;
|
||||
let stream_num = 4 + 2 * i;
|
||||
objs.push((
|
||||
page_num,
|
||||
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents {stream_num} 0 R /Resources << /Font << /F1 {font} 0 R >> {page_xobjects} >> >>")
|
||||
.into_bytes(),
|
||||
));
|
||||
let mut stm = format!("<< /Length {} >>\nstream\n", content.len()).into_bytes();
|
||||
stm.extend_from_slice(content);
|
||||
stm.extend_from_slice(b"\nendstream");
|
||||
objs.push((stream_num, stm));
|
||||
}
|
||||
objs.push((font, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec()));
|
||||
for (id, body) in extra_objects {
|
||||
objs.push((*id, body.clone()));
|
||||
}
|
||||
objs.sort_by_key(|(id, _)| *id);
|
||||
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offs = Vec::with_capacity(objs.len());
|
||||
for (id, body) in &objs {
|
||||
offs.push(out.len());
|
||||
out.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
|
||||
out.extend_from_slice(body);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref_offset = out.len();
|
||||
let size = objs.len() + 1;
|
||||
out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes());
|
||||
for off in offs {
|
||||
out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {size} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn page_stream(image: &[u8], n: usize) -> Vec<u8> {
|
||||
let mut s = b"q 200 0 0 200 100 300 cm ".to_vec();
|
||||
s.extend_from_slice(image);
|
||||
s.extend_from_slice(format!(" Q BT /F1 18 Tf 72 700 Td (After image P{n}) Tj ET").as_bytes());
|
||||
s
|
||||
}
|
||||
|
||||
fn mean_grey_in_box(rgba: &[u8], width: u32, x0: u32, y0: u32, x1: u32, y1: u32) -> f64 {
|
||||
let mut sum = 0u64;
|
||||
let mut count = 0u64;
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
let idx = ((y * width + x) * 4) as usize;
|
||||
sum += u64::from(rgba[idx]);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
sum as f64 / count.max(1) as f64
|
||||
}
|
||||
|
||||
fn dark_pixels_in_box(rgba: &[u8], width: u32, x0: u32, y0: u32, x1: u32, y1: u32) -> usize {
|
||||
let mut dark = 0usize;
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
let idx = ((y * width + x) * 4) as usize;
|
||||
if rgba[idx] < 128 && rgba[idx + 1] < 128 && rgba[idx + 2] < 128 {
|
||||
dark += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
dark
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfiltered_inline_image_samples_containing_ei_are_not_executed_as_content() {
|
||||
let samples = b"0EI Q BT /F1 18 Tf 72 600 Td (Injected) Tj ET q";
|
||||
let w = samples.len();
|
||||
let mut hexed = String::with_capacity(w * 2 + 1);
|
||||
for b in samples {
|
||||
let _ = write!(hexed, "{b:02X}");
|
||||
}
|
||||
hexed.push('>');
|
||||
|
||||
let mut img1 = format!("BI /W {w} /H 1 /BPC 8 /CS /G /L {w} ID ").into_bytes();
|
||||
img1.extend_from_slice(samples);
|
||||
img1.extend_from_slice(b" EI");
|
||||
|
||||
let mut img2 = format!("BI /W {w} /H 1 /BPC 8 /CS /G /L {w} ID 0AB").into_bytes();
|
||||
img2.extend_from_slice(&samples[3..]);
|
||||
img2.extend_from_slice(b" EI");
|
||||
|
||||
let img3 = format!("BI /W {w} /H 1 /BPC 8 /CS /G /F /AHx ID {hexed} EI").into_bytes();
|
||||
let img4 = b"BI /W 4 /H 1 /BPC 8 /CS /G ID \x20\x60\xa0\xe0 EI".to_vec();
|
||||
|
||||
let pdf = Arc::new(build_pdf(&[page_stream(&img1, 1), page_stream(&img2, 2), page_stream(&img3, 3), page_stream(&img4, 4)], &[], ""));
|
||||
|
||||
let mut renderer = PageRenderer::new(Arc::clone(&pdf), RenderConfig::default());
|
||||
let mut greys = Vec::new();
|
||||
for page_idx in 0..4 {
|
||||
let text_page = renderer.render(RenderRequest { page: page_idx, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false });
|
||||
assert!(text_page.error.is_none(), "page {} text error: {:?}", page_idx + 1, text_page.error);
|
||||
let text = text_page.text.expect("page text");
|
||||
let plain = text.plain_text();
|
||||
assert_eq!(plain.trim(), format!("After image P{}", page_idx + 1), "page {}", page_idx + 1);
|
||||
assert!(text.find("Injected").is_empty(), "page {} must not match Injected in Find", page_idx + 1);
|
||||
|
||||
let rendered = renderer.render(RenderRequest { page: page_idx, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
|
||||
assert!(rendered.error.is_none(), "page {} render error: {:?}", page_idx + 1, rendered.error);
|
||||
|
||||
let dark = dark_pixels_in_box(&rendered.rgba, rendered.width, 72, 172, 200, 196);
|
||||
assert_eq!(dark, 0, "page {} must not draw Injected at (72, 600)", page_idx + 1);
|
||||
|
||||
let grey = mean_grey_in_box(&rendered.rgba, rendered.width, 110, 302, 290, 482);
|
||||
greys.push(grey);
|
||||
}
|
||||
|
||||
// Page 1 and Page 3 have the exact same 47 samples (unfiltered vs ASCIIHexDecode).
|
||||
assert!((greys[0] - greys[2]).abs() < 1.0, "page 1 mean grey ({}) must match page 3 ({})", greys[0], greys[2]);
|
||||
assert!((greys[0] - greys[1]).abs() < 2.0, "page 1 mean grey ({}) must match control page 2 ({})", greys[0], greys[1]);
|
||||
assert!((greys[3] - 128.0).abs() < 2.0, "page 4 control mean grey ({})", greys[3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_xobject_inline_image_with_ei_in_samples_keeps_all_samples() {
|
||||
let form_content = b"q 200 0 0 200 100 300 cm BI /W 12 /H 1 /BPC 8 /CS /G ID 0EI 1 0 0 rg EI Q BT /F1 18 Tf 72 700 Td (Form after) Tj ET";
|
||||
let mut form_obj = format!(
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Length {} >>\nstream\n",
|
||||
form_content.len()
|
||||
)
|
||||
.into_bytes();
|
||||
form_obj.extend_from_slice(form_content);
|
||||
form_obj.extend_from_slice(b"\nendstream");
|
||||
|
||||
let page_content = b"/Fm1 Do".to_vec();
|
||||
// 1 page => font is object 5, form XObject is object 6.
|
||||
let pdf = Arc::new(build_pdf(&[page_content], &[(6, form_obj)], "/XObject << /Fm1 6 0 R >>"));
|
||||
|
||||
let mut renderer = PageRenderer::new(Arc::clone(&pdf), RenderConfig::default());
|
||||
let rendered = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
|
||||
assert!(rendered.error.is_none(), "{:?}", rendered.error);
|
||||
|
||||
let grey = mean_grey_in_box(&rendered.rgba, rendered.width, 110, 302, 290, 482);
|
||||
assert!((grey - 54.37).abs() < 1.0, "form inline image mean grey {grey} must be ~54.37 (not ~2 from 1-byte cut)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfiltered_inline_image_layouts_and_mismatched_l_do_not_inject_content() {
|
||||
let inject_prefix = b"0EI Q BT /F1 18 Tf 72 600 Td (Injected) Tj ET q";
|
||||
let make_image = |params: &str, len: usize| {
|
||||
let mut samples = inject_prefix.to_vec();
|
||||
samples.resize(len, b'0');
|
||||
let mut img = format!("BI {params} ID ").into_bytes();
|
||||
img.extend_from_slice(&samples);
|
||||
img.extend_from_slice(b" EI");
|
||||
img
|
||||
};
|
||||
|
||||
let cases = [
|
||||
("/W 16 /H 2 /CS /RGB /BPC 8", 96),
|
||||
("/W 12 /H 2 /CS /CMYK /BPC 8", 96),
|
||||
("/W 70 /H 4 /CS /G /BPC 1", 36),
|
||||
("/IM true /W 70 /H 4", 36),
|
||||
("/W 47 /H 1 /CS /G /BPC 8 /L 5", 47),
|
||||
];
|
||||
|
||||
let pages: Vec<Vec<u8>> = cases.iter().enumerate().map(|(i, (params, len))| page_stream(&make_image(params, *len), i + 1)).collect();
|
||||
|
||||
let pdf = Arc::new(build_pdf(&pages, &[], ""));
|
||||
let mut renderer = PageRenderer::new(Arc::clone(&pdf), RenderConfig::default());
|
||||
|
||||
for (i, (params, _)) in cases.iter().enumerate() {
|
||||
let page_idx = i;
|
||||
let text_page = renderer.render(RenderRequest { page: page_idx, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false });
|
||||
assert!(text_page.error.is_none(), "case {params} text error: {:?}", text_page.error);
|
||||
let text = text_page.text.expect("page text");
|
||||
let plain = text.plain_text();
|
||||
assert_eq!(plain.trim(), format!("After image P{}", i + 1), "case {params}");
|
||||
assert!(text.find("Injected").is_empty(), "case {params} must not match Injected in Find");
|
||||
|
||||
let rendered = renderer.render(RenderRequest { page: page_idx, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
|
||||
assert!(rendered.error.is_none(), "case {params} render error: {:?}", rendered.error);
|
||||
|
||||
let dark = dark_pixels_in_box(&rendered.rgba, rendered.width, 72, 172, 200, 196);
|
||||
assert_eq!(dark, 0, "case {params} must not draw Injected at (72, 600)");
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -115,7 +115,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:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- 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.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>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). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Raw-deflate fallback preserves decoding-limit errors, including through tolerant COS stream decoding. 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. Page marks, added content and print output retain small page-unit transform coefficients when serialized. Forms without Resources use page Properties for ActualText and layer visibility; own dictionaries stop fallback. |
|
||||
| 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. Page marks, added content and print output retain small page-unit transform coefficients when serialized. Forms without Resources use page Properties for ActualText and layer visibility; own dictionaries stop fallback. Oversized missing inline-image samples, including header and terminator overhead, no longer pin the content window (#3107 / PR #3438); normal large streamed images retain deterministic sample boundaries. |
|
||||
| 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. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. |
|
||||
| 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<br>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). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. |
|
||||
|
||||
+318
-3
@@ -35,12 +35,12 @@ pub mod ops;
|
||||
|
||||
use crate::content::ops::TypedInstruction;
|
||||
use crate::object;
|
||||
use crate::object::dict::InlineImageDict;
|
||||
use crate::object::dict::{Dict, InlineImageDict};
|
||||
use crate::object::name::{Name, skip_name_like};
|
||||
use crate::object::{Array, Null, Number, Object, Stream};
|
||||
use crate::reader::Reader;
|
||||
use crate::reader::{Readable, ReaderContext, ReaderExt, Skippable};
|
||||
use crate::trivia::{Comment, is_white_space_character};
|
||||
use crate::trivia::{Comment, is_regular_character, is_white_space_character};
|
||||
use crate::util::find_needle;
|
||||
use core::array;
|
||||
use core::fmt::{Debug, Formatter};
|
||||
@@ -242,7 +242,33 @@ impl<'a> UntypedIter<'a> {
|
||||
let mut lookahead_touched = false;
|
||||
let mut accepted = false;
|
||||
|
||||
'outer: while let Some(pos) = find_needle(self.reader.tail()?, b"EI") {
|
||||
// PdfCraft patch: an unfiltered inline image's byte length is determined by
|
||||
// its parameters (and `/L` or `/Length` when present and consistent), so
|
||||
// `EI` inside its samples does not cut the image short (#3107).
|
||||
match unfiltered_image_end(
|
||||
stream_data,
|
||||
&dict,
|
||||
self.complete,
|
||||
start_offset.saturating_sub(self.committed),
|
||||
) {
|
||||
UnfilteredEnd::Found(end_data, end_after_ei) => {
|
||||
let image_data = stream_data.get(..end_data)?;
|
||||
let stream = Stream::new(image_data, dict.clone());
|
||||
self.stack.push(Object::Stream(stream))?;
|
||||
self.reader.read_bytes(end_after_ei)?;
|
||||
self.reader.skip_white_spaces();
|
||||
accepted = true;
|
||||
}
|
||||
UnfilteredEnd::NeedsMore => {
|
||||
self.needs_more = true;
|
||||
return None;
|
||||
}
|
||||
UnfilteredEnd::Fallback => {}
|
||||
}
|
||||
|
||||
'outer: while !accepted
|
||||
&& let Some(pos) = find_needle(self.reader.tail()?, b"EI")
|
||||
{
|
||||
self.reader.read_bytes(pos)?;
|
||||
|
||||
if self.reader.peek_bytes(2) == Some(b"EI") {
|
||||
@@ -852,9 +878,163 @@ mod macros {
|
||||
pub(crate) use op6;
|
||||
}
|
||||
|
||||
/// PdfCraft patch: outcome of checking an unfiltered inline image's deterministic byte length.
|
||||
enum UnfilteredEnd {
|
||||
/// Deterministic end found: `(end_data, end_after_ei)`.
|
||||
Found(usize, usize),
|
||||
/// Window ran out before the terminator after the expected sample bytes could be decided.
|
||||
NeedsMore,
|
||||
/// Not an unfiltered image with a valid terminator at its computed length; use the heuristic.
|
||||
Fallback,
|
||||
}
|
||||
|
||||
/// PdfCraft patch: an unfiltered inline image's sample byte count, including byte padding per row,
|
||||
/// and `/Length` or `/L` when present and consistent (#3107).
|
||||
fn unfiltered_image_len(dict: &Dict<'_>) -> Option<usize> {
|
||||
for key in [b"Filter".as_slice(), b"F"] {
|
||||
if let Some(filter) = dict.get::<Object<'_>>(key)
|
||||
&& !matches!(filter, Object::Null(_))
|
||||
&& !matches!(&filter, Object::Array(filters) if filters.iter::<Object<'_>>().next().is_none())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
for (full, short) in [
|
||||
(b"Width".as_slice(), b"W".as_slice()),
|
||||
(b"Height", b"H"),
|
||||
(b"ColorSpace", b"CS"),
|
||||
(b"BitsPerComponent", b"BPC"),
|
||||
(b"ImageMask", b"IM"),
|
||||
] {
|
||||
if let (Some(full), Some(short)) = (dict.get::<Object<'_>>(full), dict.get::<Object<'_>>(short))
|
||||
&& full != short
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let get = |full: &[u8], short: &[u8]| {
|
||||
dict.get::<Object<'_>>(full)
|
||||
.or_else(|| dict.get::<Object<'_>>(short))
|
||||
};
|
||||
let int = |obj: &Object<'_>| match obj {
|
||||
Object::Number(n) => i32::try_from(n.as_i64())
|
||||
.ok()
|
||||
.filter(|&i| Number::from_i32(i) == *n)
|
||||
.map(i64::from),
|
||||
_ => None,
|
||||
};
|
||||
let width = usize::try_from(int(&get(b"Width", b"W")?)?)
|
||||
.ok()
|
||||
.filter(|n| *n > 0)?;
|
||||
let height = usize::try_from(int(&get(b"Height", b"H")?)?)
|
||||
.ok()
|
||||
.filter(|n| *n > 0)?;
|
||||
let mask = match get(b"ImageMask", b"IM") {
|
||||
None | Some(Object::Null(_) | Object::Boolean(false)) => false,
|
||||
Some(Object::Boolean(true)) => true,
|
||||
_ => return None,
|
||||
};
|
||||
let bits = match get(b"BitsPerComponent", b"BPC") {
|
||||
None | Some(Object::Null(_)) => None,
|
||||
Some(value) => Some(int(&value)?),
|
||||
};
|
||||
let (components, bits) = if mask {
|
||||
if bits.is_some_and(|bits| bits != 1) {
|
||||
return None;
|
||||
}
|
||||
(1usize, 1usize)
|
||||
} else {
|
||||
let (components, max_bits) = match get(b"ColorSpace", b"CS")? {
|
||||
Object::Name(name) => match name.as_ref() {
|
||||
b"DeviceGray" | b"G" => (1, 16),
|
||||
b"DeviceRGB" | b"RGB" => (3, 16),
|
||||
b"DeviceCMYK" | b"CMYK" => (4, 16),
|
||||
_ => return None,
|
||||
},
|
||||
Object::Array(space) => {
|
||||
let mut items = space.iter::<Object<'_>>();
|
||||
if !matches!(items.next(), Some(Object::Name(n)) if matches!(n.as_ref(), b"Indexed" | b"I"))
|
||||
|| items.count() != 3
|
||||
{
|
||||
return None;
|
||||
}
|
||||
(1, 8)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let bits = match bits? {
|
||||
bits @ (1 | 2 | 4 | 8 | 16) => bits as usize,
|
||||
_ => return None,
|
||||
};
|
||||
if bits > max_bits {
|
||||
return None;
|
||||
}
|
||||
(components, bits)
|
||||
};
|
||||
let row = width.checked_mul(components)?.checked_mul(bits)?.div_ceil(8);
|
||||
row.checked_mul(height)
|
||||
}
|
||||
|
||||
/// PdfCraft patch: locate the deterministic `EI` terminator of an unfiltered inline image (#3107).
|
||||
fn unfiltered_image_end(data: &[u8], dict: &Dict<'_>, complete: bool, header_bytes: usize) -> UnfilteredEnd {
|
||||
let Some(end_data) = unfiltered_image_len(dict) else {
|
||||
return UnfilteredEnd::Fallback;
|
||||
};
|
||||
// An inline image shares the content window's budget, not the larger decoded-image
|
||||
// budget. Include its dictionary/header and the shortest whitespace + EI + delimiter
|
||||
// look-ahead, or one more byte beyond any separator whitespace already present.
|
||||
// Missing bytes that cannot fit must use the existing heuristic, not pin the window
|
||||
// and discard following content. Already-available exact ends still win.
|
||||
let missing_end = if complete
|
||||
|| end_data
|
||||
.checked_add(4)
|
||||
.and_then(|minimum| data.len().checked_add(1).map(|next| minimum.max(next)))
|
||||
.and_then(|needed| needed.checked_add(header_bytes))
|
||||
.is_none_or(|needed| needed > crate::MAX_DECODED_STREAM)
|
||||
{
|
||||
UnfilteredEnd::Fallback
|
||||
} else {
|
||||
UnfilteredEnd::NeedsMore
|
||||
};
|
||||
let Some(after) = data.get(end_data..) else {
|
||||
return missing_end;
|
||||
};
|
||||
let Some(&first) = after.first() else {
|
||||
return missing_end;
|
||||
};
|
||||
if !is_white_space_character(first) {
|
||||
return UnfilteredEnd::Fallback;
|
||||
}
|
||||
let Some(separator) = after.iter().position(|&b| !is_white_space_character(b)) else {
|
||||
return missing_end;
|
||||
};
|
||||
let Some(rest) = after.get(separator..) else {
|
||||
return UnfilteredEnd::Fallback;
|
||||
};
|
||||
if rest.first() != Some(&b'E') {
|
||||
return UnfilteredEnd::Fallback;
|
||||
}
|
||||
let Some(&second) = rest.get(1) else {
|
||||
return missing_end;
|
||||
};
|
||||
if second != b'I' {
|
||||
return UnfilteredEnd::Fallback;
|
||||
}
|
||||
let Some(end_after_ei) = end_data.checked_add(separator).and_then(|ei| ei.checked_add(2)) else {
|
||||
return UnfilteredEnd::Fallback;
|
||||
};
|
||||
match rest.get(2) {
|
||||
Some(&b) if !is_regular_character(b) => UnfilteredEnd::Found(end_data, end_after_ei),
|
||||
Some(_) => UnfilteredEnd::Fallback,
|
||||
None if complete => UnfilteredEnd::Found(end_data, end_after_ei),
|
||||
None => missing_end,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ContentTokenizer, UntypedIter};
|
||||
use crate::reader::ReaderExt;
|
||||
|
||||
fn operators(data: &[u8]) -> Vec<Vec<u8>> {
|
||||
let mut tokenizer = UntypedIter::new(data);
|
||||
@@ -1014,4 +1194,139 @@ mod tests {
|
||||
assert_eq!(windowed, expected, "{:?}", String::from_utf8_lossy(&data));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfiltered_inline_image_with_ei_in_samples_is_not_cut_early() {
|
||||
// 48-byte unfiltered DeviceGray image whose first four bytes are `0EI `
|
||||
// followed by valid page content operators (`Q BT ... Tj ET q`).
|
||||
let data = b"q 100 0 0 100 72 650 cm \
|
||||
BI /W 48 /H 1 /CS /G /BPC 8 ID 0EI Q BT /F1 18 Tf 72 600 Td (Injected) Tj ET q! EI Q \
|
||||
BT /F1 14 Tf 72 550 Td (After) Tj ET";
|
||||
assert_eq!(
|
||||
operators(data),
|
||||
vec![
|
||||
b"q".to_vec(),
|
||||
b"cm".to_vec(),
|
||||
b"BI".to_vec(),
|
||||
b"Q".to_vec(),
|
||||
b"BT".to_vec(),
|
||||
b"Tf".to_vec(),
|
||||
b"Td".to_vec(),
|
||||
b"Tj".to_vec(),
|
||||
b"ET".to_vec(),
|
||||
]
|
||||
);
|
||||
let expected = slice_instructions(data);
|
||||
for piece in 1..=data.len() {
|
||||
assert_eq!(windowed_instructions(data, || piece), expected, "piece size {piece}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_unfiltered_inline_image_does_not_hold_following_content() {
|
||||
for length in [crate::MAX_DECODED_STREAM - 1, crate::MAX_DECODED_STREAM, crate::MAX_DECODED_STREAM + 1] {
|
||||
// The next BI supplies the existing EI heuristic's bounded look-ahead. The
|
||||
// stream is not exhausted: an impossible declaration must not pin this window.
|
||||
let data = format!(
|
||||
"BI /W {length} /H 1 /CS /G /BPC 8 ID x EI BT (After) Tj ET \
|
||||
BI /W 1 /H 1 /CS /G /BPC 8 ID y EI Q"
|
||||
);
|
||||
let mut tokenizer = UntypedIter::new_window(data.as_bytes(), false);
|
||||
for expected in [b"BI".as_slice(), b"BT", b"Tj", b"ET"] {
|
||||
let instruction = tokenizer.next_instruction().expect("following content survives");
|
||||
assert_eq!(instruction.operator.as_ref(), expected, "declared length {length}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfiltered_inline_image_budget_includes_header_and_terminator() {
|
||||
let mut reader = crate::reader::Reader::new(b"/W 1 /H 1 /CS /G /BPC 8 ID ");
|
||||
let inline = reader
|
||||
.read_without_context::<crate::object::dict::InlineImageDict<'_>>()
|
||||
.expect("synthetic inline dictionary");
|
||||
let dict = inline.get_dict();
|
||||
let fits = crate::MAX_DECODED_STREAM - 5; // One sample + whitespace/EI/delimiter.
|
||||
assert!(matches!(super::unfiltered_image_end(b"", dict, false, fits), super::UnfilteredEnd::NeedsMore));
|
||||
assert!(matches!(super::unfiltered_image_end(b"x ", dict, false, fits), super::UnfilteredEnd::Fallback));
|
||||
assert!(matches!(super::unfiltered_image_end(b"x EI", dict, false, fits + 1), super::UnfilteredEnd::Fallback));
|
||||
assert!(matches!(super::unfiltered_image_end(b"x EI", dict, true, usize::MAX), super::UnfilteredEnd::Found(1, 4)));
|
||||
for header in [fits + 1, usize::MAX] {
|
||||
assert!(matches!(super::unfiltered_image_end(b"", dict, false, header), super::UnfilteredEnd::Fallback));
|
||||
// The guard is only for missing bytes, never a reason to reinterpret available samples.
|
||||
assert!(matches!(super::unfiltered_image_end(b"x EI Q", dict, false, header), super::UnfilteredEnd::Found(1, 4)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_unfiltered_inline_image_still_waits_for_its_samples() {
|
||||
// A valid image larger than the interpreter's initial 64 KiB window must
|
||||
// still use its exact length rather than execute operators in its samples.
|
||||
let length = (64 << 10) + 1;
|
||||
let mut data = format!("BI /W {length} /H 1 /CS /G /BPC 8 ID ").into_bytes();
|
||||
let sample_start = data.len();
|
||||
data.extend_from_slice(b"0EI Q BT (Injected) Tj ET q");
|
||||
data.resize(sample_start + length, b'0');
|
||||
data.extend_from_slice(b" EI BT (After) Tj ET");
|
||||
let mut partial = UntypedIter::new_window(&data[..64 << 10], false);
|
||||
assert!(partial.next_instruction().is_none());
|
||||
assert!(partial.needs_more);
|
||||
assert_eq!(partial.committed, 0);
|
||||
assert_eq!(operators(&data), [b"BI".to_vec(), b"BT".to_vec(), b"Tj".to_vec(), b"ET".to_vec()]);
|
||||
let expected = slice_instructions(&data);
|
||||
for piece in [4096, 64 << 10, data.len()] {
|
||||
assert_eq!(windowed_instructions(&data, || piece), expected, "piece size {piece}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfiltered_inline_image_variants_and_fallbacks() {
|
||||
// ImageMask with 1-bit row padding (width 10 -> 2 bytes per row, height 2 -> 4 bytes)
|
||||
let mask_stream = b"BI /IM true /W 10 /H 2 ID 0EI\n EI Q";
|
||||
assert_eq!(operators(mask_stream), vec![b"BI".to_vec(), b"Q".to_vec()]);
|
||||
let expected_mask = slice_instructions(mask_stream);
|
||||
for piece in 1..=mask_stream.len() {
|
||||
assert_eq!(windowed_instructions(mask_stream, || piece), expected_mask);
|
||||
}
|
||||
|
||||
// Indexed color space with 4-bit samples (width 5 -> 3 bytes per row, height 2 -> 6 bytes)
|
||||
// and explicit /L matching computed length.
|
||||
let indexed_stream =
|
||||
b"BI /Width 5 /Height 2 /CS [/I /RGB 1 <000000ffffff>] /BPC 4 /L 6 ID 0EI Q! EI Q";
|
||||
assert_eq!(operators(indexed_stream), vec![b"BI".to_vec(), b"Q".to_vec()]);
|
||||
let expected_indexed = slice_instructions(indexed_stream);
|
||||
for piece in 1..=indexed_stream.len() {
|
||||
assert_eq!(windowed_instructions(indexed_stream, || piece), expected_indexed);
|
||||
}
|
||||
|
||||
// Conflicting /W and /Width falls back to the heuristic scan; mismatched /L does not disrupt the verified layout.
|
||||
let mismatch_l = b"BI /W 4 /H 1 /CS /G /BPC 8 /L 99 ID abcd EI Q";
|
||||
assert_eq!(operators(mismatch_l), vec![b"BI".to_vec(), b"Q".to_vec()]);
|
||||
let conflict_w = b"BI /W 4 /Width 8 /H 1 /CS /G /BPC 8 ID abcd EI Q";
|
||||
assert_eq!(operators(conflict_w), vec![b"BI".to_vec(), b"Q".to_vec()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_layout_keeps_its_computed_length() {
|
||||
let inject = b" EI Q BT /F1 9 Tf (X) Tj ET q";
|
||||
let case = |params: &str, len: usize| {
|
||||
let mut samples = inject.to_vec();
|
||||
samples.resize(len, b'0');
|
||||
let mut data = format!("BI {params} ID ").into_bytes();
|
||||
data.extend_from_slice(&samples);
|
||||
data.extend_from_slice(b" EI Q");
|
||||
data
|
||||
};
|
||||
for (params, len) in [
|
||||
("/W 16 /H 2 /CS /RGB /BPC 8", 96),
|
||||
("/W 12 /H 2 /CS /CMYK /BPC 8", 96),
|
||||
("/W 70 /H 4 /CS /G /BPC 1", 36),
|
||||
("/IM true /W 70 /H 4", 36),
|
||||
("/W 30 /H 2 /CS [/I /RGB 1 <000000ffffff>] /BPC 4 /L 30", 30),
|
||||
("/W 15 /H 2 /CS /G /BPC 16 /L 60", 60),
|
||||
("/W 47 /H 1 /CS /G /BPC 8 /L 5", 47),
|
||||
] {
|
||||
assert_eq!(operators(&case(params, len)), vec![b"BI".to_vec(), b"Q".to_vec()], "{params}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user