Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ The release run heads these entries with the version and opens a fresh

## Unreleased

- A scanned pdf page is no longer blank: `JBIG2Decode` images decode in house.
MMR/Huffman, refinement and halftone regions still skip the image.
- Justified pdf text is spaced as the file asks: word spacing (`Tw`) applies to
runs painted with an embedded font, which had rendered short.
- A pdf page is turned as its `/Rotate` says.
Expand Down
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ set(ODR_SOURCE_FILES
"src/odr/internal/pdf/pdf_filter.cpp"
"src/odr/internal/pdf/pdf_function.cpp"
"src/odr/internal/pdf/pdf_image.cpp"
"src/odr/internal/pdf/pdf_jbig2.cpp"
"src/odr/internal/pdf/pdf_jpx.cpp"
"src/odr/internal/pdf/pdf_graphics_operator_parser.cpp"
"src/odr/internal/pdf/pdf_graphics_state.cpp"
Expand Down
17 changes: 11 additions & 6 deletions src/odr/internal/pdf/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,12 +146,17 @@ Things the code won't shout at you:
is unlocked with the empty password first so `/Info` decrypts). It is
all-or-nothing: a malformed structure leaves `document_type` at `unknown` rather
than half-filling the fields. XMP is not parsed — the strings are `/Info`-only.
- **Image codecs are not decoded** in the filter framework
(DCTDecode/JPXDecode/CCITTFaxDecode/JBIG2Decode): `decode()` stops and hands
back the still-encoded payload for the image path; `read_decoded_stream` treats
them as an error. A JPEG then passes through to the browser and a JPEG 2000
goes to `pdf_jpx` (openjpeg) to be re-encoded as PNG like any other raster;
CCITT and JBIG2 remain undecodable. `Crypt` passes through only as `Identity`.
- **Image codecs mostly are not decoded** in the filter framework
(DCTDecode/JPXDecode/CCITTFaxDecode): `decode()` stops and hands back the
still-encoded payload for the image path; `read_decoded_stream` treats them as
an error. A JPEG then passes through to the browser and a JPEG 2000 goes to
`pdf_jpx` (openjpeg) to be re-encoded as PNG like any other raster; CCITT
remains undecodable. The exception is **JBIG2**: `pdf_jbig2` decodes it in
house, there being no library to defer to, covering the arithmetic generic
regions, symbol dictionaries and text regions a scanner emits. MMR/Huffman,
refinement and halftone fail the image, not the page. `/JBIG2Globals` reaches
the filter as a `DecodeOptions` — only the parser can follow its reference.
`Crypt` passes through only as `Identity`.
- **Inherited page attributes** (`Resources`/`MediaBox`/`CropBox`/`Rotate`, Table
30) are resolved by threading an accumulator down the `Pages` recursion — *not*
by a `Parent` walk. Lenience (all with a `Logger` warning): `CropBox` ←
Expand Down
52 changes: 46 additions & 6 deletions src/odr/internal/pdf/pdf_document_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,44 @@ std::vector<std::uint8_t> resolve_mask_alpha(DocumentParser &parser,
image_decode(parser, dictionary), stencil, base_width, base_height);
}

/// The `/JBIG2Globals` stream a `JBIG2Decode` filter names (ISO 32000-1
/// 7.4.7). One we cannot read leaves the bytes empty: the decoder then fails
/// and the image is skipped, rather than the document.
DecodeOptions jbig2_decode_options(DocumentParser &parser,
const Object &decode_parms) {
DecodeOptions options;
const auto take = [&](const Object &parms) {
const Object resolved = parser.resolve_object_copy(parms);
if (!resolved.is_dictionary()) {
return;
}
const Dictionary &dictionary = resolved.as_dictionary();
if (!dictionary.has_key("JBIG2Globals")) {
return;
}
const Object &globals = dictionary["JBIG2Globals"];
if (!globals.is_reference()) {
return;
}
try {
options.jbig2_globals =
parser.read_decoded_stream(globals.as_reference());
} catch (const std::exception &) {
options.jbig2_globals.clear();
}
};

const Object resolved = parser.resolve_object_copy(decode_parms);
if (resolved.is_array()) {
for (const Object &entry : resolved.as_array()) {
take(entry);
}
} else {
take(resolved);
}
return options;
}

