diff --git a/docs.json b/docs.json new file mode 100644 index 0000000..e69de29 diff --git a/engine/include/pdfengine/content_object.hpp b/engine/include/pdfengine/content_object.hpp index 672b9d8..3e58600 100644 --- a/engine/include/pdfengine/content_object.hpp +++ b/engine/include/pdfengine/content_object.hpp @@ -4,6 +4,7 @@ #include #include #include +#include namespace pdfengine { @@ -59,4 +60,19 @@ public: Matrix transform; }; +enum class PathPaintOp { + Stroke, + Fill, + FillStroke +}; + +class PathObject : public ContentObject { +public: + ContentObjectType getType() const override { return ContentObjectType::Path; } + + Path path; + PathPaintOp paintOp = PathPaintOp::Stroke; + Matrix transform; +}; + } // namespace pdfengine diff --git a/engine/src/core/image_decoder.cpp b/engine/src/core/image_decoder.cpp index 91902a1..f6641db 100644 --- a/engine/src/core/image_decoder.cpp +++ b/engine/src/core/image_decoder.cpp @@ -1,113 +1,245 @@ #include "image_decoder.hpp" -#include -#include -#include -#ifdef PDFENGINE_WITH_SKIA -#include -#include -#include -#include +#include +#include +#include +#include +#include + +#include + +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(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(cinfo->err); + longjmp(manager->jump, 1); +} + +} // namespace namespace pdfengine { -std::vector ImageDecoder::decode(QPDFObjectHandle imageStream, - const std::string& colorSpace, - int width, int height, - int bitsPerComponent, +std::vector 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; 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 rawBytes(buffer->getBuffer(), buffer->getBuffer() + buffer->getSize()); + std::vector 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 ImageDecoder::decodeJpegWithSkia(const std::vector& jpegBytes) { - (void)jpegBytes; -#ifdef PDFENGINE_WITH_SKIA - sk_sp data = SkData::MakeWithCopy(jpegBytes.data(), jpegBytes.size()); - sk_sp 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 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 ImageDecoder::convertGrayToRgba(const std::vector& rawBytes, int width, int height) { +std::vector ImageDecoder::decodeJpeg(const std::vector& 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(jpegBytes.data()), + static_cast(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(cinfo.output_width); + const int height = static_cast(cinfo.output_height); + const int components = static_cast(cinfo.output_components); + const int rowStride = width * components; + + std::vector rgba(static_cast(width) * height * 4); + std::vector row(static_cast(rowStride)); + + while (cinfo.output_scanline < cinfo.output_height) { + JSAMPROW rowPointer = row.data(); + const int y = static_cast(cinfo.output_scanline); + jpeg_read_scanlines(&cinfo, &rowPointer, 1); + + for (int x = 0; x < width; ++x) { + const auto src = static_cast(x) * components; + const auto dst = (static_cast(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(std::clamp(255.0f * (1.0f - c) * (1.0f - k), 0.0f, 255.0f)); + rgba[dst + 1] = static_cast(std::clamp(255.0f * (1.0f - m) * (1.0f - k), 0.0f, 255.0f)); + rgba[dst + 2] = static_cast(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& rgba, + int width, + int height) { + if (rgba.size() != static_cast(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(i) * 4 + 3] = maskRgba[static_cast(i) * 4]; + } +} + +std::vector ImageDecoder::convertGrayToRgba(const std::vector& rawBytes, + int width, + int height) { int expectedSize = width * height; std::vector 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(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(width * height * 4)) { rgba.push_back(0); rgba.push_back(0); rgba.push_back(0); @@ -116,53 +248,77 @@ std::vector ImageDecoder::convertGrayToRgba(const std::vector ImageDecoder::convertRgbToRgba(const std::vector& rawBytes, int width, int height) { +std::vector ImageDecoder::convertRgbToRgba(const std::vector& rawBytes, + int width, + int height) { int expectedSize = width * height * 3; std::vector 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(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(width * height * 4)) { + rgba.push_back(0); + rgba.push_back(0); + rgba.push_back(0); + rgba.push_back(255); } return rgba; } -std::vector ImageDecoder::convertCmykToRgba(const std::vector& rawBytes, int width, int height) { +std::vector ImageDecoder::convertCmykToRgba(const std::vector& rawBytes, + int width, + int height) { int expectedSize = width * height * 4; std::vector 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(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(std::clamp(r, 0.0f, 255.0f))); - rgba.push_back(static_cast(std::clamp(g, 0.0f, 255.0f))); - rgba.push_back(static_cast(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(std::clamp(std::lround(r), 0l, 255l))); + rgba.push_back(static_cast(std::clamp(std::lround(g), 0l, 255l))); + rgba.push_back(static_cast(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(width * height * 4)) { + rgba.push_back(0); + rgba.push_back(0); + rgba.push_back(0); + rgba.push_back(255); } return rgba; } diff --git a/engine/src/core/image_decoder.hpp b/engine/src/core/image_decoder.hpp index 1a7f318..d9619d6 100644 --- a/engine/src/core/image_decoder.hpp +++ b/engine/src/core/image_decoder.hpp @@ -18,7 +18,8 @@ public: const std::string& filter); private: - static std::vector decodeJpegWithSkia(const std::vector& jpegBytes); + static std::vector decodeJpeg(const std::vector& jpegBytes); + static void applySoftMask(QPDFObjectHandle imageStream, std::vector& rgba, int width, int height); // Converts raw DeviceGray (1 byte per pixel) to RGBA (4 bytes per pixel) static std::vector convertGrayToRgba(const std::vector& rawBytes, int width, int height); diff --git a/engine/src/parser/content_builder.cpp b/engine/src/parser/content_builder.cpp index 8b96694..76d830d 100644 --- a/engine/src/parser/content_builder.cpp +++ b/engine/src/parser/content_builder.cpp @@ -39,6 +39,20 @@ void ContentBuilder::processOperation(const Operation& op, std::vectorbitsPerComponent = 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); @@ -206,4 +221,89 @@ void ContentBuilder::handleDo(const Operation& op, std::vector= op.operands.size() || op.operands[index]->type != AstNodeType::Number) { + return false; + } + value = static_cast(op.operands[index]->numberValue); + return true; + }; + + if (op.op == "h") { + if (!currentPath_.empty()) { + currentPath_.close(); + } + return; + } + + if (op.op == "m" || op.op == "l") { + if (op.operands.size() < 2) return; + float x = 0.0f; + float y = 0.0f; + if (!numberOperand(op.operands.size() - 2, x) || !numberOperand(op.operands.size() - 1, y)) { + return; + } + if (op.op == "m") { + currentPath_.moveTo(x, y); + } else { + currentPath_.lineTo(x, y); + } + return; + } + + if (op.op == "c") { + if (op.operands.size() < 6) return; + float x1 = 0.0f; + float y1 = 0.0f; + float x2 = 0.0f; + float y2 = 0.0f; + float x3 = 0.0f; + float y3 = 0.0f; + const size_t start = op.operands.size() - 6; + if (!numberOperand(start + 0, x1) || !numberOperand(start + 1, y1) || + !numberOperand(start + 2, x2) || !numberOperand(start + 3, y2) || + !numberOperand(start + 4, x3) || !numberOperand(start + 5, y3)) { + return; + } + currentPath_.cubicTo(x1, y1, x2, y2, x3, y3); + return; + } + + if (op.op == "re") { + if (op.operands.size() < 4) return; + float x = 0.0f; + float y = 0.0f; + float width = 0.0f; + float height = 0.0f; + const size_t start = op.operands.size() - 4; + if (!numberOperand(start + 0, x) || !numberOperand(start + 1, y) || + !numberOperand(start + 2, width) || !numberOperand(start + 3, height)) { + return; + } + currentPath_.addRect(x, y, width, height); + } +} + +void ContentBuilder::handlePathPaint(PathPaintOp paintOp, + std::vector>& outObjects, + bool closePath) { + if (closePath) { + if (!currentPath_.empty()) { + currentPath_.close(); + } + } + if (currentPath_.empty()) { + return; + } + + auto pathObj = std::make_unique(); + pathObj->path = currentPath_; + pathObj->paintOp = paintOp; + pathObj->transform = state_.ctm; + outObjects.push_back(std::move(pathObj)); + + currentPath_.clear(); +} + } // namespace pdfengine diff --git a/engine/src/parser/content_builder.hpp b/engine/src/parser/content_builder.hpp index 169d317..ef0065c 100644 --- a/engine/src/parser/content_builder.hpp +++ b/engine/src/parser/content_builder.hpp @@ -27,6 +27,7 @@ private: GraphicsState state_; std::vector stateStack_; ResourceResolver* resolver_; + Path currentPath_; void processOperation(const Operation& op, std::vector>& outObjects); @@ -37,6 +38,8 @@ private: void handleTJ_Array(const Operation& op, std::vector>& outObjects); void handleCm(const Operation& op); void handleDo(const Operation& op, std::vector>& outObjects); + void handlePathConstruction(const Operation& op); + void handlePathPaint(PathPaintOp paintOp, std::vector>& outObjects, bool closePath = false); }; } // namespace pdfengine diff --git a/engine/tests/CMakeLists.txt b/engine/tests/CMakeLists.txt index 9c65e6c..2d2b580 100644 --- a/engine/tests/CMakeLists.txt +++ b/engine/tests/CMakeLists.txt @@ -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() diff --git a/engine/tests/content_builder_test.cpp b/engine/tests/content_builder_test.cpp index 13a39f0..98c16c4 100644 --- a/engine/tests/content_builder_test.cpp +++ b/engine/tests/content_builder_test.cpp @@ -14,6 +14,26 @@ using namespace pdfengine; +namespace { + +std::vector> buildObjects(const std::string& content) { + Lexer lexer(content); + auto tokens = lexer.tokenize(); + ContentParser parser(tokens); + ContentBuilder builder; + return builder.build(parser.parse()); +} + +const PathObject* requirePathObject(const std::unique_ptr& object) { + EXPECT_EQ(object->getType(), ContentObjectType::Path); + if (object->getType() != ContentObjectType::Path) { + return nullptr; + } + return static_cast(object.get()); +} + +} // namespace + TEST(ContentBuilderTest, SimpleTextState) { Lexer lexer("10 20 Td /F1 12 Tf (Hello) Tj"); auto tokens = lexer.tokenize(); @@ -73,6 +93,100 @@ TEST(ContentBuilderTest, RotatedTextMatrix) { EXPECT_DOUBLE_EQ(textObj->tm[5], 200.0); } +TEST(ContentBuilderTest, StrokePathFromMoveAndLine) { + auto objects = buildObjects("10 20 m 30 40 l S"); + ASSERT_EQ(objects.size(), 1); + + const auto* pathObj = requirePathObject(objects[0]); + ASSERT_NE(pathObj, nullptr); + EXPECT_EQ(pathObj->paintOp, PathPaintOp::Stroke); + + const auto& segments = pathObj->path.segments(); + ASSERT_EQ(segments.size(), 2); + EXPECT_EQ(segments[0].verb, Path::Verb::MoveTo); + EXPECT_FLOAT_EQ(segments[0].points[0].x, 10.0f); + EXPECT_FLOAT_EQ(segments[0].points[0].y, 20.0f); + EXPECT_EQ(segments[1].verb, Path::Verb::LineTo); + EXPECT_FLOAT_EQ(segments[1].points[0].x, 30.0f); + EXPECT_FLOAT_EQ(segments[1].points[0].y, 40.0f); +} + +TEST(ContentBuilderTest, FillRectanglePath) { + auto objects = buildObjects("5 6 7 8 re f"); + ASSERT_EQ(objects.size(), 1); + + const auto* pathObj = requirePathObject(objects[0]); + ASSERT_NE(pathObj, nullptr); + EXPECT_EQ(pathObj->paintOp, PathPaintOp::Fill); + + const auto& segments = pathObj->path.segments(); + ASSERT_EQ(segments.size(), 5); + EXPECT_EQ(segments[0].verb, Path::Verb::MoveTo); + EXPECT_FLOAT_EQ(segments[0].points[0].x, 5.0f); + EXPECT_FLOAT_EQ(segments[0].points[0].y, 6.0f); + EXPECT_EQ(segments[1].verb, Path::Verb::LineTo); + EXPECT_FLOAT_EQ(segments[1].points[0].x, 12.0f); + EXPECT_FLOAT_EQ(segments[1].points[0].y, 6.0f); + EXPECT_EQ(segments[2].verb, Path::Verb::LineTo); + EXPECT_FLOAT_EQ(segments[2].points[0].x, 12.0f); + EXPECT_FLOAT_EQ(segments[2].points[0].y, 14.0f); + EXPECT_EQ(segments[3].verb, Path::Verb::LineTo); + EXPECT_FLOAT_EQ(segments[3].points[0].x, 5.0f); + EXPECT_FLOAT_EQ(segments[3].points[0].y, 14.0f); + EXPECT_EQ(segments[4].verb, Path::Verb::Close); +} + +TEST(ContentBuilderTest, FillStrokeCubicPath) { + auto objects = buildObjects("1 2 m 3 4 5 6 7 8 c B"); + ASSERT_EQ(objects.size(), 1); + + const auto* pathObj = requirePathObject(objects[0]); + ASSERT_NE(pathObj, nullptr); + EXPECT_EQ(pathObj->paintOp, PathPaintOp::FillStroke); + + const auto& segments = pathObj->path.segments(); + ASSERT_EQ(segments.size(), 2); + EXPECT_EQ(segments[0].verb, Path::Verb::MoveTo); + EXPECT_EQ(segments[1].verb, Path::Verb::CubicBezierTo); + EXPECT_FLOAT_EQ(segments[1].points[0].x, 3.0f); + EXPECT_FLOAT_EQ(segments[1].points[0].y, 4.0f); + EXPECT_FLOAT_EQ(segments[1].points[1].x, 5.0f); + EXPECT_FLOAT_EQ(segments[1].points[1].y, 6.0f); + EXPECT_FLOAT_EQ(segments[1].points[2].x, 7.0f); + EXPECT_FLOAT_EQ(segments[1].points[2].y, 8.0f); +} + +TEST(ContentBuilderTest, PathPaintClearsCurrentPath) { + auto objects = buildObjects("0 0 m 10 10 l S 20 20 m 30 30 l f"); + ASSERT_EQ(objects.size(), 2); + + const auto* stroke = requirePathObject(objects[0]); + const auto* fill = requirePathObject(objects[1]); + ASSERT_NE(stroke, nullptr); + ASSERT_NE(fill, nullptr); + + EXPECT_EQ(stroke->paintOp, PathPaintOp::Stroke); + EXPECT_EQ(fill->paintOp, PathPaintOp::Fill); + ASSERT_EQ(stroke->path.segments().size(), 2); + ASSERT_EQ(fill->path.segments().size(), 2); + EXPECT_FLOAT_EQ(fill->path.segments()[0].points[0].x, 20.0f); + EXPECT_FLOAT_EQ(fill->path.segments()[0].points[0].y, 20.0f); +} + +TEST(ContentBuilderTest, PathCapturesCurrentTransform) { + auto objects = buildObjects("q 2 0 0 3 10 20 cm 1 2 3 4 re f Q"); + ASSERT_EQ(objects.size(), 1); + + const auto* pathObj = requirePathObject(objects[0]); + ASSERT_NE(pathObj, nullptr); + EXPECT_FLOAT_EQ(pathObj->transform.a, 2.0f); + EXPECT_FLOAT_EQ(pathObj->transform.b, 0.0f); + EXPECT_FLOAT_EQ(pathObj->transform.c, 0.0f); + EXPECT_FLOAT_EQ(pathObj->transform.d, 3.0f); + EXPECT_FLOAT_EQ(pathObj->transform.e, 10.0f); + EXPECT_FLOAT_EQ(pathObj->transform.f, 20.0f); +} + TEST(ContentBuilderTest, IntegrationHelloWorld) { pdfengine::qpdf_layer::QpdfExtractor extractor; std::filesystem::path path = std::filesystem::path(TEST_CORPUS_DIR) / "basic" / "hello_world.pdf"; diff --git a/engine/tests/image_xobject_test.cpp b/engine/tests/image_xobject_test.cpp index a7c6f96..53cde7a 100644 --- a/engine/tests/image_xobject_test.cpp +++ b/engine/tests/image_xobject_test.cpp @@ -1,123 +1,794 @@ #include -#include -#include + #include -#include +#include + +#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 -#include -#include +#include "../src/qpdf/qpdf_resource_resolver.hpp" -#ifdef PDFENGINE_WITH_SKIA -#include -#include -#include -#endif +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include 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 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 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 body; +}; + +struct ImageSpec { + std::string name; + int id = 0; + int width = 0; + int height = 0; + std::string colorSpace = "/DeviceRGB"; + std::string filter; + std::vector streamData; + std::optional smaskId; +}; + +void appendText(std::vector& 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 textBody(const std::string& text) { + return std::vector(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 makeStreamBody(const std::string& dictionaryEntries, + const std::vector& streamData) { + std::vector 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 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 makePdf(std::vector 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 offsets(static_cast(maxId) + 1, 0); + std::vector pdf; + appendText(pdf, "%PDF-1.4\n%\xFF\xFF\xFF\xFF\n"); + + for (const auto& object : objects) { + offsets[static_cast(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(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 buildSinglePagePdf(int pageWidth, + int pageHeight, + const std::string& content, + const std::vector& images) { + std::ostringstream xobjects; + for (const auto& image : images) { + if (!image.name.empty()) { + xobjects << "/" << image.name << " " << image.id << " 0 R "; + } + } + + std::vector 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 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 flate(const std::vector& raw) { + uLongf size = compressBound(static_cast(raw.size())); + std::vector compressed(size); + const int result = compress2(compressed.data(), &size, raw.data(), + static_cast(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(cinfo->err); + longjmp(manager->jump, 1); +} + +std::vector encodeJpegRgb(const std::vector& 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(width); + cinfo.image_height = static_cast(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( + reinterpret_cast(rgb.data() + cinfo.next_scanline * rowStride)); + jpeg_write_scanlines(&cinfo, &row, 1); + } + + jpeg_finish_compress(&cinfo); + std::vector 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(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(buffer->getBuffer()), buffer->getSize()); + decoded.push_back('\n'); + } + } + return decoded; +} + +std::vector> 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> buildFirstPageObjects(QPDF& qpdf, + const std::vector& pdfBytes) { + qpdf.processMemoryFile("image-xobject-test", reinterpret_cast(pdfBytes.data()), pdfBytes.size()); + return buildPageObjects(qpdf.getAllPages().at(0)); +} + +std::vector imageObjects(const std::vector>& objects) { + std::vector images; + for (const auto& object : objects) { + if (object->getType() == ContentObjectType::Image) { + images.push_back(static_cast(object.get())); + } + } + return images; +} + +Rgba decodedPixel(const ImageObject& image, int x, int y) { + const auto index = (static_cast(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(r / count), + static_cast(g / count), + static_cast(b / count), + static_cast(a / count), + }; +} + +Rgba pagePixel(const PageImage& image, int x, int yFromBottom) { + const int row = image.height - 1 - yFromBottom; + const auto index = (static_cast(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(r / count), + static_cast(g / count), + static_cast(b / count), + static_cast(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 renderWithPdfium(const std::vector& 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 rgb(std::initializer_list values) { + return std::vector(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 photoRgb; + for (int y = 0; y < 6; ++y) { + for (int x = 0; x < 8; ++x) { + photoRgb.push_back(static_cast(35 + x * 24)); + photoRgb.push_back(static_cast(40 + y * 28)); + photoRgb.push_back(static_cast(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 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 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 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(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 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{"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 images; + std::ostringstream content; + const std::array 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(colors[i].r), + static_cast(colors[i].g), + static_cast(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(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 objects; + objects.push_back({1, textBody("<< /Type /Catalog /Pages 2 0 R >>")}); + + int nextId = 3; + std::vector 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((pageIndex + imageIndex) % 255); + objects.push_back({imageId, makeImageBody({ + "", + imageId, + 1, + 1, + "/DeviceRGB", + "/FlateDecode", + flate({shade, static_cast(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 verifyCustomDecode = [&](std::string_view phase) { + QPDF qpdf; + qpdf.processMemoryFile("image-xobject-stress", reinterpret_cast(pdf.data()), pdf.size()); + auto pages = qpdf.getAllPages(); + ASSERT_EQ(pages.size(), static_cast(kPages)) << phase; + + size_t decodedImages = 0; + for (size_t pageIndex = 0; pageIndex < pages.size(); ++pageIndex) { + const auto& page = pages[pageIndex]; + const std::string content = decodedStream(page.getKey("/Contents")); + Lexer lexer(content); + auto tokens = lexer.tokenize(); + ContentParser parser(tokens); + auto operations = parser.parse(); + size_t doOps = 0; + std::vector 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); + } + } + } + QpdfResourceResolver resolver(page.getKey("/Resources")); + std::vector unresolved; + for (int imageIndex = 0; imageIndex < kImagesPerPage; ++imageIndex) { + const std::string name = "Im" + std::to_string(imageIndex); + if (resolver.resolveXObject(name).type != XObjectType::Image) { + unresolved.push_back(name); + } + } + auto pageObjects = buildPageObjects(page); + auto parsedImages = imageObjects(pageObjects); + std::vector emittedNames; + for (const auto* image : parsedImages) { + emittedNames.push_back(image->name); + } + ASSERT_EQ(parsedImages.size(), static_cast(kImagesPerPage)) + << phase << " page=" << pageIndex + << " doOps=" << doOps + << " doNames=" << ::testing::PrintToString(doNames) + << " unresolved=" << ::testing::PrintToString(unresolved) + << " emitted=" << ::testing::PrintToString(emittedNames) + << " decoded=[" << content << "]" + << " resources=[" << page.getKey("/Resources").unparse() << "]"; + for (const auto* image : parsedImages) { + EXPECT_EQ(image->pixelData.size(), 4u) << phase << " page=" << pageIndex; + } + decodedImages += parsedImages.size(); + } + EXPECT_EQ(decodedImages, static_cast(kPages * kImagesPerPage)) << phase; + }; + + verifyCustomDecode("before-pdfium-render"); + + 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 = {}; + + verifyCustomDecode("after-pdfium-render"); } -#endif diff --git a/frontend/src/viewer/StreamEditLayer.tsx b/frontend/src/viewer/StreamEditLayer.tsx index 9da7c13..bcff41b 100644 --- a/frontend/src/viewer/StreamEditLayer.tsx +++ b/frontend/src/viewer/StreamEditLayer.tsx @@ -3,6 +3,8 @@ import { gatewayService } from '../lib/gatewayService'; import type { TextObjectResponse } from '../lib/gatewayService'; import { toast } from '../lib/toast'; +import { loadPdfFont } from '../lib/fontFaceLoader'; + // Measure a run's width + ascent/descent in its actual font (memoized). Sizes are returned in the // same units as `sizePx`, so callers scale by the text matrix + zoom. Before the embedded @font-face // loads this measures the fallback chain (still far better than a char-count guess); clearMeasureCache @@ -53,6 +55,7 @@ export const StreamEditLayer: React.FC = ({ const [editingIndex, setEditingIndex] = useState(null); const [value, setValue] = useState(''); const [fontsReady, setFontsReady] = useState(false); + const [fontFamilyMap, setFontFamilyMap] = useState>({}); const inputRef = useRef(null); useEffect(() => { @@ -87,7 +90,7 @@ export const StreamEditLayer: React.FC = ({ const obj = objects[editingIndex]; const newText = value; const idx = editingIndex; - + setEditingIndex(null); if (newText === obj.text) return; @@ -124,13 +127,30 @@ export const StreamEditLayer: React.FC = ({ useEffect(() => { if (!uniqueFonts.length) return; let active = true; - const fonts = (document as unknown as { fonts?: { load: (f: string) => Promise } }).fonts; - if (!fonts) return; - Promise.all(uniqueFonts.map(fn => fonts.load(`16px 'PDF_${fn}'`).catch(() => {}))) - .then(() => { if (active) { clearMeasureCache(); setFontsReady(v => !v); } }); + + Promise.all(uniqueFonts.map(async (fn) => { + const cssFamily = await loadPdfFont(documentId, fn); + return { fn, cssFamily }; + })).then((results) => { + if (!active) return; + let changed = false; + const newMap: Record = { ...fontFamilyMap }; + for (const res of results) { + if (res.cssFamily && newMap[res.fn] !== res.cssFamily) { + newMap[res.fn] = res.cssFamily; + changed = true; + } + } + if (changed) { + setFontFamilyMap(newMap); + clearMeasureCache(); + setFontsReady(v => !v); + } + }); + return () => { active = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [uniqueFontsKey]); + }, [uniqueFontsKey, documentId]); if (loading) { return null; @@ -138,14 +158,6 @@ export const StreamEditLayer: React.FC = ({ return (
- {objects.map((obj, i) => { // tm[4] is X, tm[5] is Y (baseline, bottom-left origin) const pdfX = obj.tm[4]; @@ -160,7 +172,8 @@ export const StreamEditLayer: React.FC = ({ const fontSizeScreen = obj.fontSize * scaleY * zoom; const isEditing = editingIndex === i; - const fontFamily = obj.fontName ? `'PDF_${obj.fontName}', sans-serif` : 'sans-serif'; + const cssFam = obj.fontName ? fontFamilyMap[obj.fontName] : null; + const fontFamily = cssFam ? `'${cssFam}', sans-serif` : 'sans-serif'; // P2b — EXACT hit-box from real font metrics (measured in the actual embedded font once // it loads; falls back to the sans-serif chain before then) instead of the old diff --git a/gateway/app/routers/documents.py b/gateway/app/routers/documents.py index 6be4e63..9dfae9a 100644 --- a/gateway/app/routers/documents.py +++ b/gateway/app/routers/documents.py @@ -268,18 +268,18 @@ def get_font_bytes(document_id: str, internal_font_id: str) -> Response: # Cap the id length; never echo it back into error bodies. if not internal_font_id or len(internal_font_id) > 256: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Font not found") + return Response(status_code=status.HTTP_204_NO_CONTENT) doc_info = document_store.get_document(document_id) if not doc_info: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found") + return Response(status_code=status.HTTP_204_NO_CONTENT) try: data = bytes(doc_info["doc_instance"].get_font_data(internal_font_id)) except Exception: data = b"" if not data: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Font not found") + return Response(status_code=status.HTTP_204_NO_CONTENT) magic = data[:4] if magic in _SFNT_TTF_MAGIC: @@ -288,7 +288,7 @@ def get_font_bytes(document_id: str, internal_font_id: str) -> Response: media_type = "font/otf" else: # Type1 (\x80\x01 / "%!") or anything not sfnt-wrapped — not browser-loadable. - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Font not loadable") + return Response(status_code=status.HTTP_204_NO_CONTENT) etag = '"' + hashlib.sha256(data).hexdigest()[:32] + '"' return Response(