diff --git a/README.md b/README.md index 02e8eaa..1fab1d3 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,14 @@ helpers may discard or retain some delimiter whitespace. The second field is **not measured decoded pixels**, not an extracted PNG/JPEG file size, and not allocated image memory. Device and Indexed color spaces can be resolved directly or by name in the current page/Form `/Resources` dictionary. Indexed images use -one component per sample, regardless of the palette's base space. Other custom -spaces (including ICCBased, Separation and DeviceN), missing names, and cyclic +one component per sample, regardless of the palette's base space. Version 0.3.0 +also resolves named CalGray (one component), CalRGB and Lab (three components) +resources, including indirect parameter dictionaries. Only the family and +dictionary shape are inspected; colour conversion parameters are not validated. +Other custom spaces (including ICCBased, Separation and DeviceN), missing names, and cyclic aliases remain explicitly unknown. Lookup is bounded to 32 steps and never -reads image pixels or decodes profiles. See PDF 32000-1, sections 7.8.3 and 8.9.7 +reads image pixels or decodes profiles. See PDF 32000-1, sections 7.8.3, +8.6.5.2–8.6.5.4 and 8.9.7 ([Adobe reference](https://opensource.adobe.com/dc-acrobat-sdk-docs/standards/pdfstandards/pdf/PDF32000_2008.pdf)). DCT payloads are never treated as decoded rasters. PDF 32000-1 sections 7.4.9 and 8.9.7 prohibit JPXDecode and JBIG2Decode for inline images; JPX image XObjects diff --git a/pdf_inline_audit.py b/pdf_inline_audit.py index 8899b7c..779cc05 100644 --- a/pdf_inline_audit.py +++ b/pdf_inline_audit.py @@ -17,7 +17,7 @@ from pypdf import PdfReader from pypdf.generic import ContentStream, DictionaryObject, NullObject -VERSION = "0.2.1" +VERSION = "0.3.0" @dataclass(frozen=True) @@ -34,14 +34,23 @@ class ScanLimit(Exception): def color_components(color: Any, resources: dict | None = None) -> int | None: - """Resolve known device/Indexed spaces, without decoding profiles or pixels.""" + """Resolve known component counts without decoding profiles or pixels.""" spaces = None seen = set() for _ in range(32): if hasattr(color, "get_object"): color = color.get_object() if isinstance(color, list): - return 1 if color and str(color[0]) in ("/I", "/Indexed") else None + if color and str(color[0]) in ("/I", "/Indexed"): + return 1 + if len(color) != 2: + return None + parameters = color[1] + if hasattr(parameters, "get_object"): + parameters = parameters.get_object() + if not isinstance(parameters, dict): + return None + return {"/CalGray": 1, "/CalRGB": 3, "/Lab": 3}.get(str(color[0])) if not isinstance(color, str): return None channels = { diff --git a/pyproject.toml b/pyproject.toml index 17d56a3..757e8b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pdf-inline-audit" -version = "0.2.1" +version = "0.3.0" description = "Reproducible corpus statistics for PDF inline image payloads" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_audit.py b/tests/test_audit.py index a3ca7b6..97a2114 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -171,6 +171,40 @@ def test_form_colorspace_overrides_page_resource_name(self): assert result['status'] == 'ok' assert result['images'][0]['estimated_packed_sample_bytes'] == 12 + def test_named_cie_spaces_resolve_indirect_resources_and_parameters(self): + for family, expected in [('/CalGray', 6), ('/CalRGB', 18), ('/Lab', 18)]: + with self.subTest(family=family): + params = DictionaryObject({NameObject('/WhitePoint'): ArrayObject([ + NumberObject(1), NumberObject(1), NumberObject(1)])}) + space = ArrayObject([NameObject(family), params]) + payload = b'00' * expected + b'>' + path = make_pdf(self.tmp_path / f'{family[1:]}.pdf', + [inline(payload, b'/W 2 /H 3 /BPC 8 /CS /Alias /F /AHx')], + color_spaces=[{'/Alias': NameObject('/CIE'), '/CIE': space}]) + writer = PdfWriter(clone_from=str(path)) + color = writer.pages[0]['/Resources']['/ColorSpace']['/CIE'].get_object() + color[1] = writer._add_object(color[1]) + writer.write(path) + writer.close() + result = scan_pdf(path) + assert result['status'] == 'ok' + image = result['images'][0] + assert image['estimated_packed_sample_bytes'] == expected + assert image['estimate_unknown_reason'] is None + assert image['parsed_encoded_payload_bytes'] == len(payload) + + def test_cie_row_padding_and_unsupported_descriptions(self): + for family, expected in [('/CalGray', 4), ('/CalRGB', 10), ('/Lab', 10)]: + with self.subTest(family=family): + assert sample_estimate({'/W': 3, '/H': 2, '/BPC': 4, + '/CS': '/CIE'}, + {'/ColorSpace': {'/CIE': [family, {}]}}) == (expected, None) + for color in [[], ['/CalGray'], ['/CalRGB', None], ['/Lab', {}, {}], + ['/ICCBased', {}], ['/Separation', {}], '/CalRGB']: + with self.subTest(color=color): + assert sample_estimate({'/W': 3, '/H': 2, '/BPC': 4, '/CS': color}) == ( + None, 'unresolved_or_unsupported_color_space') + def test_null_form_resources_match_absence_but_empty_dictionary_does_not(self): source = make_pdf(self.tmp_path / 'source.pdf', [b'/Fm Do /Fm Do'], inline(b'00>', b'/W 1 /H 1 /BPC 8 /CS /Custom /F /AHx'),