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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ The release run heads these entries with the version and opens a fresh

## Unreleased

- PDF Lab and ICC colors, indexed palettes and image samples honor component
ranges. Invalid Lab white points and malformed range arrays are rejected.

- JBIG2 decoding checks integer arithmetic, clips off-page regions and bounds
symbol allocation and loops. Unsupported intermediate regions are rejected
instead of painted onto the page.
Expand Down
6 changes: 5 additions & 1 deletion src/odr/internal/pdf/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,11 @@ above. So each run is raised by one font ascent:
0 to 255 and unresolved base spaces are rejected before image allocation.
An ICC alternate that cannot stand in is dropped for the device space `N`
names, and a short palette reads its missing bytes as 0.
- CMYK is naive (no ICC) and overprint is ignored. CIE, ICCBased, Indexed,
- Lab and ICC component ranges are shared by direct colors, Indexed palette
scaling and default image decoding (Table 90). An invalid Lab `WhitePoint`
or `Range`, or an invalid ICC `Range`, keeps the default. ICC still uses its alternate;
profiles are not evaluated. CMYK uses the Adobe polynomial approximation,
and overprint is ignored. CIE, ICCBased, Indexed,
Separation, DeviceN and Lab resolve to RGB at emission by sampling the tint
`/Function` (types 0, 2, 3, 4).

