mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 22:41:47 -06:00
M2.6: a picture whose JBIG2 mask is past the allowance isn't drawn either
Without its mask a picture shows what the mask hides, so the page leaves it out, as export does. Any other unreadable mask still leaves the picture, which is the renderer's tolerance for bad masks. What an export may hold is a function of its own, tested down to the step and at the top of the range. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4aa732ea91
commit
0f325d44b6
@@ -31,15 +31,20 @@ 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, 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.
|
||||
/// 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.
|
||||
const MAX_EXPORT_BYTES: usize = 2 << 30;
|
||||
|
||||
/// What an export of a file of `file_bytes` may hold: twice the file if that is more than
|
||||
/// [`MAX_EXPORT_BYTES`], so that a file of large images is exported whole.
|
||||
pub(crate) fn export_bytes(file_bytes: usize) -> usize {
|
||||
MAX_EXPORT_BYTES.max(file_bytes.saturating_mul(2))
|
||||
}
|
||||
|
||||
/// 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.max(doc.bytes().len().saturating_mul(2)))
|
||||
extract_images_within(doc, pages, min_side, &mut ImageBudget::new(doc), export_bytes(doc.bytes().len()))
|
||||
}
|
||||
|
||||
/// [`extract_images`] within `budget`, and holding `max_bytes` of files. An image past either is
|
||||
|
||||
@@ -2302,3 +2302,14 @@ fn an_image_whose_jbig2_mask_is_past_the_allowance_is_left_out() {
|
||||
let refused = crate::extract::image_file_within(&doc, image, &mut ImageBudget::with_work(0)).unwrap_err();
|
||||
assert!(refused.contains("more JBIG2 decoding than"), "{refused}");
|
||||
}
|
||||
|
||||
/// A large honest file keeps its images: what an export may hold grows with the file past the fixed
|
||||
/// part, and a file too long to double doesn't wrap round to nothing.
|
||||
#[test]
|
||||
fn an_export_may_hold_twice_a_large_file() {
|
||||
let fixed = crate::extract::export_bytes(0);
|
||||
assert_eq!(fixed, 2 << 30);
|
||||
assert_eq!(crate::extract::export_bytes(fixed / 2), fixed);
|
||||
assert_eq!(crate::extract::export_bytes(fixed / 2 + 1), fixed + 2);
|
||||
assert_eq!(crate::extract::export_bytes(usize::MAX), usize::MAX);
|
||||
}
|
||||
|
||||
@@ -751,7 +751,7 @@ fn jbig2_globals(secret: &[u8]) -> Vec<u8> {
|
||||
/// 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() {
|
||||
fn a_jbig2_image_past_the_runs_allowance_stops_the_run() {
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Width".to_vec(), Object::Int(16));
|
||||
dict.set(b"Height".to_vec(), Object::Int(2));
|
||||
|
||||
@@ -3672,6 +3672,50 @@ fn the_jbig2_images_of_a_page_share_one_allowance() {
|
||||
assert!(black(&usual, 10) && black(&usual, 30), "the usual allowance is far above a page like this");
|
||||
}
|
||||
|
||||
/// Without its mask a picture would show what the mask hides, so a JBIG2 mask past the page's
|
||||
/// allowance takes the picture with it, and the page says so. A mask that can't be read for any
|
||||
/// other reason leaves the picture, as it did.
|
||||
#[test]
|
||||
fn a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn() {
|
||||
let draw = |entry: &str, mask: &[u8], work: u64| {
|
||||
let content = b"q 20 0 0 20 5 5 cm /Im1 Do Q";
|
||||
let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 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".to_vec();
|
||||
pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes());
|
||||
pdf.extend_from_slice(content);
|
||||
pdf.extend_from_slice(b"\nendstream endobj\n");
|
||||
pdf.extend_from_slice(
|
||||
format!("5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray {entry} 6 0 R /Length 1 >> stream\n\0\nendstream endobj\n").as_bytes(),
|
||||
);
|
||||
let kind = if entry == "/Mask" { "/ImageMask true" } else { "/ColorSpace /DeviceGray" };
|
||||
pdf.extend_from_slice(
|
||||
format!("6 0 obj << /Type /XObject /Subtype /Image /Width 16 /Height 2 /BitsPerComponent 1 {kind} /Filter /JBIG2Decode /Length {} >> stream\n", mask.len()).as_bytes(),
|
||||
);
|
||||
pdf.extend_from_slice(mask);
|
||||
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
|
||||
let config = RenderConfig { jbig2_work: Some(work), ..RenderConfig::default() };
|
||||
let page = PageRenderer::new(Arc::new(pdf), config).render(RenderRequest {
|
||||
page: 0,
|
||||
kind: RequestKind::Pixels,
|
||||
tile: None,
|
||||
scale: 1.0,
|
||||
tag: 0,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(page.error.is_none(), "{:?}", page.error);
|
||||
let shown = match &page.rgba[((25 * 40 + 15) * 4)..][..4] {
|
||||
[0, 0, 0, 255] => true,
|
||||
[255, 255, 255, 255] => false,
|
||||
other => panic!("neither the picture nor the page: {other:?}"),
|
||||
};
|
||||
(shown, page.warnings.contains(&RenderWarning::ContentTruncated))
|
||||
};
|
||||
let mask = black_jbig2_page();
|
||||
assert_eq!(draw("/SMask", &mask, u64::MAX), (false, false), "the mask hides all of it");
|
||||
assert_eq!(draw("/SMask", &mask, 0), (false, true), "past the allowance a soft mask hides it too");
|
||||
assert_eq!(draw("/Mask", &mask, 0), (false, true), "and so does a stencil mask");
|
||||
assert_eq!(draw("/SMask", b"no JBIG2 here", 1 << 20), (true, false), "any other bad mask is no mask");
|
||||
}
|
||||
|
||||
/// An image that can't be decoded has still cost its reading, which a page repeats for each time
|
||||
/// it draws one, since such an image isn't kept. So what it reads comes out of the page's allowance,
|
||||
/// and an image after it has less. Allowed exactly what the good one takes, it isn't drawn behind
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
@@ -121,8 +121,8 @@ pub struct InterpreterSettings {
|
||||
/// render (tiles, whole pages, print, export).
|
||||
pub preview: bool,
|
||||
/// PdfCraft patch (73): the steps of JBIG2 decoding a page's images may take between them
|
||||
/// ([`hayro_syntax::MAX_PAGE_JBIG2_WORK`] by default). Images past it aren't drawn, each with
|
||||
/// [`InterpreterWarning::ImageDecodeFailure`].
|
||||
/// ([`hayro_syntax::MAX_PAGE_JBIG2_WORK`] by default). Images past it aren't drawn, nor images
|
||||
/// whose masks are past it, each with [`InterpreterWarning::ContentTruncated`].
|
||||
pub jbig2_work: u64,
|
||||
}
|
||||
|
||||
|
||||
+11
@@ -1016,6 +1016,17 @@ fn decode_raster(
|
||||
.flatten()
|
||||
};
|
||||
|
||||
// PdfCraft patch (73): a bad mask leaves the image as it is, but one left out for the page's
|
||||
// allowance takes the image with it. Both are reported, and only here is the file not to blame
|
||||
// for what the mask would have hidden being shown.
|
||||
let dict = obj.stream.dict();
|
||||
if alpha.is_none()
|
||||
&& hayro_syntax::page_jbig2_work_spent()
|
||||
&& (dict.contains_key(SMASK) || dict.contains_key(MASK))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(DecodedRaster { image, alpha })
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user