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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ The release run heads these entries with the version and opens a fresh
white space wherever it stands, not the start of a token.
- Text that decodes to half a surrogate pair costs that character a replacement
mark instead of the whole document.
- A pdf's text sits where the file puts it: a line was placed against the
browser's default font rather than its own, which dropped small text by
several points.
- A pdf's cmyk colours are converted as Adobe converts them, so a process cyan
reads as one instead of as pure `#00ffff`.
- A pdf page is the size of its crop box and shows what is on it and no more,
as a viewer shows it.
- A pdf's JPEG 2000 images render. New dependency: `openjpeg`.

## v6.5.0 - 2026-08-10

Expand Down
3 changes: 3 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ find_package(pugixml REQUIRED)
find_package(miniz REQUIRED)
find_package(cryptopp REQUIRED)
find_package(nlohmann_json REQUIRED)
find_package(OpenJPEG REQUIRED)
find_package(uchardet REQUIRED)
find_package(utf8cpp REQUIRED)

Expand Down Expand Up @@ -216,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_jpx.cpp"
"src/odr/internal/pdf/pdf_graphics_operator_parser.cpp"
"src/odr/internal/pdf/pdf_graphics_state.cpp"
"src/odr/internal/pdf/pdf_object.cpp"
Expand Down Expand Up @@ -276,6 +278,7 @@ target_link_libraries(odr
miniz::miniz
cryptopp::cryptopp
nlohmann_json::nlohmann_json
openjp2
uchardet::uchardet
utf8::cpp
)
Expand Down
1 change: 1 addition & 0 deletions conan.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"uchardet/0.0.8#6ab25e452021fcdb560f4e37f4a27bc1%1759735438.978",
"pybind11/2.13.6#42746850cd4c68d1b1ea42de456c2182%1755673714.548",
"pugixml/1.15#979e88f4fafbfe3585d2c0510a071cc7%1739435725.483",
"openjpeg/2.5.4#372fbc2b4348d45ab0c0a62a8475dc2f%1760446899.685",
"nlohmann_json/3.12.0#2d634ab0ec8d9f56353e5ccef6d6612c%1744735883.94",
"miniz/3.0.2#bfbce07c6654293cce27ee24129d2df7%1743673472.805",
"gtest/1.14.0#f8f0757a574a8dd747d16af62d6eb1b7%1743410807.169",
Expand Down
1 change: 1 addition & 0 deletions conanfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ def requirements(self):
self.requires("cryptopp/8.9.0")
self.requires("miniz/3.0.2")
self.requires("nlohmann_json/3.12.0")
self.requires("openjpeg/2.5.4")
self.requires("uchardet/0.0.8")
self.requires("utfcpp/4.0.9")
if self.options.get_safe("with_http_server", False):
Expand Down
71 changes: 42 additions & 29 deletions src/odr/internal/html/pdf_file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
#include <odr/internal/html/common.hpp>
#include <odr/internal/html/html_service.hpp>
#include <odr/internal/html/html_writer.hpp>
#include <odr/internal/pdf/pdf_color.hpp>
#include <odr/internal/pdf/pdf_document.hpp>
#include <odr/internal/pdf/pdf_document_element.hpp>
#include <odr/internal/pdf/pdf_document_parser.hpp>
Expand Down Expand Up @@ -316,14 +317,11 @@ std::string device_color_to_css(const pdf::GraphicsState::Color &color) {
b = to255(color.rgb[2]);
break;
case pdf::ColorSpace::device_cmyk: {
// Naive CMYK -> RGB (no ICC).
const double c = color.cmyk[0];
const double m = color.cmyk[1];
const double y = color.cmyk[2];
const double k = color.cmyk[3];
r = to255((1 - c) * (1 - k));
g = to255((1 - m) * (1 - k));
b = to255((1 - y) * (1 - k));
const std::array<double, 3> rgb = pdf::cmyk_to_rgb(
color.cmyk[0], color.cmyk[1], color.cmyk[2], color.cmyk[3]);
r = to255(rgb[0]);
g = to255(rgb[1]);
b = to255(rgb[2]);
break;
}
case pdf::ColorSpace::unknown:
Expand Down Expand Up @@ -2196,7 +2194,7 @@ class HtmlServiceImpl final : public HtmlService {
.m = m,
.invisible = invisible,
.is_matrix = is_matrix,
.asc = ascent_em(text),
.asc = ascent_em(text.font),
.scale = is_matrix ? 1.0 : m.a,
.ox = m.e,
.baseline = m.f,
Expand Down Expand Up @@ -2255,11 +2253,17 @@ class HtmlServiceImpl final : public HtmlService {

template <typename AddClass>
static PageBox begin_page(const pdf::Page &page, AddClass &&add_class) {
const pdf::Array &page_box = page.media_box.as_array();
const double box_x0 = page_box[0].as_real();
const double box_y0 = page_box[1].as_real();
const double width = page_box[2].as_real() - box_x0;
const double height = page_box[3].as_real() - box_y0;
// The crop box is what a viewer shows (14.11.2); it falls back to the media
// box. Its corners may be given in either order (7.9.5).
const pdf::Array &page_box = page.crop_box.as_array();
const double box_x0 =
std::min(page_box[0].as_real(), page_box[2].as_real());
const double box_y0 =
std::min(page_box[1].as_real(), page_box[3].as_real());
const double width =
std::max(page_box[0].as_real(), page_box[2].as_real()) - box_x0;
const double height =
std::max(page_box[1].as_real(), page_box[3].as_real()) - box_y0;

std::string classes = "p";
{
Expand Down Expand Up @@ -2358,11 +2362,15 @@ class HtmlServiceImpl final : public HtmlService {
// side margin is part of that width, so a phone screen keeps a gutter.
out.out() << ".d{display:flex;flex-direction:column;align-items:center;"
"gap:16px;padding:16px 0;width:max-content;min-width:100%}";
// `overflow:hidden` clips to the crop box, as a viewer does: content may
// sit outside it (a bleed, or an InDesign spread's other page).
out.out() << ".p{position:relative;margin:0 16px;background:#fff;"
"box-shadow:0 1px 4px rgba(0,0,0,.5)}";
"overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.5)}";
Comment thread
andiwand marked this conversation as resolved.
// `.t`: shared base for all absolutely-positioned line blocks.
// `font-size:0` collapses its strut, which outranks the run it holds and
// would take the line box's baseline.
out.out() << ".t{position:absolute;left:0;top:0;transform-origin:0 0;"
"white-space:pre;line-height:1;font-kerning:none;"
"white-space:pre;line-height:1;font-size:0;font-kerning:none;"
"font-variant-ligatures:none}";
write_mode_css();
// SVG overlay covering the page box (visual graphics layer).
Expand Down Expand Up @@ -2472,11 +2480,15 @@ class HtmlServiceImpl final : public HtmlService {
}
const std::string url = file_to_url(reencoded, "font/ttf");
const std::string n = std::to_string(index + 1);
font_faces += "@font-face{font-family:'odr-f";
font_faces += n;
font_faces += "';src:url(";
font_faces += url;
font_faces += ");}";
// The overrides sum to one em, so `line-height:1` puts the baseline at
// exactly the `ascent_em` a run's `top` is derived from.
const double ascent = ascent_em(&font);
std::ostringstream face;
face << "@font-face{font-family:'odr-f" << n << "';src:url(" << url
<< ");ascent-override:" << round2(ascent * 100.0)
<< "%;descent-override:" << round2((1.0 - ascent) * 100.0)
<< "%;line-gap-override:0%}";
font_faces += std::move(face).str();
const auto rule = [&](const char *cls, const char *color) {
font_styles += '.';
font_styles += cls;
Expand All @@ -2495,19 +2507,20 @@ class HtmlServiceImpl final : public HtmlService {
}
}

static double ascent_em(const pdf::TextElement &text) {
/// Baseline offset below a line block's `top`, in em. Capped at one em so
/// the `@font-face` descent can make the two sum to it.
static double ascent_em(const pdf::Font *font) {
double em = 0.8;
if (text.font != nullptr && text.font->descriptor_ascent) {
em = *text.font->descriptor_ascent;
} else if (text.font != nullptr && text.font->embedded_font != nullptr) {
const std::uint16_t units = text.font->embedded_font->units_per_em();
if (font != nullptr && font->descriptor_ascent) {
em = *font->descriptor_ascent;
} else if (font != nullptr && font->embedded_font != nullptr) {
const std::uint16_t units = font->embedded_font->units_per_em();
if (units != 0) {
em = static_cast<double>(
text.font->embedded_font->bounding_box().y_max) /
em = static_cast<double>(font->embedded_font->bounding_box().y_max) /
units;
}
}
return std::clamp(em, 0.5, 1.2);
return std::clamp(em, 0.5, 1.0);
}

static std::string glyph_run_str(const pdf::Font &font,
Expand Down
6 changes: 4 additions & 2 deletions src/odr/internal/pdf/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,12 @@ 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 deliberately not decoded** in the filter framework
- **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. `Crypt` passes through only as `Identity`.
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`.
- **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
38 changes: 32 additions & 6 deletions src/odr/internal/pdf/pdf_color.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,6 @@ namespace {

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

/// Naive DeviceCMYK -> RGB (no ICC), matching the HTML emitter's conversion.
std::array<double, 3> cmyk_to_rgb(const double c, const double m,
const double y, const double k) {
return {(1 - c) * (1 - k), (1 - m) * (1 - k), (1 - y) * (1 - k)};
}

/// sRGB gamma encode of a linear component (IEC 61966-2-1).
double linear_to_srgb(const double c) {
const double v = clamp01(c);
Expand Down Expand Up @@ -183,6 +177,38 @@ std::vector<double> ColorSpaceDef::initial_components() const {

namespace odr::internal {

std::array<double, 3> pdf::cmyk_to_rgb(const double c, const double m,
const double y, const double k) {
const double r =
255 +
c * (-4.387332384609988 * c + 54.48615194189176 * m +
18.82290502165302 * y + 212.25662451639585 * k - 285.2331026137004) +
m * (1.7149763477362134 * m - 5.6096736904047315 * y -
17.873870861415444 * k - 5.497006427196366) +
y * (-2.5217340131683033 * y - 21.248923337353073 * k +
17.5119270841813) +
k * (-21.86122147463605 * k - 189.48180835922747);
const double g =
255 +
c * (8.841041422036149 * c + 60.118027045597366 * m +
6.871425592049007 * y + 31.159100130055922 * k - 79.2970844816548) +
m * (-15.310361306967817 * m + 17.575251261109482 * y +
131.35250912493976 * k - 190.9453302588951) +
y * (4.444339102852739 * y + 9.8632861493405 * k - 24.86741582555878) +
k * (-20.737325471181034 * k - 187.80453709719578);
const double b = 255 +
c * (0.8842522430003296 * c + 8.078677503112928 * m +
30.89978309703729 * y - 0.23883238689178934 * k -
14.183576799673286) +
m * (10.49593273432072 * m + 63.02378494754052 * y +
50.606957656360734 * k - 112.23884253719248) +
y * (0.03296041114873217 * y + 115.60384449646641 * k -
193.58209356861505) +
k * (-22.33816807309886 * k - 180.12613974708367);
return {pdf::clamp01(r / 255.0), pdf::clamp01(g / 255.0),
pdf::clamp01(b / 255.0)};
}

std::shared_ptr<pdf::ColorSpaceDef>
pdf::parse_color_space(const Object &object, const ColorSpaceContext &context) {
const Object resolved = context.resolve(object);
Expand Down
4 changes: 4 additions & 0 deletions src/odr/internal/pdf/pdf_color.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ struct ColorSpaceContext {
std::function<std::shared_ptr<ColorSpaceDef>(const std::string &)> named;
};

/// DeviceCMYK -> sRGB without an ICC engine: pdf.js's polynomial fit of Adobe's
/// 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 or malformed definition.
Expand Down
4 changes: 2 additions & 2 deletions src/odr/internal/pdf/pdf_document_element.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,8 @@ struct XObject final : Element {
// --- image (`/Subtype /Image`) ---
/// Browser-ready bytes: a `DCTDecode` JPEG passed through, or a raster
/// re-encoded as PNG (with any `/SMask`/`/Mask` composited into RGBA). Empty
/// for an undecodable codec (JPX/CCITT/JBIG2) and for a stencil, so `Do`
/// skips it.
/// for an undecodable codec (CCITT/JBIG2) and for a stencil, so `Do` skips
/// it.
std::string image_data;
std::string image_mime;

Expand Down
9 changes: 7 additions & 2 deletions src/odr/internal/pdf/pdf_document_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -749,7 +749,7 @@ std::vector<std::uint8_t> resolve_mask_alpha(DocumentParser &parser,
DecodeResult result =
decode(filter, decode_parms, parser.read_object_stream(object));
if (result.stopped_at_filter.has_value()) {
return {}; // an image codec we cannot decode (CCITT/JBIG2/JPX)
return {}; // an image codec we cannot decode (CCITT/JBIG2)
}
return decode_mask_alpha(
result.data, image_int(parser, dictionary, "Width", 0),
Expand Down Expand Up @@ -841,10 +841,15 @@ void parse_image_data(DocumentParser &parser, const Dictionary &dictionary,
}
}

// A JPX codestream may carry its own opacity; `/SMaskInData` says whether it
// counts (8.9.5.4).
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)) {
decode_array, alpha, color_key, smask_in_data)) {
x_object.image_data = std::move(encoded->data);
x_object.image_mime = std::move(encoded->mime);
}
Expand Down
Loading
Loading