Expand Down
122 changes: 94 additions & 28 deletions src/odr/internal/pdf/pdf_color.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ namespace odr::internal::pdf {

namespace {

double clamp01(const double v) { return std::clamp(v, 0.0, 1.0); }
double clamp01(const double v) {
return std::isnan(v) ? 0.0 : std::clamp(v, 0.0, 1.0);
}

double linear_to_srgb(const double c) {
return util::color::linear_to_srgb(clamp01(c));
Expand Down Expand Up @@ -48,14 +50,33 @@ std::shared_ptr<ColorSpaceDef> device_space(const ColorSpaceKind kind,
return def;
}

std::vector<double> read_numbers(const Object &object) {
std::vector<double> result;
if (object.is_array()) {
for (const Object &item : object.as_array()) {
result.push_back(item.as_real());
bool read_numbers(const Object &object, const std::span<double> values,
const ColorSpaceContext &context) {
const Object resolved = context.resolve(object);
if (!resolved.is_array() || resolved.as_array().size() != values.size()) {
return false;
}
for (std::size_t i = 0; i < values.size(); ++i) {
const Object item = context.resolve(resolved.as_array()[i]);
if (!item.is_real() || !std::isfinite(item.as_real())) {
return false;
}
values[i] = item.as_real();
}
return result;
return true;
}

bool read_ranges(const Object &object, const std::span<double> values,
const ColorSpaceContext &context) {
if (!read_numbers(object, values, context)) {
return false;
}
for (std::size_t i = 0; i < values.size(); i += 2) {
if (values[i] > values[i + 1]) {
return false;
}
}
return true;
}

/// Resolve a colour-space name to a device/pattern space, or a resource space
Expand Down Expand Up @@ -83,29 +104,49 @@ std::shared_ptr<ColorSpaceDef> space_from_name(const std::string &name,

} // namespace

std::array<double, 2>
ColorSpaceDef::component_range(const std::size_t index) const {
if (kind == ColorSpaceKind::lab && index < 3) {
return index == 0 ? std::array<double, 2>{0, 100}
: std::array<double, 2>{lab_range[2 * index - 2],
lab_range[2 * index - 1]};
}
if (kind == ColorSpaceKind::icc_based && index < 4) {
return {icc_range[2 * index], icc_range[2 * index + 1]};
}
return {0, 1};
}

std::array<double, 3>
ColorSpaceDef::to_rgb(const std::span<const double> c) const {
const auto at = [&](const std::size_t i) {
return i < c.size() ? c[i] : 0.0;
const auto [minimum, maximum] = component_range(i);
const double value = i < c.size() ? c[i] : 0.0;
return kind == ColorSpaceKind::indexed
? value
: std::clamp(std::isnan(value) ? 0.0 : value, minimum, maximum);
};
switch (kind) {
case ColorSpaceKind::device_gray:
case ColorSpaceKind::cal_gray: {
const double g = clamp01(at(0));
const double g = at(0);
return {g, g, g};
}
case ColorSpaceKind::device_rgb:
case ColorSpaceKind::cal_rgb:
return {clamp01(at(0)), clamp01(at(1)), clamp01(at(2))};
return {at(0), at(1), at(2)};
case ColorSpaceKind::device_cmyk:
return cmyk_to_rgb(at(0), at(1), at(2), at(3));
case ColorSpaceKind::lab:
return lab_to_rgb(at(0), at(1), at(2), white_point);
case ColorSpaceKind::icc_based:
// No ICC engine: defer to the alternate, else pick a device space by the
// component count (ISO 32000-1 8.6.5.5).
if (components != 1 && components != 3 && components != 4) {
return {0, 0, 0};
}
if (alternate != nullptr) {
return alternate->to_rgb(c);
const std::array clipped{at(0), at(1), at(2), at(3)};
return alternate->to_rgb(
std::span(clipped).first(static_cast<std::size_t>(components)));
}
if (components == 1) {
const double g = clamp01(at(0));
Expand All @@ -129,9 +170,13 @@ ColorSpaceDef::to_rgb(const std::span<const double> c) const {
std::vector<double> base_components(static_cast<std::size_t>(n), 0.0);
for (std::int32_t j = 0; j < n; ++j) {
const std::size_t k = offset + static_cast<std::size_t>(j);
const auto [minimum, maximum] =
base->component_range(static_cast<std::size_t>(j));
const double fraction = k < lookup.size()
? static_cast<std::uint8_t>(lookup[k]) / 255.0
: 0.0;
base_components[static_cast<std::size_t>(j)] =
k < lookup.size() ? static_cast<std::uint8_t>(lookup[k]) / 255.0
: 0.0;
std::lerp(minimum, maximum, fraction);
}
return base->to_rgb(base_components);
}
Expand Down Expand Up @@ -174,6 +219,15 @@ std::vector<double> ColorSpaceDef::initial_components() const {
case ColorSpaceKind::device_n:
// Initial tint is full colorant (ISO 32000-1 8.6.3).
return std::vector<double>(static_cast<std::size_t>(components), 1.0);
case ColorSpaceKind::lab:
case ColorSpaceKind::icc_based: {
std::vector<double> result(static_cast<std::size_t>(components));
for (std::size_t i = 0; i < result.size(); ++i) {
const auto [minimum, maximum] = component_range(i);
result[i] = std::clamp(0.0, minimum, maximum);
}
return result;
}
case ColorSpaceKind::device_cmyk:
// Initial DeviceCMYK colour is black, i.e. {0, 0, 0, 1} (ISO
// 32000-1 8.6.3).
Expand Down Expand Up @@ -260,6 +314,15 @@ pdf::parse_color_space(const Object &object, const ColorSpaceContext &context,
return nullptr;
}
def->components = static_cast<std::int32_t>(count.as_integer());
// an invalid optional Range keeps the default 0 to 1 per component
if (std::array<double, 8> range = def->icc_range;
dict.has_value("Range") &&
read_ranges(dict.get("Range"),
std::span(range).first(
2 * static_cast<std::size_t>(def->components)),
context)) {
def->icc_range = range;
}
if (dict.has_value("Alternate")) {
def->alternate =
parse_color_space(dict.get("Alternate"), context, depth + 1);
Expand All @@ -282,19 +345,22 @@ pdf::parse_color_space(const Object &object, const ColorSpaceContext &context,
auto def = std::make_shared<ColorSpaceDef>();
def->kind = ColorSpaceKind::lab;
def->components = 3;
if (array.size() >= 2) {
const Object params = context.resolve(array[1]);
if (params.is_dictionary()) {
const Dictionary &dict = params.as_dictionary();
const std::vector<double> wp = read_numbers(dict.get("WhitePoint"));
if (wp.size() == 3) {
def->white_point = {wp[0], wp[1], wp[2]};
}
const std::vector<double> range = read_numbers(dict.get("Range"));
if (range.size() == 4) {
def->lab_range = {range[0], range[1], range[2], range[3]};
}
}
const Object params =
array.size() >= 2 ? context.resolve(array[1]) : Object{};
if (!params.is_dictionary()) {
return def;
}
const Dictionary &dict = params.as_dictionary();
// an invalid WhitePoint or Range keeps the default, as a missing one does
if (std::array<double, 3> point{};
read_numbers(dict.get("WhitePoint"), point, context) && point[0] > 0 &&
point[1] == 1 && point[2] > 0) {
def->white_point = point;
}
if (std::array<double, 4> range{};
dict.has_value("Range") &&
read_ranges(dict.get("Range"), range, context)) {
def->lab_range = range;
}
return def;
}
Expand Down
26 changes: 12 additions & 14 deletions src/odr/internal/pdf/pdf_color.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#include <odr/internal/pdf/pdf_function.hpp>

#include <array>
#include <cstddef>
#include <cstdint>
#include <functional>
#include <memory>
Expand All @@ -14,9 +15,7 @@ namespace odr::internal::pdf {

class Object;

/// The colour-space families PDF content can select (ISO 32000-1 8.6). The
/// device families are also reachable through the dedicated operators
/// (`g`/`rg`/`k`); the rest arrive via `/ColorSpace` resources and `cs`/`scn`.
/// PDF colour-space families (ISO 32000-1 8.6).
enum class ColorSpaceKind {
device_gray,
device_rgb,
Expand All @@ -32,10 +31,8 @@ enum class ColorSpaceKind {
unknown,
};

/// A resolved colour space: enough to convert a tuple of component values to
/// sRGB at emit time (ISO 32000-1 8.6.4 / 8.6.5 / 8.6.6). Non-device spaces are
/// approximated — ICC profiles by their alternate or component count, Cal* as
/// device, overprint ignored.
/// Resolved colour space for sRGB conversion (ISO 32000-1 8.6).
/// ICC uses an alternate; Cal* uses the corresponding device approximation.
struct ColorSpaceDef {
ColorSpaceKind kind{ColorSpaceKind::unknown};
/// Number of input components a colour in this space carries.
Expand All @@ -44,6 +41,10 @@ struct ColorSpaceDef {
// Lab (8.6.5.4): the white point and the a*/b* component ranges.
std::array<double, 3> white_point{0.9505, 1.0, 1.089};
std::array<double, 4> lab_range{-100, 100, -100, 100};
std::array<double, 8> icc_range{0, 1, 0, 1, 0, 1, 0, 1};

/// Valid component range, also used by Indexed palettes and image decoding.
[[nodiscard]] std::array<double, 2> component_range(std::size_t index) const;

// Indexed (8.6.6.3): the base space, the packed palette and the max index.
std::shared_ptr<ColorSpaceDef> base;
Expand All @@ -64,10 +65,8 @@ struct ColorSpaceDef {
[[nodiscard]] std::vector<double> initial_components() const;
};

/// How `parse_color_space` reaches indirect data: `resolve` dereferences,
/// `load_stream` decodes a stream's bytes (ICC profiles, an Indexed palette
/// given as a stream), and `named` looks up a colour space referenced by name
/// (a base space, the `/ColorSpace` resource table), forwarding its depth.
/// Resolve indirect objects, decode stream bytes and look up resource names.
/// Named lookups must forward the recursion depth.
struct ColorSpaceContext {
std::function<Object(const Object &)> resolve;
std::function<std::string(const Object &)> load_stream;
Expand All @@ -80,9 +79,8 @@ struct ColorSpaceContext {
/// transform. The naive `(1-c)(1-k)` reads pure cyan as `#00ffff`.
std::array<double, 3> cmyk_to_rgb(double c, double m, double y, double k);

/// Build a colour space from its PDF object — a name (`/DeviceRGB`, …) or an
/// array (`[/ICCBased 5 0 R]`, `[/Separation …]`, …). Returns `nullptr` for an
/// unsupported, malformed or excessively nested definition.
/// Parse a colour-space name or array; return null for unsupported, malformed
/// or excessively nested definitions.
std::shared_ptr<ColorSpaceDef>
parse_color_space(const Object &object, const ColorSpaceContext &context,
std::uint32_t depth = 0);
Expand Down
6 changes: 4 additions & 2 deletions src/odr/internal/pdf/pdf_image.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -180,14 +180,16 @@ std::string pdf::encode_image_png(const std::string &samples,
if (decode.size() >= 2 * (k + 1)) {
const double d_min = decode[2 * k];
const double d_max = decode[2 * k + 1];
return d_min + sample * (d_max - d_min) / max_sample;
return std::lerp(d_min, d_max, static_cast<double>(sample) / max_sample);
}
if (indexed) {
// Default Indexed /Decode is [0, 2^bpc-1]: the sample is the palette
// index, which `to_rgb` looks up directly (8.6.6.3).
return sample;
}
return static_cast<double>(sample) / max_sample;
const auto [minimum, maximum] = color_space.component_range(k);
return std::lerp(minimum, maximum,
static_cast<double>(sample) / max_sample);
};

if (bits_per_component == 1 && components == 1 && !has_alpha) {
Expand Down
59 changes: 59 additions & 0 deletions test/src/internal/pdf/pdf_color.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -262,3 +262,62 @@ TEST(PdfColor, a_mismatched_icc_alternate_is_ignored) {
EXPECT_EQ(def->alternate, nullptr);
EXPECT_EQ(to_rgb(*def, {1, 0, 0}), (std::array<double, 3>{1, 0, 0}));
}

TEST(PdfColor, lab_parameters_and_palette_ranges) {
Dictionary params;
params["WhitePoint"] = reals({0.9505, 1, 1.089});
params["Range"] = reals({-128, 127, -128, 127});
const Object lab(Array({Object(Name{"Lab"}), Object(params)}));
const Object indexed(
Array({Object(Name{"Indexed"}), lab, Object(Integer{0}),
Object(StandardString(std::string("\xff\x80\x80", 3)))}));
const auto def = parse_color_space(indexed, context());
ASSERT_NE(def, nullptr);
const auto white = to_rgb(*def, {0});
for (const double channel : white) {
EXPECT_NEAR(channel, 1.0, 0.001);
}
EXPECT_EQ(to_rgb(*def->base, {200, 300, -300}),
to_rgb(*def->base, {100, 127, -128}));
for (const Object &point :
{Object{}, reals({0, 1, 1}), reals({1, 2, 1}), reals({1, 1, -1})}) {
params["WhitePoint"] = point;
const auto fallback = parse_color_space(
Object(Array({Object(Name{"Lab"}), Object(params)})), context());
ASSERT_NE(fallback, nullptr);
EXPECT_EQ(fallback->white_point, (std::array<double, 3>{0.9505, 1, 1.089}));
}
params["WhitePoint"] = reals({1, 1, 1});
params["Range"] = reals({10, -10, -20, 20});
const auto fallback = parse_color_space(
Object(Array({Object(Name{"Lab"}), Object(params)})), context());
ASSERT_NE(fallback, nullptr);
EXPECT_EQ(fallback->lab_range, (std::array<double, 4>{-100, 100, -100, 100}));
}

TEST(PdfColor, icc_component_ranges_clip_and_scale_palette_values) {
Dictionary profile;
profile["N"] = Object(Integer{3});
profile["Range"] = reals({0.2, 0.8, 0.1, 0.9, 0.3, 0.7});
const Object icc(Array({Object(Name{"ICCBased"}), Object(profile)}));
const auto def = parse_color_space(icc, context());
ASSERT_NE(def, nullptr);
EXPECT_EQ(to_rgb(*def, {-1, 2, -1}), (std::array<double, 3>{0.2, 0.9, 0.3}));
const auto indexed = parse_color_space(
Object(Array({Object(Name{"Indexed"}), icc, Object(Integer{0}),
Object(StandardString(std::string("\0\xff\0", 3)))})),
context());
ASSERT_NE(indexed, nullptr);
EXPECT_EQ(to_rgb(*indexed, {0}), (std::array<double, 3>{0.2, 0.9, 0.3}));
for (const Object &range :
{reals({0, 1}), reals({1, 0, 0, 1, 0, 1}),
reals({0, std::numeric_limits<double>::infinity(), 0, 1, 0, 1}),
Object(Name{"Bad"})}) {
profile["Range"] = range;
const auto fallback = parse_color_space(
Object(Array({Object(Name{"ICCBased"}), Object(profile)})), context());
ASSERT_NE(fallback, nullptr);
EXPECT_EQ(fallback->icc_range,
(std::array<double, 8>{0, 1, 0, 1, 0, 1, 0, 1}));
}
}
10 changes: 10 additions & 0 deletions test/src/internal/pdf/pdf_image.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -242,3 +242,13 @@ TEST(PdfImage, rejects_unrepresentable_output_sizes) {
decode_mask_alpha("", 1, 1, 8, {}, false, 65536, 65536).empty());
}
}

TEST(PdfImage, lab_default_decode_uses_the_component_ranges) {
ColorSpaceDef lab;
lab.kind = ColorSpaceKind::lab;
lab.components = 3;
lab.lab_range = {-128, 127, -128, 127};
const auto png =
decode_png(encode_image_png(bytes({255, 128, 128}), 1, 1, 8, lab, {}));
EXPECT_EQ(png.pixels, bytes({255, 255, 255}));
}
Loading