M2.6: what is left out past the JBIG2 allowance is said, and redaction stops

From a review of who reads a refusal.

The rule is to leave it out and say so, and it held on some paths only. An image whose JBIG2 mask is
refused no longer comes out opaque. The first image past the allowance reads as that and not as
corrupt. A page reports what it left out. The Office warning stopped blaming forms. Redaction refuses
the run, leaving the document as it was: removing a readable image whole would take what no mark
covers with it, unreported.

The 2 GiB an export holds is twice the document if that is more, so a large honest file loses nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
Grzegorz Martens
2026-10-31 14:13:48 +01:00
co-authored by Claude Opus 5.5
parent 0ed339901e
commit 4aa732ea91
16 changed files with 121 additions and 27 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ impl Automation {
if !export.truncated_pages.is_empty() {
use pdfcraft_engine::compare::{MAX_FORM_DEPTH, MAX_FORM_VISITS};
out["warnings"] = json!([format!(
"forms nested more than {MAX_FORM_DEPTH} deep, or past the first {MAX_FORM_VISITS} forms of a page (forms the page draws itself count too), were left out on the pages in truncated_pages: their text and images are missing from the export, although the viewer may still show them"
"pictures past what one export may hold or decode, or forms nested more than {MAX_FORM_DEPTH} deep, or past the first {MAX_FORM_VISITS} forms of a page (forms the page draws itself count too), were left out on the pages in truncated_pages: their text and images are missing from the export, although the viewer may still show them"
)]);
}
Ok(out)
+3 -3
View File
@@ -1194,7 +1194,7 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }),
&["doc", "field", "event"],
)),
t("doc_export_office", "Export to Word, PowerPoint, HTML, RTF or Markdown", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline), RTF (.rtf) or Markdown (.md or .markdown: pipe tables, images left out), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit are listed in truncated_pages, with a warning; the text and images of the forms left out are missing.")
t("doc_export_office", "Export to Word, PowerPoint, HTML, RTF or Markdown", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline), RTF (.rtf) or Markdown (.md or .markdown: pipe tables, images left out), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit, or that have pictures past what one export may hold or decode, are listed in truncated_pages, with a warning; what was left out is missing.")
.cmd("export.docx")
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.")
@@ -1281,7 +1281,7 @@ pub fn tools() -> Vec<ToolDef> {
.ro()
.cmd("ocr.recognize")
.with(schema(json!({}), &[])),
t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`<name>_Page_<n>_Image_<k>.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, separations) are listed under skipped. Includes unsaved edits.")
t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`<name>_Page_<n>_Image_<k>.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, separations), or past what one export may decode or hold, are listed under skipped, with the reason. Includes unsaved edits.")
.cmd("export.all_images")
.with(schema(
json!({
@@ -1442,7 +1442,7 @@ pub fn tools() -> Vec<ToolDef> {
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.")
.with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])),
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.")
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). JBIG2 images past what one run may decode stay as they are, counted in images_refused. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.")
.cmd("optimize.advanced")
.with(schema(
json!({
+4 -2
View File
@@ -31,8 +31,10 @@ JBIG2 images and masks are decoded through `pdfcraft_cos::ImageBudget` (`pdfcraf
other filters too, with the `/JBIG2Globals` of their own `/DecodeParms` entry, as `JBIG2Decode` gives them: black as 0. An image
whose JBIG2 page isn't its size (PdfCraft's renderer draws the whole page into the image; the page is refused before it is decoded),
or whose globals can't be read or decode to more than 32 MiB (they are decoded before the page's size is known, so they have a
limit of their own), is skipped, and so is one past what one export may decode, which its images share; such a mask is left out and
the image exported opaque, as JPX and CCITT masks are.
limit of their own), is skipped, and such a mask is left out and the image exported opaque, as JPX and CCITT masks are.
Past what one export may decode of JBIG2, which its images share, an image is skipped, and so is one whose mask is past it, which
would otherwise come out showing what the mask hides. An export also stops at 2 GiB of files held, or twice the document if that
is more, since they stay in memory until it ends.
Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples.
Missing, null or malformed Decode arrays keep the default mapping.
Soft-mask Decode intervals map samples to opacity before the mask is resized, including reversed ranges and indirect entries.
+9 -3
View File
@@ -31,14 +31,15 @@ pub struct ImageExport {
/// Images larger than this many pixels are skipped (memory).
const MAX_PIXELS: u64 = 100_000_000;
/// What one export may hold of the files it makes: they are all in memory until it ends, and a file
/// of a few KB can make gigabytes of PNG. Images past it are left out, with the reason.
/// What one export may hold of the files it makes, or twice the file if that is more, so that a file
/// of large images is exported whole: they are all in memory until it ends, and a file of a few KB
/// can make gigabytes of PNG. Images past it are left out, with the reason.
const MAX_EXPORT_BYTES: usize = 2 << 30;
/// The images of `pages` (0-based; each image once, on the first page using it), skipping
/// those with fewer than `min_side` pixels on their shorter side.
pub fn extract_images(doc: &Document, pages: &[usize], min_side: u32) -> ImageExport {
extract_images_within(doc, pages, min_side, &mut ImageBudget::new(doc), MAX_EXPORT_BYTES)
extract_images_within(doc, pages, min_side, &mut ImageBudget::new(doc), MAX_EXPORT_BYTES.max(doc.bytes().len().saturating_mul(2)))
}
/// [`extract_images`] within `budget`, and holding `max_bytes` of files. An image past either is
@@ -155,7 +156,12 @@ fn image(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Result<(&'stat
};
let mask = s.dict.get(b"ImageMask").is_some_and(|m| matches!(&*doc.resolve(m), Object::Bool(true)));
// A stencil mask (`ImageMask true`) has no /SMask or /Mask of its own (8.9.6.2).
let refused = budget.refused();
let mut alpha = if mask { None } else { soft_mask(doc, s, w, h, budget).or_else(|| stencil_mask(doc, s, w, h, budget)) };
// Without its mask the image would come out opaque, showing what the mask hides.
if budget.refused() > refused {
return Err("more JBIG2 decoding than one operation may do".into());
}
let key = if mask { None } else { colour_key(doc, s) };
// Keep the existing JPEG and stencil-mask Decode handling separate from lossless colour images.
let inverted = (jpeg || mask)
+27
View File
@@ -2275,3 +2275,30 @@ fn jbig2_images_past_the_exports_allowance_are_left_out_with_the_reason() {
assert!(crate::extract::image_file_within(&doc, a, &mut budget).is_ok());
assert!(crate::extract::image_file_within(&doc, b, &mut budget).is_err());
}
/// A mask past the allowance can't be left out as an unreadable one is, or the image comes out
/// opaque, showing what the mask hides: the image goes, with the reason.
#[test]
fn an_image_whose_jbig2_mask_is_past_the_allowance_is_left_out() {
use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream};
let mut mask = Dict::new();
mask.set(b"Subtype".to_vec(), Object::name("Image"));
mask.set(b"Width".to_vec(), Object::Int(16));
mask.set(b"Height".to_vec(), Object::Int(2));
mask.set(b"BitsPerComponent".to_vec(), Object::Int(1));
mask.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
mask.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
let mut doc = Document::new_empty();
let mask = doc.add(Object::Stream(Stream::from_raw(mask, pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap())));
let mut image = Dict::new();
image.set(b"Subtype".to_vec(), Object::name("Image"));
image.set(b"Width".to_vec(), Object::Int(1));
image.set(b"Height".to_vec(), Object::Int(1));
image.set(b"BitsPerComponent".to_vec(), Object::Int(8));
image.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
image.set(b"SMask".to_vec(), Object::Ref(mask));
let image = doc.add(Object::Stream(Stream::from_raw(image, vec![0x80])));
assert!(crate::extract::image_file_within(&doc, image, &mut ImageBudget::with_work(u64::MAX)).is_ok());
let refused = crate::extract::image_file_within(&doc, image, &mut ImageBudget::with_work(0)).unwrap_err();
assert!(refused.contains("more JBIG2 decoding than"), "{refused}");
}
+18
View File
@@ -8209,6 +8209,24 @@ fn copy_image_reads_the_globals_of_a_jbig2_picture() {
assert_eq!(truncated, [0]);
}
/// An Office export leaves out a picture it can't encode, as it did. That isn't a page cut short:
/// only a picture past what the export may do is reported as one.
#[test]
fn an_office_export_doesnt_call_an_unreadable_picture_truncated() {
let pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n\
4 0 obj << /Length 31 >> stream\nq 160 0 0 20 20 100 cm /Im1 Do Q\nendstream endobj\n\
5 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /JPXDecode /Length 4 >> stream\nnone\nendstream endobj\n\
trailer << /Root 1 0 R >>\n%%EOF"
.to_vec();
let mut s = Session::new();
let id = s.open("picture.pdf", None, Arc::new(pdf), None).unwrap();
let (pages, truncated) = s.get(id).unwrap().export_pages_within(true, 1 << 20, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX));
assert!(pages[0].images.is_empty());
assert_eq!(truncated, Vec::<usize>::new());
}
/// Copy Image takes a JBIG2 picture as its pixels and as a PNG file, behind another filter
/// (ASCIIHexDecode) too: Export's decoding, through `Document::decoded_image`.
#[test]
+3 -2
View File
@@ -58,7 +58,8 @@ pub fn decode_jbig2_within(data: &[u8], globals: Option<&[u8]>, columns: u32, ro
return Err(FilterError::LimitExceeded(max));
}
let mut out = Rows::new(width, height, false);
image.decode_within(&mut out, work).map_err(|e| corrupt(e.to_string()))?;
// Stopped by what was left, it isn't a damaged image, whatever the decoder calls it.
image.decode_within(&mut out, work).map_err(|e| if *work == 0 { spent() } else { corrupt(e.to_string()) })?;
Ok(out.out)
}
@@ -159,7 +160,7 @@ mod tests {
decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work).unwrap();
assert_eq!(work, 7, "only what it took");
let mut work = cost - 1;
assert!(matches!(decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work), Err(FilterError::Corrupt { .. })), "a step short");
assert!(matches!(decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work), Err(FilterError::Unsupported(_))), "a step short");
assert_eq!(work, 0, "spends all that was left");
// Nothing left: not read at all, so this garbage isn't reported as garbage.
assert!(matches!(decode_jbig2_within(b"garbage", None, w, h, usize::MAX, &mut work), Err(FilterError::Unsupported(_))));
+1 -1
View File
@@ -101,7 +101,7 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
- Lossless JBIG2 uses bounded generic-region encoding or exact-copy symbol dictionaries when
smaller. No lossy symbol substitution is used. Export, redaction and this step read JBIG2
images, with bounded shared globals. All of a run's JBIG2 images share one allowance of
decoding (`pdfcraft_cos::ImageBudget`): about 2,000 scanned pages' worth, plus more for each
decoding (`pdfcraft_cos::ImageBudget`): about 1,000 scanned pages' worth, plus more for each
byte of the file. Images past it stay as they are, and `Report::images_refused` counts them.
- **Discard objects:** thumbnails, alternate images, document tags, print settings.
- **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex
+4 -2
View File
@@ -24,8 +24,7 @@ clear_marks(&mut doc, None)?; // remove marks without app
is Flate, and refers to no globals: when no other image uses them, they leave the file with the
original on the full rewrite; when one does, they stay as they are, and may hold the shapes of
marks under the area (not where they were). Codecs redaction doesn't decode (DCT, JPX) are
removed whole (fail-closed), and so is a JBIG2 image past what one redaction may decode
(`pdfcraft_cos::ImageBudget`), one whose page isn't its size (the renderer
removed whole (fail-closed), and so is a JBIG2 image whose page isn't its size (the renderer
draws the whole page into the image, so the rows cleared wouldn't be the ones shown) or of more
than one bit or component, and Group 3 on bytes (`/K` 0 or more, `/EncodedByteAlign`): PdfCraft's
renderer draws some of its layouts (the fill ending each end-of-line code on a byte) as other
@@ -34,6 +33,9 @@ clear_marks(&mut doc, None)?; // remove marks without app
and a byte per row, plus 4 KiB (ASCIIHex text broken into lines under Flate is a little over
twice; for a fax image's code, about a byte per pixel), are removed whole;
cleared copies drop `/Alternates` so alternate pixels cannot survive;
A run that would decode more JBIG2 than one redaction may (`pdfcraft_cos::ImageBudget`) is refused
and changes nothing: such an image can't be cleared, and removing it whole would take what no mark
covers with it.
- **vectors**: covered paths are removed; partly covered paths and shadings are clipped so
nothing paints inside the areas;
- **form XObjects**: rewritten recursively into new objects (pages sharing the original keep it);
+13 -1
View File
@@ -70,6 +70,10 @@ pub enum RedactError {
/// could not be shown complete.
#[error("the document's objects nest too deeply to remove hidden information completely; the operation was refused")]
TooDeep,
/// Past what one redaction may decode an image can't be cleared, and removing it whole would
/// take what no mark covers with it, unreported.
#[error("the images under these marks are more than one redaction can decode, so nothing was changed; redact fewer pages at a time")]
ImagesPastAllowance,
#[error(transparent)]
Edit(#[from] pdfcraft_edit::EditError),
#[error(transparent)]
@@ -377,6 +381,12 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
/// [`apply`] without the copy: an error can leave `doc` part redacted.
fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, RedactError> {
let images = pdfcraft_cos::ImageBudget::new(doc);
apply_within(doc, pages, images)
}
/// [`apply_to`], its images decoded within `images`.
fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_cos::ImageBudget) -> Result<Report, RedactError> {
let (all_marks, unreadable) = marks_checked(doc);
// A mark with no readable area is not applied, and applying the others would look complete:
// refuse before anything changes. Only the pages being applied count.
@@ -412,7 +422,6 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Redac
let widget_owner: Vec<(ObjRef, ObjRef)> =
pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.obj)).collect::<Vec<_>>()).collect();
let listed_before = listed_annotations(doc);
let images = pdfcraft_cos::ImageBudget::new(doc);
let mut applying = Applying {
report,
doomed_fields: Vec::new(),
@@ -428,6 +437,9 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Redac
redact_page_content(doc, &mut applying, pi, &page, &rects)?;
remove_page_annotations(doc, &mut applying, &page, &rects, page_marks)?;
}
if applying.images.refused() > 0 {
return Err(RedactError::ImagesPastAllowance);
}
let Applying { mut report, doomed_fields, doomed_xobjects, retired_properties, mut removed_everywhere, .. } = applying;
// 2b. Replies and pop-ups of a removed annotation can be listed on another page than their
+18 -3
View File
@@ -747,8 +747,9 @@ fn jbig2_globals(secret: &[u8]) -> Vec<u8> {
g
}
/// An image the run's allowance can't decode can't be cleared, so it is removed whole, like any
/// other image that can't be read: what a mark covers never stays.
/// An image the run's allowance can't decode can't be cleared. Removed whole, like an image that
/// can't be read, it would take what no mark covers with it without a word, so the run stops there,
/// and [`apply`] leaves the document as it was.
#[test]
fn a_jbig2_image_past_the_runs_allowance_is_removed_whole() {
let mut dict = Dict::new();
@@ -762,7 +763,21 @@ fn a_jbig2_image_past_the_runs_allowance_is_removed_whole() {
let half = [[0.0, 0.0, 0.5, 1.0]];
let clear = |work| crate::image::clear(&doc, &image, &pdfcraft_content::Matrix::IDENTITY, &half, &mut pdfcraft_cos::ImageBudget::with_work(work));
assert!(matches!(clear(u64::MAX), Ok(Some(_))), "cleared in a copy");
assert!(matches!(clear(0), Err(())), "removed whole");
assert!(matches!(clear(0), Err(())), "it can't be cleared");
let coded = pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap();
let dict = "/Type /XObject /Subtype /Image /Width 16 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode";
let mut doc = one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(dict, coded)]);
mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], "");
let refused = crate::apply_within(&mut doc, None, pdfcraft_cos::ImageBudget::with_work(0));
assert!(matches!(refused, Err(crate::RedactError::ImagesPastAllowance)), "{refused:?}");
let mut doc = one_page(
b"q 160 0 0 20 0 0 cm /Im Do Q",
"/XObject << /Im 7 0 R >>",
vec![stream(dict, pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap())],
);
mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], "");
assert!(crate::apply_within(&mut doc, None, pdfcraft_cos::ImageBudget::with_work(u64::MAX)).is_ok());
}
/// A JBIG2 image (a scan the optimizer stored as JBIG2) has its covered pixels cleared in a copy,
+3 -1
View File
@@ -3657,7 +3657,9 @@ fn the_jbig2_images_of_a_page_share_one_allowance() {
assert_eq!(drawn(one), (true, false), "enough for one");
assert_eq!(drawn(2 * one - 1), (true, false), "a step short of both");
assert_eq!(drawn(2 * one), (true, true), "enough for both");
let [_, second] = pages(one);
let [first, second] = pages(one);
assert!(first.warnings.contains(&RenderWarning::ContentTruncated), "the page says it left something out: {:?}", first.warnings);
assert!(pages(2 * one)[0].warnings.is_empty(), "and only then");
assert!(black(&second, 10), "the next page has its own allowance, though this one spent all of it");
let usual = PageRenderer::new(pdf.clone(), RenderConfig::default()).render(RenderRequest {
page: 0,
+2 -2
View File
@@ -7189,7 +7189,7 @@ status = "shipped"
commands = ["export.all_images"]
tools = ["doc_export_all_images"]
evidence = ["crates/create/src/tests.rs::ccitt_fax_images_export_as_their_picture", "crates/create/src/tests.rs::group_3_ccitt_masks_and_parameters_by_reference_export_as_their_picture", "crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/create/src/tests.rs::soft_mask_decode_ranges_become_exported_alpha", "crates/create/src/tests.rs::soft_mask_decode_maps_packed_and_jpeg_masks_before_resizing", "crates/automation/tests/it/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/it/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps", "crates/create/src/tests.rs::jbig2_images_export_as_their_picture", "crates/create/src/tests.rs::jbig2_globals_are_decoded_within_a_limit_of_their_own", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/create/src/tests.rs::symbol_coded_jbig2_images_export_as_their_picture", "crates/create/src/tests.rs::jbig2_images_behind_another_filter_export_as_their_picture"]
notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX masks are left out (image exported opaque); JPX images and separations reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Still limited: 16-bit samples use high bytes; general JPEG image Decode, ICC transforms, and calibrated colors remain unsupported. CCITT fax images (Group 3 and 4, with their /DecodeParms, behind other filters too) export as their picture, masks included; byte-aligned Group 3 is read by the end-of-line codes when the data starts with one and on bytes otherwise, and one cut short is reported. Still limited: byte-aligned Group 3 with codes but none before the first row (not as T.4 sends them) is reported, or at /K 0 may export wrong. JBIG2 images (with the /JBIG2Globals of their own /DecodeParms entry, within 32 MiB, behind other filters too) export as their picture, masks included; the JBIG2 page has to be the image's size (the renderer draws the whole page into the image)."
notes = "each image once (page resources and nested forms), up to what one export may decode of JBIG2 and hold of files (2 GiB or twice the document), images past that left out with the reason; JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX masks are left out (image exported opaque); JPX images and separations reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Still limited: 16-bit samples use high bytes; general JPEG image Decode, ICC transforms, and calibrated colors remain unsupported. CCITT fax images (Group 3 and 4, with their /DecodeParms, behind other filters too) export as their picture, masks included; byte-aligned Group 3 is read by the end-of-line codes when the data starts with one and on bytes otherwise, and one cut short is reported. Still limited: byte-aligned Group 3 with codes but none before the first row (not as T.4 sends them) is reported, or at /K 0 may export wrong. JBIG2 images (with the /JBIG2Globals of their own /DecodeParms entry, within 32 MiB, behind other filters too) export as their picture, masks included; the JBIG2 page has to be the image's size (the renderer draws the whole page into the image)."
[[feature]]
id = "create.export-html"
@@ -7409,7 +7409,7 @@ status = "partial"
commands = ["optimize.advanced", "optimize.reduce"]
tools = ["doc_optimize", "doc_reduce"]
evidence = ["crates/optimize/tests/optimize.rs::eight_bit_black_and_white_images_become_one_bit", "crates/filters/src/ccitt.rs::bitmaps_round_trip", "crates/filters/src/ccitt.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/filters/src/ccitt.rs::what_it_cannot_encode_is_an_error", "crates/optimize/tests/optimize.rs::black_and_white_images_become_ccitt_group_4", "crates/optimize/tests/optimize.rs::group_3_fax_images_become_group_4", "crates/optimize/tests/optimize.rs::rgb_and_two_colour_indexed_black_and_white_images_become_one_bit", "crates/optimize/tests/optimize.rs::black_and_white_rgb_used_beyond_the_page_content_read_stays_rgb", "crates/optimize/tests/optimize.rs::black_and_white_rgb_drawn_only_where_the_optimizer_reads_becomes_grey", "crates/optimize/tests/optimize.rs::black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_ccitt_group_4", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_saves_an_optimized_copy", "crates/filters/src/mq.rs::the_annex_h_test_sequence_encodes_to_its_bytes", "crates/filters/src/jbig2.rs::bitmaps_round_trip", "crates/filters/src/jbig2.rs::a_page_of_text_like_marks_round_trips_and_shrinks", "crates/optimize/tests/optimize.rs::black_and_white_images_become_jbig2", "crates/automation/tests/it/automation.rs::doc_optimize_stores_black_and_white_images_as_jbig2", "crates/filters/src/jbig2/symbols.rs::repeated_marks_are_stored_once", "crates/filters/src/jbig2/symbols.rs::scattered_marks_round_trip", "crates/optimize/tests/optimize.rs::rendered_text_becomes_jbig2_symbols", "crates/optimize/tests/optimize.rs::jbig2_scans_are_averaged_and_stored_again", "crates/optimize/src/images.rs::jbig2_images_past_the_runs_allowance_stay_as_they_are"]
notes = "All of a run's JBIG2 images share one allowance of decoding (about 2,000 scanned pages' worth, more for a longer file); those past it stay as they are and are counted in the report, which doc_optimize returns and the panel doesn't show yet. CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB, RGB with a profile, RGB drawn or listed where /DefaultGray or /DefaultRGB is in force or referred to from anywhere but the page and form resources read (an annotation's appearance or a pattern drawing it, directly or through resources or a form shared with a page; another image's /Alternates), and RGB whose soft mask has a /Matte aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller (damaged Group 3 with end-of-line codes as the decoder recovers it, as it reports no damage there; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded). CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Lossless JBIG2 with bounded exact-copy symbol coding is available too, with image export; lossy JBIG2 remains unsupported. JBIG2 images are read too: averaged like any (under Retain stored as JBIG2 again), and, not resampled, stored again as the compression asks when that is smaller (as JBIG2 symbols where its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact repeats and is often larger, and then stays as it is; one whose JBIG2 page isn't its size is left alone. Not yet: inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples."
notes = "All of a run's JBIG2 images share one allowance of decoding (about 1,000 scanned pages' worth, more for a longer file); those past it stay as they are and are counted in the report, which doc_optimize returns and the panel doesn't show yet. CCITT Group 4, lossless: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size does it too; doc_optimize mono) stores a 1-bit image of one colour component (grey, indexed, a separation) or an image mask, kept with no filter or general-purpose ones, as Group 4 when that is smaller as stored; the samples decode to the same bits, so colour space, decode array and masks stay. Packed to one bit first, then stored the same way (colour-key masks aside): eight-bit grey of only black and white, eight-bit DeviceRGB of only black and white (as DeviceGray; calibrated RGB, RGB with a profile, RGB drawn or listed where /DefaultGray or /DefaultRGB is in force or referred to from anywhere but the page and form resources read (an annotation's appearance or a pattern drawing it, directly or through resources or a form shared with a page; another image's /Alternates), and RGB whose soft mask has a /Matte aside), and an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1 (keeping its palette), unless the grey or colour settings make it smaller still, as they can a dithered scan downsampled to JPEG, its soft mask counted. Group 4 wins on scans; on clean computer-drawn 1-bit art Flate can be smaller, and the image then stays. Group 3 fax images are stored again as Group 4 when that is smaller (damaged Group 3 with end-of-line codes as the decoder recovers it, as it reports no damage there; byte-aligned Group 3 with end-of-line codes but none before the first row, not as T.4 sends them, as PdfCraft draws it, which at /K 0 may be other rows than were coded). CCITT fax images are decoded to be downsampled (optimize.downsample-images), and a resampled image is stored as Group 4 or 1-bit Flate, whichever is smaller. Lossless JBIG2 with bounded exact-copy symbol coding is available too, with image export; lossy JBIG2 remains unsupported. JBIG2 images are read too: averaged like any (under Retain stored as JBIG2 again), and, not resampled, stored again as the compression asks when that is smaller (as JBIG2 symbols where its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact repeats and is often larger, and then stays as it is; one whose JBIG2 page isn't its size is left alone. Not yet: inline images, soft masks and /Mask images, Group 4 for rows wider than 32,767 samples."
[[feature]]
id = "optimize.only-if-smaller"
+9 -1
View File
@@ -755,7 +755,15 @@ fn decode_context<'a>(
let decoded = obj
.stream
.decoded_image(&decode_params)
.map_err(|_| (obj.warning_sink)(InterpreterWarning::ImageDecodeFailure))
.map_err(|_| {
// PdfCraft patch (73): left out for the page's allowance, it is content the page's
// budget skipped, which is reported, and no failure of this image.
(obj.warning_sink)(if hayro_syntax::page_jbig2_work_spent() {
InterpreterWarning::ContentTruncated
} else {
InterpreterWarning::ImageDecodeFailure
});
})
.ok()?;
let (mut scale_x, mut scale_y) = (1.0, 1.0);
+5 -4
View File
@@ -56,14 +56,15 @@ fn decode_for_page<D: hayro_jbig2::Decoder>(image: &hayro_jbig2::Image<'_>, deco
image.decode(decoder).ok()
}
/// Whether the page has nothing left for another image, which then isn't read at all.
/// PdfCraft patch (31): whether the page has nothing left for another image, which then isn't read
/// at all.
#[cfg(feature = "std")]
fn page_work_spent() -> bool {
pub fn page_jbig2_work_spent() -> bool {
PAGE_WORK_LEFT.with(core::cell::Cell::get) == 0
}
#[cfg(not(feature = "std"))]
fn page_work_spent() -> bool {
pub fn page_jbig2_work_spent() -> bool {
false
}
@@ -76,7 +77,7 @@ pub(crate) fn decode(
params: &Dict<'_>,
image_params: &ImageDecodeParams,
) -> Option<FilterResult<'static>> {
if page_work_spent() {
if page_jbig2_work_spent() {
return None;
}
+1 -1
View File
@@ -9,7 +9,7 @@ mod dct;
#[cfg(feature = "images")]
mod jbig2;
#[cfg(feature = "images")]
pub use jbig2::MAX_PAGE_JBIG2_WORK;
pub use jbig2::{MAX_PAGE_JBIG2_WORK, page_jbig2_work_spent};
#[cfg(all(feature = "images", feature = "std"))]
pub use jbig2::set_page_jbig2_work;
#[cfg(feature = "images")]