test: verify image xobjects

This commit is contained in:
saqib mir
2026-06-19 17:08:36 +05:30
parent 7e6e7ef32d
commit 8e9b0c8dfb
5 changed files with 994 additions and 200 deletions
+250 -94
View File
@@ -1,113 +1,245 @@
#include "image_decoder.hpp"
#include <stdexcept>
#include <iostream>
#include <algorithm>
#ifdef PDFENGINE_WITH_SKIA
#include <include/core/SkData.h>
#include <include/core/SkImage.h>
#include <include/core/SkBitmap.h>
#include <include/core/SkImageInfo.h>
#include <algorithm>
#include <csetjmp>
#include <cstdio>
#include <cmath>
#include <iostream>
#include <jpeglib.h>
namespace {
std::string nameValue(QPDFObjectHandle dict, const char* key) {
if (!dict.isDictionary() || !dict.hasKey(key)) {
return {};
}
auto value = dict.getKey(key);
if (value.isName()) {
return value.getName();
}
if (value.isArray() && value.getArrayNItems() > 0 && value.getArrayItem(0).isName()) {
return value.getArrayItem(0).getName();
}
return {};
}
int intValue(QPDFObjectHandle dict, const char* key, int fallback) {
if (!dict.isDictionary() || !dict.hasKey(key)) {
return fallback;
}
return static_cast<int>(dict.getKey(key).getNumericValue());
}
#ifdef _MSC_VER
#pragma warning(push)
#pragma warning(disable: 4324)
#endif
struct JpegErrorManager {
jpeg_error_mgr pub;
std::jmp_buf jump;
};
#ifdef _MSC_VER
#pragma warning(pop)
#endif
void jpegErrorExit(j_common_ptr cinfo) {
auto* manager = reinterpret_cast<JpegErrorManager*>(cinfo->err);
longjmp(manager->jump, 1);
}
} // namespace
namespace pdfengine {
std::vector<uint8_t> ImageDecoder::decode(QPDFObjectHandle imageStream,
const std::string& colorSpace,
int width, int height,
int bitsPerComponent,
std::vector<uint8_t> ImageDecoder::decode(QPDFObjectHandle imageStream,
const std::string& colorSpace,
int width,
int height,
int bitsPerComponent,
const std::string& filter) {
if (!imageStream.isStream()) {
return {};
}
// For JPEG, we need the raw compressed stream. QPDF's getStreamData with qpdf_dl_all
// generally uncompresses FlateDecode but leaves DCTDecode compressed if it cannot decode it,
// OR we can explicitly get the raw stream data.
// Actually, getRawStreamData() gives the raw bytes. Let's check filter type.
bool isJpeg = (filter == "/DCTDecode");
const bool isJpeg = (filter == "/DCTDecode");
std::shared_ptr<Buffer> buffer;
if (isJpeg) {
// We want the raw compressed JPEG bytes
buffer = imageStream.getRawStreamData();
} else {
// FlateDecode or other filters - we want QPDF to uncompress it for us
buffer = imageStream.getStreamData();
}
if (!buffer) return {};
if (!buffer) {
return {};
}
std::vector<unsigned char> rawBytes(buffer->getBuffer(), buffer->getBuffer() + buffer->getSize());
std::vector<uint8_t> rgba;
if (isJpeg) {
return decodeJpegWithSkia(rawBytes);
rgba = decodeJpeg(rawBytes);
} else {
// Raw pixels (e.g., from FlateDecode)
if (bitsPerComponent != 8) {
std::cerr << "Warning: bitsPerComponent " << bitsPerComponent << " not fully supported yet for raw images.\n";
// For now, if it's not 8, we just attempt standard conversion assuming bytes are padded,
// but real implementation needs bit-packing logic.
std::cerr << "Warning: bitsPerComponent " << bitsPerComponent
<< " not fully supported yet for raw images.\n";
}
if (colorSpace == "/DeviceGray") {
return convertGrayToRgba(rawBytes, width, height);
rgba = convertGrayToRgba(rawBytes, width, height);
} else if (colorSpace == "/DeviceRGB") {
return convertRgbToRgba(rawBytes, width, height);
rgba = convertRgbToRgba(rawBytes, width, height);
} else if (colorSpace == "/DeviceCMYK") {
return convertCmykToRgba(rawBytes, width, height);
rgba = convertCmykToRgba(rawBytes, width, height);
} else {
// Default: treat as RGB or something fallback
std::cerr << "Warning: Unsupported color space " << colorSpace << ", falling back to RGB extraction.\n";
return convertRgbToRgba(rawBytes, width, height);
std::cerr << "Warning: Unsupported color space " << colorSpace
<< ", falling back to RGB extraction.\n";
rgba = convertRgbToRgba(rawBytes, width, height);
}
}
}
std::vector<uint8_t> ImageDecoder::decodeJpegWithSkia(const std::vector<unsigned char>& jpegBytes) {
(void)jpegBytes;
#ifdef PDFENGINE_WITH_SKIA
sk_sp<SkData> data = SkData::MakeWithCopy(jpegBytes.data(), jpegBytes.size());
sk_sp<SkImage> image = SkImage::MakeFromEncoded(data);
if (!image) {
std::cerr << "Error: Failed to decode JPEG with Skia\n";
return {};
}
int w = image->width();
int h = image->height();
std::vector<uint8_t> rgba(w * h * 4);
SkImageInfo dstInfo = SkImageInfo::Make(w, h, kRGBA_8888_SkColorType, kUnpremul_SkAlphaType);
bool success = image->readPixels(dstInfo, rgba.data(), w * 4, 0, 0);
if (!success) {
std::cerr << "Error: Failed to read pixels from decoded SkImage\n";
return {};
}
applySoftMask(imageStream, rgba, width, height);
return rgba;
#else
std::cerr << "Error: Skia is required for JPEG decoding\n";
return {};
#endif
}
std::vector<uint8_t> ImageDecoder::convertGrayToRgba(const std::vector<unsigned char>& rawBytes, int width, int height) {
std::vector<uint8_t> ImageDecoder::decodeJpeg(const std::vector<unsigned char>& jpegBytes) {
if (jpegBytes.empty()) {
return {};
}
jpeg_decompress_struct cinfo{};
JpegErrorManager jerr{};
cinfo.err = jpeg_std_error(&jerr.pub);
jerr.pub.error_exit = jpegErrorExit;
#ifdef _MSC_VER
#pragma warning(push)
#pragma warning(disable: 4611)
#endif
if (setjmp(jerr.jump)) {
jpeg_destroy_decompress(&cinfo);
std::cerr << "Error: Failed to decode JPEG image stream\n";
return {};
}
#ifdef _MSC_VER
#pragma warning(pop)
#endif
jpeg_create_decompress(&cinfo);
jpeg_mem_src(&cinfo, const_cast<unsigned char*>(jpegBytes.data()),
static_cast<unsigned long>(jpegBytes.size()));
jpeg_read_header(&cinfo, TRUE);
const bool cmykSource = cinfo.jpeg_color_space == JCS_CMYK ||
cinfo.jpeg_color_space == JCS_YCCK;
cinfo.out_color_space = cmykSource ? JCS_CMYK : JCS_RGB;
jpeg_start_decompress(&cinfo);
const int width = static_cast<int>(cinfo.output_width);
const int height = static_cast<int>(cinfo.output_height);
const int components = static_cast<int>(cinfo.output_components);
const int rowStride = width * components;
std::vector<uint8_t> rgba(static_cast<size_t>(width) * height * 4);
std::vector<JSAMPLE> row(static_cast<size_t>(rowStride));
while (cinfo.output_scanline < cinfo.output_height) {
JSAMPROW rowPointer = row.data();
const int y = static_cast<int>(cinfo.output_scanline);
jpeg_read_scanlines(&cinfo, &rowPointer, 1);
for (int x = 0; x < width; ++x) {
const auto src = static_cast<size_t>(x) * components;
const auto dst = (static_cast<size_t>(y) * width + x) * 4;
if (components == 1) {
const uint8_t gray = row[src];
rgba[dst + 0] = gray;
rgba[dst + 1] = gray;
rgba[dst + 2] = gray;
} else if (components == 4) {
const float c = row[src + 0] / 255.0f;
const float m = row[src + 1] / 255.0f;
const float yv = row[src + 2] / 255.0f;
const float k = row[src + 3] / 255.0f;
rgba[dst + 0] = static_cast<uint8_t>(std::clamp(255.0f * (1.0f - c) * (1.0f - k), 0.0f, 255.0f));
rgba[dst + 1] = static_cast<uint8_t>(std::clamp(255.0f * (1.0f - m) * (1.0f - k), 0.0f, 255.0f));
rgba[dst + 2] = static_cast<uint8_t>(std::clamp(255.0f * (1.0f - yv) * (1.0f - k), 0.0f, 255.0f));
} else {
rgba[dst + 0] = row[src + 0];
rgba[dst + 1] = row[src + 1];
rgba[dst + 2] = row[src + 2];
}
rgba[dst + 3] = 255;
}
}
jpeg_finish_decompress(&cinfo);
jpeg_destroy_decompress(&cinfo);
return rgba;
}
void ImageDecoder::applySoftMask(QPDFObjectHandle imageStream,
std::vector<uint8_t>& rgba,
int width,
int height) {
if (rgba.size() != static_cast<size_t>(width) * height * 4) {
return;
}
auto dict = imageStream.getDict();
if (!dict.isDictionary() || !dict.hasKey("/SMask")) {
return;
}
auto smask = dict.getKey("/SMask");
if (!smask.isStream()) {
return;
}
auto smaskDict = smask.getDict();
const int maskWidth = intValue(smaskDict, "/Width", 0);
const int maskHeight = intValue(smaskDict, "/Height", 0);
if (maskWidth != width || maskHeight != height) {
std::cerr << "Warning: Soft mask dimensions do not match image dimensions.\n";
return;
}
const int maskBpc = intValue(smaskDict, "/BitsPerComponent", 8);
const std::string maskColorSpace = nameValue(smaskDict, "/ColorSpace").empty()
? "/DeviceGray"
: nameValue(smaskDict, "/ColorSpace");
const std::string maskFilter = nameValue(smaskDict, "/Filter");
auto maskRgba = decode(smask, maskColorSpace, maskWidth, maskHeight, maskBpc, maskFilter);
if (maskRgba.size() != rgba.size()) {
return;
}
for (int i = 0; i < width * height; ++i) {
rgba[static_cast<size_t>(i) * 4 + 3] = maskRgba[static_cast<size_t>(i) * 4];
}
}
std::vector<uint8_t> ImageDecoder::convertGrayToRgba(const std::vector<unsigned char>& rawBytes,
int width,
int height) {
int expectedSize = width * height;
std::vector<uint8_t> rgba;
rgba.reserve(width * height * 4);
for (int i = 0; i < std::min((int)rawBytes.size(), expectedSize); ++i) {
for (int i = 0; i < std::min(static_cast<int>(rawBytes.size()), expectedSize); ++i) {
uint8_t gray = rawBytes[i];
rgba.push_back(gray); // R
rgba.push_back(gray); // G
rgba.push_back(gray); // B
rgba.push_back(255); // A
rgba.push_back(gray);
rgba.push_back(gray);
rgba.push_back(gray);
rgba.push_back(255);
}
// Pad if stream was too short
while (rgba.size() < (size_t)(width * height * 4)) {
while (rgba.size() < static_cast<size_t>(width * height * 4)) {
rgba.push_back(0);
rgba.push_back(0);
rgba.push_back(0);
@@ -116,53 +248,77 @@ std::vector<uint8_t> ImageDecoder::convertGrayToRgba(const std::vector<unsigned
return rgba;
}
std::vector<uint8_t> ImageDecoder::convertRgbToRgba(const std::vector<unsigned char>& rawBytes, int width, int height) {
std::vector<uint8_t> ImageDecoder::convertRgbToRgba(const std::vector<unsigned char>& rawBytes,
int width,
int height) {
int expectedSize = width * height * 3;
std::vector<uint8_t> rgba;
rgba.reserve(width * height * 4);
for (int i = 0; i + 2 < std::min((int)rawBytes.size(), expectedSize); i += 3) {
rgba.push_back(rawBytes[i]); // R
rgba.push_back(rawBytes[i + 1]); // G
rgba.push_back(rawBytes[i + 2]); // B
rgba.push_back(255); // A
for (int i = 0; i + 2 < std::min(static_cast<int>(rawBytes.size()), expectedSize); i += 3) {
rgba.push_back(rawBytes[i]);
rgba.push_back(rawBytes[i + 1]);
rgba.push_back(rawBytes[i + 2]);
rgba.push_back(255);
}
while (rgba.size() < (size_t)(width * height * 4)) {
rgba.push_back(0); rgba.push_back(0); rgba.push_back(0); rgba.push_back(255);
while (rgba.size() < static_cast<size_t>(width * height * 4)) {
rgba.push_back(0);
rgba.push_back(0);
rgba.push_back(0);
rgba.push_back(255);
}
return rgba;
}
std::vector<uint8_t> ImageDecoder::convertCmykToRgba(const std::vector<unsigned char>& rawBytes, int width, int height) {
std::vector<uint8_t> ImageDecoder::convertCmykToRgba(const std::vector<unsigned char>& rawBytes,
int width,
int height) {
int expectedSize = width * height * 4;
std::vector<uint8_t> rgba;
rgba.reserve(width * height * 4);
for (int i = 0; i + 3 < std::min((int)rawBytes.size(), expectedSize); i += 4) {
// PDF CMYK is typically 0 = 0% ink, 255 = 100% ink
// Wait, usually PDF CMYK is inverted or direct.
// Standard CMYK to RGB:
// R = 255 * (1 - C/255) * (1 - K/255)
// G = 255 * (1 - M/255) * (1 - K/255)
// B = 255 * (1 - Y/255) * (1 - K/255)
for (int i = 0; i + 3 < std::min(static_cast<int>(rawBytes.size()), expectedSize); i += 4) {
float c = rawBytes[i] / 255.0f;
float m = rawBytes[i + 1] / 255.0f;
float y = rawBytes[i + 2] / 255.0f;
float k = rawBytes[i + 3] / 255.0f;
// Note: Sometimes Adobe CMYK is inverted. Let's stick to standard first.
float r = 255.0f * (1.0f - c) * (1.0f - k);
float g = 255.0f * (1.0f - m) * (1.0f - k);
float b = 255.0f * (1.0f - y) * (1.0f - k);
auto applyInk = [](float paper, float processColor, float amount) {
return paper * ((1.0f - amount) + amount * (processColor / 255.0f));
};
rgba.push_back(static_cast<uint8_t>(std::clamp(r, 0.0f, 255.0f)));
rgba.push_back(static_cast<uint8_t>(std::clamp(g, 0.0f, 255.0f)));
rgba.push_back(static_cast<uint8_t>(std::clamp(b, 0.0f, 255.0f)));
float r = 255.0f;
float g = 255.0f;
float b = 255.0f;
// Match PDFium's default DeviceCMYK process primaries closely enough for
// unprofiled print images until ICCBased color management lands.
r = applyInk(r, 0.0f, c);
g = applyInk(g, 174.0f, c);
b = applyInk(b, 239.0f, c);
r = applyInk(r, 237.0f, m);
g = applyInk(g, 0.0f, m);
b = applyInk(b, 140.0f, m);
r = applyInk(r, 255.0f, y);
g = applyInk(g, 241.0f, y);
b = applyInk(b, 0.0f, y);
r = applyInk(r, 35.0f, k);
g = applyInk(g, 31.0f, k);
b = applyInk(b, 32.0f, k);
rgba.push_back(static_cast<uint8_t>(std::clamp(std::lround(r), 0l, 255l)));
rgba.push_back(static_cast<uint8_t>(std::clamp(std::lround(g), 0l, 255l)));
rgba.push_back(static_cast<uint8_t>(std::clamp(std::lround(b), 0l, 255l)));
rgba.push_back(255);
}
while (rgba.size() < (size_t)(width * height * 4)) {
rgba.push_back(0); rgba.push_back(0); rgba.push_back(0); rgba.push_back(255);
while (rgba.size() < static_cast<size_t>(width * height * 4)) {
rgba.push_back(0);
rgba.push_back(0);
rgba.push_back(0);
rgba.push_back(255);
}
return rgba;
}
+2 -1
View File
@@ -18,7 +18,8 @@ public:
const std::string& filter);
private:
static std::vector<uint8_t> decodeJpegWithSkia(const std::vector<unsigned char>& jpegBytes);
static std::vector<uint8_t> decodeJpeg(const std::vector<unsigned char>& jpegBytes);
static void applySoftMask(QPDFObjectHandle imageStream, std::vector<uint8_t>& rgba, int width, int height);
// Converts raw DeviceGray (1 byte per pixel) to RGBA (4 bytes per pixel)
static std::vector<uint8_t> convertGrayToRgba(const std::vector<unsigned char>& rawBytes, int width, int height);
+1
View File
@@ -196,6 +196,7 @@ void ContentBuilder::handleDo(const Operation& op, std::vector<std::unique_ptr<C
imageObj->bitsPerComponent = bpc;
imageObj->colorSpace = colorSpace;
imageObj->filter = filter;
imageObj->hasSoftMask = streamDict.hasKey("/SMask");
imageObj->transform = state_.ctm;
imageObj->pixelData = ImageDecoder::decode(resolved.object, colorSpace, width, height, bpc, filter);
+8 -2
View File
@@ -13,11 +13,13 @@ add_executable(pdfengine_smoke
ast_serializer_test.cpp
content_serializer_test.cpp
qpdf_writer_test.cpp
image_xobject_test.cpp
)
if(PDFENGINE_WITH_QPDF)
target_sources(pdfengine_smoke PRIVATE qpdf_extractor_test.cpp)
target_sources(pdfengine_smoke PRIVATE
qpdf_extractor_test.cpp
image_xobject_test.cpp
)
endif()
target_link_libraries(pdfengine_smoke
@@ -31,6 +33,10 @@ if(PDFENGINE_WITH_SKIA)
target_link_libraries(pdfengine_smoke PRIVATE skia::skia)
endif()
if(PDFENGINE_WITH_QPDF)
target_link_libraries(pdfengine_smoke PRIVATE qpdf::libqpdf ZLIB::ZLIB JPEG::JPEG)
endif()
if(MSVC)
target_link_options(pdfengine_smoke PRIVATE "/FORCE:MULTIPLE")
endif()
+733 -103
View File
@@ -1,123 +1,753 @@
#include <gtest/gtest.h>
#include <pdfengine/token.hpp>
#include <pdfengine/ast.hpp>
#include <pdfengine/content_object.hpp>
#include <pdfengine/skia_renderer.hpp>
#include <pdfengine/pdf_document.hpp>
#include "../src/parser/content_builder.hpp"
#include "../src/parser/lexer.hpp"
#include "../src/parser/parser.hpp"
#include "../src/parser/content_builder.hpp"
#include "../src/core/image_decoder.hpp"
#include <vector>
#include <memory>
#include <cmath>
#include "../src/qpdf/qpdf_resource_resolver.hpp"
#ifdef PDFENGINE_WITH_SKIA
#include <include/core/SkCanvas.h>
#include <include/core/SkBitmap.h>
#include <include/core/SkColor.h>
#endif
#include <qpdf/Buffer.hh>
#include <qpdf/QPDF.hh>
#include <qpdf/QPDFObjectHandle.hh>
#include <zlib.h>
#include <jpeglib.h>
#include <algorithm>
#include <array>
#include <cmath>
#include <csetjmp>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <memory>
#include <optional>
#include <sstream>
#include <string>
#include <string_view>
#include <vector>
using namespace pdfengine;
using namespace pdfengine::qpdf_layer;
// A mock resource resolver to inject dummy images during testing
class MockResourceResolver : public ResourceResolver {
public:
std::string mockColorSpace = "/DeviceGray";
std::string mockFilter = "";
int mockBpc = 8;
int mockWidth = 2;
int mockHeight = 2;
std::vector<unsigned char> mockRawData;
bool isJpeg = false;
// We mock the decoded behavior by hooking into the decoder manually or validating the extracted object
// But since ContentBuilder invokes ImageDecoder, we actually need to return a valid QPDFObjectHandle stream.
// That's difficult to mock without a real QPDF instance.
// Wait, the ImageDecoder uses QPDFObjectHandle getStreamData.
// Instead of full QPDF stream, we can subclass ContentBuilder or pass raw ImageObject if we just want to test conversion?
// Let's test the ImageDecoder logic directly for colorspace conversions.
ResolvedXObject resolveXObject(const std::string& name) override {
(void)name;
// Return dummy - but we can't easily fake QPDFObjectHandle streams without QPDF.
// We will just test ImageDecoder directly for conversion logic.
return {};
}
namespace {
struct Rgba {
int r = 0;
int g = 0;
int b = 0;
int a = 255;
};
// Directly testing ImageDecoder logic (Test 1, 2, 3)
TEST(ImageDecoderTest, DeviceGrayToRgba) {
std::vector<unsigned char> gray = { 0, 128, 255, 64 };
auto rgba = ImageDecoder::decode(QPDFObjectHandle(), "/DeviceGray", 2, 2, 8, "");
// Note: Since QPDFObjectHandle is null, decode returns empty if it tries to read stream.
// But we made convertGrayToRgba private.
struct PdfObject {
int id = 0;
std::vector<uint8_t> body;
};
struct ImageSpec {
std::string name;
int id = 0;
int width = 0;
int height = 0;
std::string colorSpace = "/DeviceRGB";
std::string filter;
std::vector<uint8_t> streamData;
std::optional<int> smaskId;
};
void appendText(std::vector<uint8_t>& out, std::string_view text) {
out.insert(out.end(), text.begin(), text.end());
}
// Actually, testing DisplayList and Matrix transforms (Test 6, 7, 8)
TEST(ImageXObjectTest, TransformMatrixVerification) {
// We will verify the Matrix calculations.
Matrix ctm;
// Translate 50 50
Matrix m1 = {1, 0, 0, 1, 50, 50};
ctm = ctm.multiply(m1);
EXPECT_DOUBLE_EQ(ctm.e, 50.0);
EXPECT_DOUBLE_EQ(ctm.f, 50.0);
// Scale 100 100
Matrix m2 = {100, 0, 0, 100, 0, 0};
ctm = ctm.multiply(m2);
EXPECT_DOUBLE_EQ(ctm.a, 100.0);
EXPECT_DOUBLE_EQ(ctm.d, 100.0);
EXPECT_DOUBLE_EQ(ctm.e, 5000.0);
EXPECT_DOUBLE_EQ(ctm.f, 5000.0);
std::vector<uint8_t> textBody(const std::string& text) {
return std::vector<uint8_t>(text.begin(), text.end());
}
// Since QPDF object mocking is hard, the real tests (4, 5, 9, 10) require reading an actual PDF with images.
// We will test the builder's state tracking.
TEST(ImageXObjectTest, BuilderStateTracking) {
Lexer lexer("q 100 0 0 100 50 50 cm /Im1 Do Q");
std::vector<uint8_t> makeStreamBody(const std::string& dictionaryEntries,
const std::vector<uint8_t>& streamData) {
std::vector<uint8_t> body;
appendText(body, "<< ");
appendText(body, dictionaryEntries);
appendText(body, " /Length ");
appendText(body, std::to_string(streamData.size()));
appendText(body, " >>\nstream\n");
body.insert(body.end(), streamData.begin(), streamData.end());
appendText(body, "\nendstream");
return body;
}
std::vector<uint8_t> makeImageBody(const ImageSpec& image) {
std::ostringstream dict;
dict << "/Type /XObject /Subtype /Image"
<< " /Width " << image.width
<< " /Height " << image.height;
if (!image.colorSpace.empty()) {
dict << " /ColorSpace " << image.colorSpace;
}
dict << " /BitsPerComponent 8";
if (!image.filter.empty()) {
dict << " /Filter " << image.filter;
}
if (image.smaskId) {
dict << " /SMask " << *image.smaskId << " 0 R";
}
return makeStreamBody(dict.str(), image.streamData);
}
std::vector<uint8_t> makePdf(std::vector<PdfObject> objects, int rootObjectId = 1) {
std::sort(objects.begin(), objects.end(), [](const PdfObject& a, const PdfObject& b) {
return a.id < b.id;
});
int maxId = 0;
for (const auto& object : objects) {
maxId = std::max(maxId, object.id);
}
std::vector<size_t> offsets(static_cast<size_t>(maxId) + 1, 0);
std::vector<uint8_t> pdf;
appendText(pdf, "%PDF-1.4\n%\xFF\xFF\xFF\xFF\n");
for (const auto& object : objects) {
offsets[static_cast<size_t>(object.id)] = pdf.size();
appendText(pdf, std::to_string(object.id));
appendText(pdf, " 0 obj\n");
pdf.insert(pdf.end(), object.body.begin(), object.body.end());
appendText(pdf, "\nendobj\n");
}
const size_t xrefOffset = pdf.size();
appendText(pdf, "xref\n0 ");
appendText(pdf, std::to_string(maxId + 1));
appendText(pdf, "\n0000000000 65535 f \n");
for (int id = 1; id <= maxId; ++id) {
char row[32]{};
std::snprintf(row, sizeof(row), "%010zu 00000 n \n", offsets[static_cast<size_t>(id)]);
appendText(pdf, row);
}
appendText(pdf, "trailer\n<< /Size ");
appendText(pdf, std::to_string(maxId + 1));
appendText(pdf, " /Root ");
appendText(pdf, std::to_string(rootObjectId));
appendText(pdf, " 0 R >>\nstartxref\n");
appendText(pdf, std::to_string(xrefOffset));
appendText(pdf, "\n%%EOF\n");
return pdf;
}
std::vector<uint8_t> buildSinglePagePdf(int pageWidth,
int pageHeight,
const std::string& content,
const std::vector<ImageSpec>& images) {
std::ostringstream xobjects;
for (const auto& image : images) {
if (!image.name.empty()) {
xobjects << "/" << image.name << " " << image.id << " 0 R ";
}
}
std::vector<PdfObject> objects;
objects.push_back({1, textBody("<< /Type /Catalog /Pages 2 0 R >>")});
objects.push_back({2, textBody("<< /Type /Pages /Kids [3 0 R] /Count 1 >>")});
std::ostringstream page;
page << "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 " << pageWidth << " " << pageHeight
<< "] /Resources << /XObject << " << xobjects.str()
<< ">> >> /Contents 4 0 R >>";
objects.push_back({3, textBody(page.str())});
const std::vector<uint8_t> contentBytes(content.begin(), content.end());
objects.push_back({4, makeStreamBody("", contentBytes)});
for (const auto& image : images) {
objects.push_back({image.id, makeImageBody(image)});
}
return makePdf(std::move(objects));
}
std::vector<uint8_t> flate(const std::vector<uint8_t>& raw) {
uLongf size = compressBound(static_cast<uLong>(raw.size()));
std::vector<uint8_t> compressed(size);
const int result = compress2(compressed.data(), &size, raw.data(),
static_cast<uLong>(raw.size()), Z_BEST_COMPRESSION);
EXPECT_EQ(result, Z_OK);
compressed.resize(size);
return compressed;
}
#ifdef _MSC_VER
#pragma warning(push)
#pragma warning(disable: 4324)
#endif
struct JpegErrorManager {
jpeg_error_mgr pub;
std::jmp_buf jump;
};
#ifdef _MSC_VER
#pragma warning(pop)
#endif
void jpegErrorExit(j_common_ptr cinfo) {
auto* manager = reinterpret_cast<JpegErrorManager*>(cinfo->err);
longjmp(manager->jump, 1);
}
std::vector<uint8_t> encodeJpegRgb(const std::vector<uint8_t>& rgb,
int width,
int height,
int quality = 95) {
jpeg_compress_struct cinfo{};
JpegErrorManager jerr{};
cinfo.err = jpeg_std_error(&jerr.pub);
jerr.pub.error_exit = jpegErrorExit;
unsigned char* output = nullptr;
unsigned long outputSize = 0;
#ifdef _MSC_VER
#pragma warning(push)
#pragma warning(disable: 4611)
#endif
if (setjmp(jerr.jump)) {
jpeg_destroy_compress(&cinfo);
std::free(output);
return {};
}
#ifdef _MSC_VER
#pragma warning(pop)
#endif
jpeg_create_compress(&cinfo);
jpeg_mem_dest(&cinfo, &output, &outputSize);
cinfo.image_width = static_cast<JDIMENSION>(width);
cinfo.image_height = static_cast<JDIMENSION>(height);
cinfo.input_components = 3;
cinfo.in_color_space = JCS_RGB;
jpeg_set_defaults(&cinfo);
jpeg_set_quality(&cinfo, quality, TRUE);
jpeg_start_compress(&cinfo, TRUE);
const int rowStride = width * 3;
while (cinfo.next_scanline < cinfo.image_height) {
JSAMPROW row = const_cast<JSAMPLE*>(
reinterpret_cast<const JSAMPLE*>(rgb.data() + cinfo.next_scanline * rowStride));
jpeg_write_scanlines(&cinfo, &row, 1);
}
jpeg_finish_compress(&cinfo);
std::vector<uint8_t> jpeg(output, output + outputSize);
jpeg_destroy_compress(&cinfo);
std::free(output);
return jpeg;
}
std::string decodedStream(QPDFObjectHandle contents) {
std::string decoded;
if (contents.isStream()) {
auto buffer = contents.getStreamData();
decoded.assign(reinterpret_cast<const char*>(buffer->getBuffer()), buffer->getSize());
} else if (contents.isArray()) {
for (int i = 0; i < contents.getArrayNItems(); ++i) {
auto buffer = contents.getArrayItem(i).getStreamData();
decoded.append(reinterpret_cast<const char*>(buffer->getBuffer()), buffer->getSize());
decoded.push_back('\n');
}
}
return decoded;
}
std::vector<std::unique_ptr<ContentObject>> buildPageObjects(QPDFObjectHandle page) {
Lexer lexer(decodedStream(page.getKey("/Contents")));
auto tokens = lexer.tokenize();
ContentParser parser(tokens);
ContentBuilder builder; // No resolver, so it drops Do, but we can check state if we expose it, or just verify it doesn't crash
auto ops = parser.parse();
auto objects = builder.build(ops);
EXPECT_TRUE(objects.empty()); // Dropped because resolver is null
auto operations = parser.parse();
QpdfResourceResolver resolver(page.getKey("/Resources"));
ContentBuilder builder(&resolver);
return builder.build(operations);
}
#ifdef PDFENGINE_WITH_SKIA
TEST(ImageXObjectTest, SkiaRenderWithMatrix) {
SkBitmap bitmap;
bitmap.allocN32Pixels(200, 200);
SkCanvas canvas(bitmap);
canvas.clear(SK_ColorWHITE);
SkiaRenderer renderer(&canvas);
ImageInfo img;
img.width = 2; img.height = 2;
img.channels = 4;
img.pixelData = {
255, 0, 0, 255, 0, 255, 0, 255,
0, 0, 255, 255, 0, 0, 0, 255
std::vector<std::unique_ptr<ContentObject>> buildFirstPageObjects(QPDF& qpdf,
const std::vector<uint8_t>& pdfBytes) {
qpdf.processMemoryFile("image-xobject-test", reinterpret_cast<const char*>(pdfBytes.data()), pdfBytes.size());
return buildPageObjects(qpdf.getAllPages().at(0));
}
std::vector<const ImageObject*> imageObjects(const std::vector<std::unique_ptr<ContentObject>>& objects) {
std::vector<const ImageObject*> images;
for (const auto& object : objects) {
if (object->getType() == ContentObjectType::Image) {
images.push_back(static_cast<const ImageObject*>(object.get()));
}
}
return images;
}
Rgba decodedPixel(const ImageObject& image, int x, int y) {
const auto index = (static_cast<size_t>(y) * image.width + x) * 4;
return {
image.pixelData[index + 0],
image.pixelData[index + 1],
image.pixelData[index + 2],
image.pixelData[index + 3],
};
// Test scaled and translated matrix
Matrix m = {100, 0, 0, 100, 50, 50};
DrawImageCommand cmd(img, m, 1.0f);
cmd.accept(renderer);
// Verify pixels on canvas: at (50, 50) it should be RED (255, 0, 0, 255)
// Actually SkColor is ARGB or BGRA depending on platform, but we can check roughly
SkColor c = bitmap.getColor(55, 55);
EXPECT_EQ(SkColorGetR(c), 255);
EXPECT_EQ(SkColorGetG(c), 0);
EXPECT_EQ(SkColorGetB(c), 0);
// Bottom right corner of image at (145, 145) should be BLACK
SkColor c4 = bitmap.getColor(145, 145);
EXPECT_EQ(SkColorGetR(c4), 0);
EXPECT_EQ(SkColorGetG(c4), 0);
EXPECT_EQ(SkColorGetB(c4), 0);
}
Rgba averageDecoded(const ImageObject& image) {
long long r = 0;
long long g = 0;
long long b = 0;
long long a = 0;
const int count = image.width * image.height;
for (int y = 0; y < image.height; ++y) {
for (int x = 0; x < image.width; ++x) {
auto p = decodedPixel(image, x, y);
r += p.r;
g += p.g;
b += p.b;
a += p.a;
}
}
return {
static_cast<int>(r / count),
static_cast<int>(g / count),
static_cast<int>(b / count),
static_cast<int>(a / count),
};
}
Rgba pagePixel(const PageImage& image, int x, int yFromBottom) {
const int row = image.height - 1 - yFromBottom;
const auto index = (static_cast<size_t>(row) * image.width + x) * 4;
return {
image.data[index + 0],
image.data[index + 1],
image.data[index + 2],
image.data[index + 3],
};
}
Rgba averagePageRect(const PageImage& image, int x, int yFromBottom, int width, int height) {
long long r = 0;
long long g = 0;
long long b = 0;
long long a = 0;
int count = 0;
for (int yy = yFromBottom; yy < yFromBottom + height; ++yy) {
for (int xx = x; xx < x + width; ++xx) {
auto p = pagePixel(image, xx, yy);
r += p.r;
g += p.g;
b += p.b;
a += p.a;
++count;
}
}
return {
static_cast<int>(r / count),
static_cast<int>(g / count),
static_cast<int>(b / count),
static_cast<int>(a / count),
};
}
bool nearChannel(int actual, int expected, int tolerance) {
return std::abs(actual - expected) <= tolerance;
}
void expectNearColor(const Rgba& actual, const Rgba& expected, int tolerance) {
EXPECT_TRUE(nearChannel(actual.r, expected.r, tolerance))
<< "red actual=" << actual.r << " expected=" << expected.r;
EXPECT_TRUE(nearChannel(actual.g, expected.g, tolerance))
<< "green actual=" << actual.g << " expected=" << expected.g;
EXPECT_TRUE(nearChannel(actual.b, expected.b, tolerance))
<< "blue actual=" << actual.b << " expected=" << expected.b;
}
bool isWhite(const Rgba& p) {
return p.r > 245 && p.g > 245 && p.b > 245;
}
std::optional<PageImage> renderWithPdfium(const std::vector<uint8_t>& pdfBytes,
int pageIndex = 0,
int dpi = 72) {
auto document = PdfDocument::loadFromMemory(pdfBytes);
if (!document) {
return std::nullopt;
}
auto page = (*document)->getPage(pageIndex);
if (!page) {
return std::nullopt;
}
auto rendered = (*page)->renderRegionRaw(dpi, 0.0, (*page)->height());
if (!rendered) {
return std::nullopt;
}
return rendered.value();
}
void expectMatrix(const Matrix& matrix, float a, float b, float c, float d, float e, float f) {
EXPECT_FLOAT_EQ(matrix.a, a);
EXPECT_FLOAT_EQ(matrix.b, b);
EXPECT_FLOAT_EQ(matrix.c, c);
EXPECT_FLOAT_EQ(matrix.d, d);
EXPECT_FLOAT_EQ(matrix.e, e);
EXPECT_FLOAT_EQ(matrix.f, f);
}
std::vector<uint8_t> rgb(std::initializer_list<uint8_t> values) {
return std::vector<uint8_t>(values);
}
} // namespace
TEST(ImageXObjectVerification, JpegLogoAndPhotoDecodeAndMatchPdfiumRender) {
const auto logoJpeg = encodeJpegRgb(rgb({
220, 20, 20, 20, 210, 30, 25, 30, 220,
230, 210, 30, 230, 40, 200, 20, 210, 210,
}), 3, 2);
std::vector<uint8_t> photoRgb;
for (int y = 0; y < 6; ++y) {
for (int x = 0; x < 8; ++x) {
photoRgb.push_back(static_cast<uint8_t>(35 + x * 24));
photoRgb.push_back(static_cast<uint8_t>(40 + y * 28));
photoRgb.push_back(static_cast<uint8_t>(190 - x * 10 + y * 4));
}
}
const auto photoJpeg = encodeJpegRgb(photoRgb, 8, 6);
ASSERT_FALSE(logoJpeg.empty());
ASSERT_FALSE(photoJpeg.empty());
const std::string content =
"q 3 0 0 2 10 80 cm /Logo Do Q\n"
"q 8 0 0 6 30 80 cm /Photo Do Q\n";
const auto pdf = buildSinglePagePdf(80, 100, content, {
{"Logo", 5, 3, 2, "/DeviceRGB", "/DCTDecode", logoJpeg, std::nullopt},
{"Photo", 6, 8, 6, "/DeviceRGB", "/DCTDecode", photoJpeg, std::nullopt},
});
QPDF qpdf;
auto objects = buildFirstPageObjects(qpdf, pdf);
auto images = imageObjects(objects);
ASSERT_EQ(images.size(), 2u);
EXPECT_EQ(images[0]->name, "Logo");
EXPECT_EQ(images[0]->width, 3);
EXPECT_EQ(images[0]->height, 2);
EXPECT_EQ(images[0]->filter, "/DCTDecode");
EXPECT_EQ(images[0]->pixelData.size(), 3u * 2u * 4u);
expectMatrix(images[0]->transform, 3, 0, 0, 2, 10, 80);
EXPECT_EQ(images[1]->name, "Photo");
EXPECT_EQ(images[1]->width, 8);
EXPECT_EQ(images[1]->height, 6);
EXPECT_EQ(images[1]->filter, "/DCTDecode");
EXPECT_EQ(images[1]->pixelData.size(), 8u * 6u * 4u);
expectMatrix(images[1]->transform, 8, 0, 0, 6, 30, 80);
auto rendered = renderWithPdfium(pdf);
if (!rendered) {
GTEST_SKIP() << "PDFium render unavailable in this build";
}
expectNearColor(averagePageRect(*rendered, 10, 80, 3, 2), averageDecoded(*images[0]), 35);
expectNearColor(averagePageRect(*rendered, 30, 80, 8, 6), averageDecoded(*images[1]), 35);
EXPECT_TRUE(isWhite(pagePixel(*rendered, 9, 80)));
EXPECT_TRUE(isWhite(pagePixel(*rendered, 38, 80)));
}
TEST(ImageXObjectVerification, FlateRgbAndTransparentPngStyleSoftMaskDecodeCorrectly) {
const auto opaqueRgb = rgb({
255, 0, 0, 0, 255, 0,
0, 0, 255, 255, 255, 0,
});
const auto transparentRgb = rgb({
200, 0, 0, 0, 200, 0,
0, 0, 200, 120, 120, 120,
});
const std::vector<uint8_t> alpha = {0, 64, 128, 255};
const std::string content =
"q 2 0 0 2 10 60 cm /Png Do Q\n"
"q 2 0 0 2 20 60 cm /PngAlpha Do Q\n";
const auto pdf = buildSinglePagePdf(60, 80, content, {
{"Png", 5, 2, 2, "/DeviceRGB", "/FlateDecode", flate(opaqueRgb), std::nullopt},
{"PngAlpha", 6, 2, 2, "/DeviceRGB", "/FlateDecode", flate(transparentRgb), 7},
{"", 7, 2, 2, "/DeviceGray", "/FlateDecode", flate(alpha), std::nullopt},
});
QPDF qpdf;
auto objects = buildFirstPageObjects(qpdf, pdf);
auto images = imageObjects(objects);
ASSERT_EQ(images.size(), 2u);
EXPECT_EQ(images[0]->pixelData.size(), 2u * 2u * 4u);
expectNearColor(decodedPixel(*images[0], 0, 0), {255, 0, 0, 255}, 0);
expectNearColor(decodedPixel(*images[0], 1, 0), {0, 255, 0, 255}, 0);
expectNearColor(decodedPixel(*images[0], 0, 1), {0, 0, 255, 255}, 0);
expectNearColor(decodedPixel(*images[0], 1, 1), {255, 255, 0, 255}, 0);
EXPECT_TRUE(images[1]->hasSoftMask);
EXPECT_EQ(decodedPixel(*images[1], 0, 0).a, 0);
EXPECT_EQ(decodedPixel(*images[1], 1, 0).a, 64);
EXPECT_EQ(decodedPixel(*images[1], 0, 1).a, 128);
EXPECT_EQ(decodedPixel(*images[1], 1, 1).a, 255);
}
TEST(ImageXObjectVerification, DeviceGrayMapsBlackAndWhiteExactly) {
const std::vector<uint8_t> gray = {0, 255};
const auto pdf = buildSinglePagePdf(20, 20, "q 2 0 0 1 5 5 cm /Scan Do Q\n", {
{"Scan", 5, 2, 1, "/DeviceGray", "/FlateDecode", flate(gray), std::nullopt},
});
QPDF qpdf;
auto objects = buildFirstPageObjects(qpdf, pdf);
auto images = imageObjects(objects);
ASSERT_EQ(images.size(), 1u);
EXPECT_EQ(decodedPixel(*images[0], 0, 0).r, 0);
EXPECT_EQ(decodedPixel(*images[0], 0, 0).g, 0);
EXPECT_EQ(decodedPixel(*images[0], 0, 0).b, 0);
EXPECT_EQ(decodedPixel(*images[0], 1, 0).r, 255);
EXPECT_EQ(decodedPixel(*images[0], 1, 0).g, 255);
EXPECT_EQ(decodedPixel(*images[0], 1, 0).b, 255);
}
TEST(ImageXObjectVerification, DeviceCmykConversionMatchesPdfiumRender) {
const std::vector<uint8_t> cmyk = {
255, 0, 0, 0, 0, 255, 0, 0,
0, 0, 255, 0, 0, 0, 0, 255,
};
const std::string content = "q 20 0 0 20 10 10 cm /Print Do Q\n";
const auto pdf = buildSinglePagePdf(50, 50, content, {
{"Print", 5, 2, 2, "/DeviceCMYK", "/FlateDecode", flate(cmyk), std::nullopt},
});
QPDF qpdf;
auto objects = buildFirstPageObjects(qpdf, pdf);
auto images = imageObjects(objects);
ASSERT_EQ(images.size(), 1u);
expectNearColor(decodedPixel(*images[0], 0, 0), {0, 174, 239, 255}, 1);
expectNearColor(decodedPixel(*images[0], 1, 0), {237, 0, 140, 255}, 1);
expectNearColor(decodedPixel(*images[0], 0, 1), {255, 241, 0, 255}, 1);
expectNearColor(decodedPixel(*images[0], 1, 1), {35, 31, 32, 255}, 1);
auto rendered = renderWithPdfium(pdf);
if (!rendered) {
GTEST_SKIP() << "PDFium render unavailable in this build";
}
expectNearColor(averagePageRect(*rendered, 10, 20, 10, 10), decodedPixel(*images[0], 0, 0), 12);
expectNearColor(averagePageRect(*rendered, 20, 20, 10, 10), decodedPixel(*images[0], 1, 0), 12);
expectNearColor(averagePageRect(*rendered, 10, 10, 10, 10), decodedPixel(*images[0], 0, 1), 12);
expectNearColor(averagePageRect(*rendered, 20, 10, 10, 10), decodedPixel(*images[0], 1, 1), 12);
}
TEST(ImageXObjectVerification, MatrixTransformTranslationScalingRotationAndFlipping) {
const auto data = flate(rgb({
255, 0, 0, 0, 255, 0,
0, 0, 255, 0, 0, 0,
}));
const std::string content =
"q 100 0 0 100 50 50 cm /Scale Do Q\n"
"q 0 40 -40 0 200 40 cm /Rotate Do Q\n"
"q 40 0 0 -40 230 120 cm /Flip Do Q\n";
const auto pdf = buildSinglePagePdf(280, 180, content, {
{"Scale", 5, 2, 2, "/DeviceRGB", "/FlateDecode", data, std::nullopt},
{"Rotate", 6, 2, 2, "/DeviceRGB", "/FlateDecode", data, std::nullopt},
{"Flip", 7, 2, 2, "/DeviceRGB", "/FlateDecode", data, std::nullopt},
});
QPDF qpdf;
qpdf.processMemoryFile("image-xobject-test", reinterpret_cast<const char*>(pdf.data()), pdf.size());
auto page = qpdf.getAllPages().at(0);
const std::string decodedContent = decodedStream(page.getKey("/Contents"));
Lexer lexer(decodedContent);
auto tokens = lexer.tokenize();
ContentParser parser(tokens);
auto operations = parser.parse();
size_t doOps = 0;
std::vector<std::string> doNames;
for (const auto& operation : operations) {
if (operation.op == "Do") {
++doOps;
if (!operation.operands.empty() && operation.operands.back()->type == AstNodeType::Name) {
doNames.push_back(operation.operands.back()->stringValue);
}
}
}
EXPECT_EQ(doOps, 3u);
EXPECT_EQ(doNames, (std::vector<std::string>{"Scale", "Rotate", "Flip"}));
QpdfResourceResolver resolver(page.getKey("/Resources"));
EXPECT_EQ(resolver.resolveXObject("Scale").type, XObjectType::Image);
EXPECT_EQ(resolver.resolveXObject("Rotate").type, XObjectType::Image);
EXPECT_EQ(resolver.resolveXObject("Flip").type, XObjectType::Image);
ContentBuilder builder(&resolver);
auto objects = builder.build(operations);
auto images = imageObjects(objects);
ASSERT_EQ(images.size(), 3u)
<< "content objects=" << objects.size()
<< " decoded=[" << decodedContent << "]"
<< " resources=[" << page.getKey("/Resources").unparse() << "]";
EXPECT_EQ(images[0]->name, "Scale");
EXPECT_EQ(images[1]->name, "Rotate");
EXPECT_EQ(images[2]->name, "Flip");
expectMatrix(images[0]->transform, 100, 0, 0, 100, 50, 50);
expectMatrix(images[1]->transform, 0, 40, -40, 0, 200, 40);
expectMatrix(images[2]->transform, 40, 0, 0, -40, 230, 120);
auto rendered = renderWithPdfium(pdf);
if (!rendered) {
GTEST_SKIP() << "PDFium render unavailable in this build";
}
EXPECT_FALSE(isWhite(pagePixel(*rendered, 75, 75)));
EXPECT_FALSE(isWhite(pagePixel(*rendered, 185, 55)));
EXPECT_FALSE(isWhite(pagePixel(*rendered, 245, 105)));
EXPECT_TRUE(isWhite(pagePixel(*rendered, 49, 50)));
EXPECT_TRUE(isWhite(pagePixel(*rendered, 201, 80)));
}
TEST(ImageXObjectVerification, MultipleImagesPreserveOrderAndPlacement) {
std::vector<ImageSpec> images;
std::ostringstream content;
const std::array<Rgba, 8> colors = {{
{230, 20, 20, 255}, {20, 230, 20, 255}, {20, 20, 230, 255}, {230, 230, 20, 255},
{230, 20, 230, 255}, {20, 230, 230, 255}, {120, 80, 240, 255}, {20, 20, 20, 255},
}};
for (int i = 0; i < 8; ++i) {
const int x = 5 + i * 10;
content << "q 6 0 0 6 " << x << " 20 cm /Im" << i << " Do Q\n";
images.push_back({
"Im" + std::to_string(i),
5 + i,
1,
1,
"/DeviceRGB",
"/FlateDecode",
flate(rgb({
static_cast<uint8_t>(colors[i].r),
static_cast<uint8_t>(colors[i].g),
static_cast<uint8_t>(colors[i].b),
})),
std::nullopt,
});
}
const auto pdf = buildSinglePagePdf(100, 50, content.str(), images);
QPDF qpdf;
auto objects = buildFirstPageObjects(qpdf, pdf);
auto parsedImages = imageObjects(objects);
ASSERT_EQ(parsedImages.size(), 8u);
auto rendered = renderWithPdfium(pdf);
if (!rendered) {
GTEST_SKIP() << "PDFium render unavailable in this build";
}
for (int i = 0; i < 8; ++i) {
EXPECT_EQ(parsedImages[i]->name, "Im" + std::to_string(i));
expectMatrix(parsedImages[i]->transform, 6, 0, 0, 6, static_cast<float>(5 + i * 10), 20);
expectNearColor(pagePixel(*rendered, 8 + i * 10, 23), colors[i], 2);
EXPECT_TRUE(isWhite(pagePixel(*rendered, 12 + i * 10, 23)));
}
}
TEST(ImageXObjectVerification, LoadRenderCloseStress100Pages1000ImageDraws) {
constexpr int kPages = 100;
constexpr int kImagesPerPage = 10;
std::vector<PdfObject> objects;
objects.push_back({1, textBody("<< /Type /Catalog /Pages 2 0 R >>")});
int nextId = 3;
std::vector<int> pageIds;
for (int pageIndex = 0; pageIndex < kPages; ++pageIndex) {
const int pageId = nextId++;
const int contentId = nextId++;
pageIds.push_back(pageId);
std::ostringstream xobjects;
std::ostringstream content;
for (int imageIndex = 0; imageIndex < kImagesPerPage; ++imageIndex) {
const int imageId = nextId++;
const std::string name = "Im" + std::to_string(imageIndex);
xobjects << "/" << name << " " << imageId << " 0 R ";
content << "q 1 0 0 1 " << (imageIndex % 5) * 3 << " "
<< (imageIndex / 5) * 3 << " cm /" << name << " Do Q\n";
const uint8_t shade = static_cast<uint8_t>((pageIndex + imageIndex) % 255);
objects.push_back({imageId, makeImageBody({
"",
imageId,
1,
1,
"/DeviceRGB",
"/FlateDecode",
flate({shade, static_cast<uint8_t>(255 - shade), 90}),
std::nullopt,
})});
}
objects.push_back({contentId, makeStreamBody("", textBody(content.str()))});
std::ostringstream page;
page << "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 20 20]"
<< " /Resources << /XObject << " << xobjects.str() << ">> >>"
<< " /Contents " << contentId << " 0 R >>";
objects.push_back({pageId, textBody(page.str())});
}
std::ostringstream kids;
for (int pageId : pageIds) {
kids << pageId << " 0 R ";
}
objects.push_back({2, textBody("<< /Type /Pages /Kids [" + kids.str() +
"] /Count " + std::to_string(kPages) + " >>")});
const auto pdf = makePdf(std::move(objects));
auto document = PdfDocument::loadFromMemory(pdf);
if (!document) {
GTEST_SKIP() << "PDFium load unavailable in this build";
}
ASSERT_EQ((*document)->pageCount(), kPages);
for (int i = 0; i < kPages; ++i) {
auto page = (*document)->getPage(i);
ASSERT_TRUE(page);
auto rendered = (*page)->renderRegionRaw(72, 0.0, (*page)->height());
ASSERT_TRUE(rendered);
}
document = {};
QPDF qpdf;
qpdf.processMemoryFile("image-xobject-stress", reinterpret_cast<const char*>(pdf.data()), pdf.size());
auto pages = qpdf.getAllPages();
ASSERT_EQ(pages.size(), static_cast<size_t>(kPages));
size_t decodedImages = 0;
for (const auto& page : pages) {
auto pageObjects = buildPageObjects(page);
auto parsedImages = imageObjects(pageObjects);
ASSERT_EQ(parsedImages.size(), static_cast<size_t>(kImagesPerPage));
for (const auto* image : parsedImages) {
EXPECT_EQ(image->pixelData.size(), 4u);
}
decodedImages += parsedImages.size();
}
EXPECT_EQ(decodedImages, static_cast<size_t>(kPages * kImagesPerPage));
}
#endif