/// Decode an `/ImageMask true` stencil onto `x_object` (ISO 32000-1 8.9.6.2).
/// Only decoded, not coloured: the fill colour is known only at `Do` time. An
/// undecodable codec leaves `stencil_mask` false, so `Do` skips it.
Expand All @@ -772,9 +810,10 @@ void parse_stencil_mask(DocumentParser &parser, const Dictionary &dictionary,
decode_parms = parser.deep_resolve_object_copy(dictionary["DecodeParms"]);
}
DecodeResult result =
decode(filter, decode_parms, parser.read_object_stream(object));
decode(filter, decode_parms, parser.read_object_stream(object),
jbig2_decode_options(parser, dictionary.get("DecodeParms")));
if (result.stopped_at_filter.has_value()) {
return; // CCITT/JBIG2 fax stencils are not yet decodable
return; // a CCITT fax stencil is not yet decodable
}
const std::int32_t width = image_int(parser, dictionary, "Width", 0);
const std::int32_t height = image_int(parser, dictionary, "Height", 0);
Expand Down Expand Up @@ -846,10 +885,11 @@ void parse_image_data(DocumentParser &parser, const Dictionary &dictionary,
const std::int32_t smask_in_data =
image_int(parser, dictionary, "SMaskInData", 0);

if (std::optional<EncodedImage> encoded =
encode_image(parser.read_object_stream(object), filter, decode_parms,
width, height, bits_per_component, color_space.get(),
decode_array, alpha, color_key, smask_in_data)) {
if (std::optional<EncodedImage> encoded = encode_image(
parser.read_object_stream(object), filter, decode_parms, width,
height, bits_per_component, color_space.get(), decode_array, alpha,
color_key, smask_in_data,
jbig2_decode_options(parser, dictionary.get("DecodeParms")))) {
x_object.image_data = std::move(encoded->data);
x_object.image_mime = std::move(encoded->mime);
}
Expand Down
12 changes: 11 additions & 1 deletion src/odr/internal/pdf/pdf_filter.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#include <odr/internal/pdf/pdf_filter.hpp>

#include <odr/internal/crypto/crypto_util.hpp>
#include <odr/internal/pdf/pdf_jbig2.hpp>
#include <odr/internal/pdf/pdf_object_parser.hpp>

#include <algorithm>
Expand Down Expand Up @@ -206,7 +207,7 @@ std::string apply_png_predictor(const std::string &data, const Integer colors,
namespace odr::internal {

pdf::DecodeResult pdf::decode(const Object &filter, const Object &decode_parms,
std::string data) {
std::string data, const DecodeOptions &options) {
DecodeResult result;

std::vector<Object> filters;
Expand All @@ -229,6 +230,15 @@ pdf::DecodeResult pdf::decode(const Object &filter, const Object &decode_parms,
for (std::size_t i = 0; i < filters.size(); ++i) {
const std::string name = canonical_filter_name(filters[i].as_string());
const Object parms = parms_for(i);
if (name == "JBIG2Decode") {
// The one image codec we decode ourselves; past the decoder's reach it
// stops the chain like the others.
if (std::optional<Jbig2Image> image =
decode_jbig2(data, options.jbig2_globals)) {
data = std::move(image->samples);
continue;
}
}
if (is_image_codec(name)) {
result.stopped_at_filter = name;
result.stopped_at_parms = parms;
Expand Down
14 changes: 11 additions & 3 deletions src/odr/internal/pdf/pdf_filter.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
namespace odr::internal::pdf {

/// Result of decoding a stream through its `/Filter` chain (ISO 32000-1 7.4).
/// If the chain reaches an image codec (DCTDecode, JPXDecode, CCITTFaxDecode,
/// JBIG2Decode), decoding stops there: `data` holds the still-encoded payload,
/// If the chain reaches an image codec it cannot decode (DCTDecode, JPXDecode,
/// CCITTFaxDecode, or a JBIG2 stream past `decode_jbig2`'s reach), decoding
/// stops there: `data` holds the still-encoded payload,
/// `stopped_at_filter` the codec's canonical name and `stopped_at_parms` its
/// decode parameters.
struct DecodeResult {
Expand All @@ -18,11 +19,18 @@ struct DecodeResult {
Object stopped_at_parms;
};

/// What a filter needs from the document but cannot resolve itself.
struct DecodeOptions {
/// The `/JBIG2Globals` stream's bytes: `/DecodeParms` names it by reference,
/// which only the parser can follow.
std::string jbig2_globals;
};

/// `filter` and `decode_parms` are the already reference-resolved values of
/// `/Filter` and `/DecodeParms`: null, a single name/dictionary, or parallel
/// arrays.
DecodeResult decode(const Object &filter, const Object &decode_parms,
std::string data);
std::string data, const DecodeOptions &options = {});

/// The image codec a `/Filter` chain terminates in — its last entry, when that
/// is an image codec such as DCTDecode (the filter `decode` would stop at) —
Expand Down
9 changes: 5 additions & 4 deletions src/odr/internal/pdf/pdf_image.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,8 @@ std::optional<pdf::EncodedImage> pdf::encode_image(
const std::int32_t bits_per_component, const ColorSpaceDef *color_space,
const std::vector<double> &decode_array,
const std::vector<std::uint8_t> &alpha,
const std::vector<double> &color_key, const std::int32_t smask_in_data) {
const std::vector<double> &color_key, const std::int32_t smask_in_data,
const DecodeOptions &options) {
const std::optional<std::string> terminal = terminal_image_codec(filter);

if (terminal == "DCTDecode") {
Expand All @@ -390,15 +391,15 @@ std::optional<pdf::EncodedImage> pdf::encode_image(
return encode_jpx(result.data, color_space, decode_array, alpha, color_key,
smask_in_data);
}
if (terminal.has_value()) {
return std::nullopt; // CCITT/JBIG2: not decodable
if (terminal.has_value() && terminal != "JBIG2Decode") {
return std::nullopt; // CCITTFax: not decodable
}

// A fully decodable raster: decode, assemble samples and PNG-encode.
if (color_space == nullptr) {
return std::nullopt;
}
DecodeResult result = decode(filter, decode_parms, std::move(raw));
DecodeResult result = decode(filter, decode_parms, std::move(raw), options);
if (result.stopped_at_filter.has_value()) {
return std::nullopt;
}
Expand Down
22 changes: 14 additions & 8 deletions src/odr/internal/pdf/pdf_image.hpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#pragma once

#include <odr/internal/pdf/pdf_filter.hpp>

#include <array>
#include <cstdint>
#include <optional>
Expand All @@ -24,14 +26,18 @@ struct EncodedImage {
/// `color_key` (see `encode_image_png`) make the raster RGBA and are ignored by
/// the JPEG pass-through. A `JPXDecode` raster comes through `decode_jpx`, its
/// own opacity channel taken only as `smask_in_data` says (Table 89: 0 ignores
/// it, 2 says the colour is premultiplied by it). `nullopt` for an undecodable
/// codec (CCITTFax/JBIG2) or an inconsistent raster.
std::optional<EncodedImage> encode_image(
std::string raw, const Object &filter, const Object &decode_parms,
std::int32_t width, std::int32_t height, std::int32_t bits_per_component,
const ColorSpaceDef *color_space, const std::vector<double> &decode,
const std::vector<std::uint8_t> &alpha = {},
const std::vector<double> &color_key = {}, std::int32_t smask_in_data = 0);
/// it, 2 says the colour is premultiplied by it). A `JBIG2Decode` raster goes
/// through `decode_jbig2`, `options` carrying the globals it may need.
/// `nullopt` for an undecodable codec (CCITTFax, or JBIG2 past the decoder's
/// reach) or an inconsistent raster.
std::optional<EncodedImage>
encode_image(std::string raw, const Object &filter, const Object &decode_parms,
std::int32_t width, std::int32_t height,
std::int32_t bits_per_component, const ColorSpaceDef *color_space,
const std::vector<double> &decode,
const std::vector<std::uint8_t> &alpha = {},
const std::vector<double> &color_key = {},
std::int32_t smask_in_data = 0, const DecodeOptions &options = {});

/// Assemble decoded image samples (ISO 32000-1 8.9.5: MSB-first, rows padded
/// to a byte boundary, `bits_per_component` of 1/2/4/8/16) into an 8-bit PNG,
Expand Down
Loading
Loading