test: verify image xobjects
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user