diff --git a/bindings/python/pdfengine_py.cpp b/bindings/python/pdfengine_py.cpp index 0005166..27bcee7 100644 --- a/bindings/python/pdfengine_py.cpp +++ b/bindings/python/pdfengine_py.cpp @@ -428,7 +428,14 @@ PYBIND11_MODULE(pdfengine, m) { .def("page_to_device", &pdfengine::PdfPage::pageToDevice, py::arg("page_point"), py::arg("device_width"), py::arg("device_height"), py::arg("rotate") = 0) .def("device_to_page", &pdfengine::PdfPage::deviceToPage, - py::arg("device_point"), py::arg("device_width"), py::arg("device_height"), py::arg("rotate") = 0); + py::arg("device_point"), py::arg("device_width"), py::arg("device_height"), py::arg("rotate") = 0) + .def("extract_display_list", [](const pdfengine::PdfPage& self) { + return get_or_throw(self.extractDisplayListJson()); + }) + .def("extract_image_xobject", [](const pdfengine::PdfPage& self, const std::string& name) { + auto res = get_or_throw(self.extractImageXObject(name)); + return py::bytes(reinterpret_cast(res.data()), res.size()); + }, py::arg("name")); py::class_>(m, "PdfDocument") .def_static("load_from_file", [](const std::string& path, const std::string& password) { diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index a7242df..6c7b183 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -13,6 +13,7 @@ add_library(pdfengine STATIC src/core/engine_info.cpp src/core/graphics_state.cpp src/core/display_list.cpp + src/core/path_interpreter.cpp src/core/skia_renderer.cpp src/parser/pdfium_loader.cpp src/parser/pdfium_document.cpp diff --git a/engine/include/pdfengine/content_object.hpp b/engine/include/pdfengine/content_object.hpp index fea0b48..26cd7e4 100644 --- a/engine/include/pdfengine/content_object.hpp +++ b/engine/include/pdfengine/content_object.hpp @@ -68,6 +68,7 @@ public: Path path; PathPaintOp paintOp = PathPaintOp::Stroke; + FillRule fillRule = FillRule::NonZero; Matrix transform; }; diff --git a/engine/include/pdfengine/display_list.hpp b/engine/include/pdfengine/display_list.hpp index 0eb611c..cd98c30 100644 --- a/engine/include/pdfengine/display_list.hpp +++ b/engine/include/pdfengine/display_list.hpp @@ -45,7 +45,8 @@ struct DrawTextCommand : public Command { struct FillPathCommand : public Command { Path path; - explicit FillPathCommand(Path path) : path(std::move(path)) {} + FillRule rule; + explicit FillPathCommand(Path path, FillRule rule = FillRule::NonZero) : path(std::move(path)), rule(rule) {} void accept(CommandVisitor& visitor) const override; }; @@ -55,6 +56,13 @@ struct StrokePathCommand : public Command { void accept(CommandVisitor& visitor) const override; }; +struct FillStrokePathCommand : public Command { + Path path; + FillRule rule; + explicit FillStrokePathCommand(Path path, FillRule rule = FillRule::NonZero) : path(std::move(path)), rule(rule) {} + void accept(CommandVisitor& visitor) const override; +}; + struct DrawImageCommand : public Command { ImageInfo image; Matrix matrix; @@ -77,6 +85,7 @@ public: virtual void visit(const DrawTextCommand& cmd) = 0; virtual void visit(const FillPathCommand& cmd) = 0; virtual void visit(const StrokePathCommand& cmd) = 0; + virtual void visit(const FillStrokePathCommand& cmd) = 0; virtual void visit(const DrawImageCommand& cmd) = 0; }; @@ -94,8 +103,9 @@ public: void setTransform(const Matrix& m); void fillRect(float x, float y, float w, float h); void drawText(const std::string& text, float x, float y); - void fillPath(const Path& path); + void fillPath(const Path& path, FillRule rule = FillRule::NonZero); void strokePath(const Path& path); + void fillStrokePath(const Path& path, FillRule rule = FillRule::NonZero); void drawImage(const ImageInfo& image, const Matrix& m, float opacity = 1.0f); [[nodiscard]] size_t size() const noexcept { return m_commands.size(); } diff --git a/engine/include/pdfengine/path.hpp b/engine/include/pdfengine/path.hpp index 0c39ffc..8ae3234 100644 --- a/engine/include/pdfengine/path.hpp +++ b/engine/include/pdfengine/path.hpp @@ -4,6 +4,12 @@ namespace pdfengine { +enum class FillRule { + NonZero, + EvenOdd +}; + +// Basic point structure struct Point { float x = 0.0f; float y = 0.0f; diff --git a/engine/include/pdfengine/path_interpreter.hpp b/engine/include/pdfengine/path_interpreter.hpp new file mode 100644 index 0000000..b7e46b5 --- /dev/null +++ b/engine/include/pdfengine/path_interpreter.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include +#include + +namespace pdfengine { + +// PathObjectInterpreter converts a PathObject into DisplayList commands +class PathObjectInterpreter { +public: + static void interpret(const PathObject& pathObj, DisplayList& displayList); +}; + +} // namespace pdfengine diff --git a/engine/include/pdfengine/pdf_document.hpp b/engine/include/pdfengine/pdf_document.hpp index 02cc052..3818177 100644 --- a/engine/include/pdfengine/pdf_document.hpp +++ b/engine/include/pdfengine/pdf_document.hpp @@ -210,6 +210,9 @@ public: [[nodiscard]] virtual DevicePoint pageToDevice(const Point2D& pagePoint, int deviceWidth, int deviceHeight, int rotate = 0) const noexcept = 0; [[nodiscard]] virtual Point2D deviceToPage(const DevicePoint& devicePoint, int deviceWidth, int deviceHeight, int rotate = 0) const noexcept = 0; + + [[nodiscard]] virtual std::expected extractDisplayListJson() const = 0; + [[nodiscard]] virtual std::expected, EngineError> extractImageXObject(const std::string& name) const = 0; }; namespace fonts::pdf_fonts { class Font; } diff --git a/engine/include/pdfengine/skia_renderer.hpp b/engine/include/pdfengine/skia_renderer.hpp index 5d12907..98c97ad 100644 --- a/engine/include/pdfengine/skia_renderer.hpp +++ b/engine/include/pdfengine/skia_renderer.hpp @@ -18,6 +18,7 @@ public: void visit(const DrawTextCommand& cmd) override; void visit(const FillPathCommand& cmd) override; void visit(const StrokePathCommand& cmd) override; + void visit(const FillStrokePathCommand& cmd) override; void visit(const DrawImageCommand& cmd) override; void render(const DisplayList& displayList); diff --git a/engine/src/core/display_list.cpp b/engine/src/core/display_list.cpp index 8bb792d..0c6c425 100644 --- a/engine/src/core/display_list.cpp +++ b/engine/src/core/display_list.cpp @@ -9,6 +9,7 @@ void FillRectCommand::accept(CommandVisitor& visitor) const { visitor.visit(*thi void DrawTextCommand::accept(CommandVisitor& visitor) const { visitor.visit(*this); } void FillPathCommand::accept(CommandVisitor& visitor) const { visitor.visit(*this); } void StrokePathCommand::accept(CommandVisitor& visitor) const { visitor.visit(*this); } +void FillStrokePathCommand::accept(CommandVisitor& visitor) const { visitor.visit(*this); } void DrawImageCommand::accept(CommandVisitor& visitor) const { visitor.visit(*this); } void DisplayList::addCommand(std::unique_ptr cmd) { @@ -43,14 +44,18 @@ void DisplayList::drawText(const std::string& text, float x, float y) { addCommand(std::make_unique(text, x, y)); } -void DisplayList::fillPath(const Path& path) { - addCommand(std::make_unique(path)); +void DisplayList::fillPath(const Path& path, FillRule rule) { + addCommand(std::make_unique(path, rule)); } void DisplayList::strokePath(const Path& path) { addCommand(std::make_unique(path)); } +void DisplayList::fillStrokePath(const Path& path, FillRule rule) { + addCommand(std::make_unique(path, rule)); +} + void DisplayList::drawImage(const ImageInfo& image, const Matrix& m, float opacity) { addCommand(std::make_unique(image, m, opacity)); } diff --git a/engine/src/core/path_interpreter.cpp b/engine/src/core/path_interpreter.cpp new file mode 100644 index 0000000..073fa2c --- /dev/null +++ b/engine/src/core/path_interpreter.cpp @@ -0,0 +1,28 @@ +#include + +namespace pdfengine { + +void PathObjectInterpreter::interpret(const PathObject& pathObj, DisplayList& displayList) { + if (pathObj.path.empty()) { + return; + } + + displayList.saveState(); + displayList.setTransform(pathObj.transform); + + switch (pathObj.paintOp) { + case PathPaintOp::Stroke: + displayList.strokePath(pathObj.path); + break; + case PathPaintOp::Fill: + displayList.fillPath(pathObj.path, pathObj.fillRule); + break; + case PathPaintOp::FillStroke: + displayList.fillStrokePath(pathObj.path, pathObj.fillRule); + break; + } + + displayList.restoreState(); +} + +} // namespace pdfengine diff --git a/engine/src/core/skia_renderer.cpp b/engine/src/core/skia_renderer.cpp index 3165902..216f9a4 100644 --- a/engine/src/core/skia_renderer.cpp +++ b/engine/src/core/skia_renderer.cpp @@ -122,6 +122,12 @@ void SkiaRenderer::visit(const FillPathCommand& cmd) { } } + if (cmd.rule == FillRule::EvenOdd) { + skPath.setFillType(SkPathFillType::kEvenOdd); + } else { + skPath.setFillType(SkPathFillType::kWinding); + } + SkPaint paint; paint.setAntiAlias(true); paint.setStyle(SkPaint::kFill_Style); @@ -178,6 +184,64 @@ void SkiaRenderer::visit(const StrokePathCommand& cmd) { #endif } +void SkiaRenderer::visit(const FillStrokePathCommand& cmd) { + (void)cmd; +#ifdef PDFENGINE_WITH_SKIA + if (!m_canvas || cmd.path.empty()) return; + + SkPath skPath; + for (const auto& segment : cmd.path.segments()) { + switch (segment.verb) { + case Path::Verb::MoveTo: + skPath.moveTo(segment.points[0].x, segment.points[0].y); + break; + case Path::Verb::LineTo: + skPath.lineTo(segment.points[0].x, segment.points[0].y); + break; + case Path::Verb::CubicBezierTo: + skPath.cubicTo( + segment.points[0].x, segment.points[0].y, + segment.points[1].x, segment.points[1].y, + segment.points[2].x, segment.points[2].y + ); + break; + case Path::Verb::Close: + skPath.close(); + break; + } + } + + if (cmd.rule == FillRule::EvenOdd) { + skPath.setFillType(SkPathFillType::kEvenOdd); + } else { + skPath.setFillType(SkPathFillType::kWinding); + } + + const auto& color = m_stateStack.current().fillColor; + + // First fill + SkPaint fillPaint; + fillPaint.setAntiAlias(true); + fillPaint.setStyle(SkPaint::kFill_Style); + fillPaint.setColor(SkColorSetARGB(255, + static_cast(color.r * 255), + static_cast(color.g * 255), + static_cast(color.b * 255))); + m_canvas->drawPath(skPath, fillPaint); + + // Then stroke + SkPaint strokePaint; + strokePaint.setAntiAlias(true); + strokePaint.setStyle(SkPaint::kStroke_Style); + strokePaint.setColor(SkColorSetARGB(255, + static_cast(color.r * 255), + static_cast(color.g * 255), + static_cast(color.b * 255))); + strokePaint.setStrokeWidth(1.0f); + m_canvas->drawPath(skPath, strokePaint); +#endif +} + void SkiaRenderer::visit(const DrawImageCommand& cmd) { (void)cmd; #ifdef PDFENGINE_WITH_SKIA diff --git a/engine/src/parser/content_builder.cpp b/engine/src/parser/content_builder.cpp index 3267cac..6510eb9 100644 --- a/engine/src/parser/content_builder.cpp +++ b/engine/src/parser/content_builder.cpp @@ -46,11 +46,14 @@ void ContentBuilder::processOperation(const Operation& op, std::vector>& outObjects, - bool closePath) { + bool closePath, + FillRule fillRule) { if (closePath) { if (!currentPath_.empty()) { currentPath_.close(); @@ -298,6 +302,7 @@ void ContentBuilder::handlePathPaint(PathPaintOp paintOp, auto pathObj = std::make_unique(); pathObj->path = currentPath_; pathObj->paintOp = paintOp; + pathObj->fillRule = fillRule; pathObj->transform = state_.ctm; outObjects.push_back(std::move(pathObj)); diff --git a/engine/src/parser/content_builder.hpp b/engine/src/parser/content_builder.hpp index f770951..68a8849 100644 --- a/engine/src/parser/content_builder.hpp +++ b/engine/src/parser/content_builder.hpp @@ -37,7 +37,7 @@ private: 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); + void handlePathPaint(PathPaintOp paintOp, std::vector>& outObjects, bool closePath = false, FillRule fillRule = FillRule::NonZero); }; } diff --git a/engine/src/parser/pdfium_document.cpp b/engine/src/parser/pdfium_document.cpp index 28b8335..afbbeab 100644 --- a/engine/src/parser/pdfium_document.cpp +++ b/engine/src/parser/pdfium_document.cpp @@ -19,6 +19,9 @@ #include "fonts/shaping/hb_shaper.hpp" #include "fonts/face/font_face.hpp" #include "decoration_builder.hpp" +#include "qpdf/qpdf_extractor.hpp" +#include "parser/lexer.hpp" +#include "parser/parser.hpp" #include #include @@ -951,6 +954,7 @@ std::expected, EngineError> PdfiumPage::extractTextWith } namespace { +#ifdef PDFENGINE_WITH_PDFIUM inline void repagSetParaId(FPDF_DOCUMENT doc, FPDF_PAGEOBJECT obj, const std::string& paraId) { if (paraId.empty() || !obj) return; FPDF_PAGEOBJECTMARK mark = FPDFPageObj_AddMark(obj, "PDFPARA"); @@ -982,6 +986,7 @@ inline std::string repagGetParaId(FPDF_PAGEOBJECT obj) { } return ""; } +#endif } std::expected PdfiumPage::extractDocumentModel() const { @@ -4437,6 +4442,51 @@ std::expected, std::string> PdfiumDocume #endif } +std::expected PdfiumPage::extractDisplayListJson() const { +#ifdef PDFENGINE_WITH_PDFIUM + if (!ownerDoc_) return std::unexpected(EngineError::Unknown); + const auto& buffer = ownerDoc_->getMemoryBuffer(); + if (buffer.empty()) return std::unexpected(EngineError::FileNotFound); + + qpdf_layer::QpdfExtractor extractor; + auto stream = extractor.extractPageStreamFromMemory(buffer, pageIndex_); + if (!stream) return std::unexpected(EngineError::InvalidFormat); + + Lexer lexer(stream->decodedContent); + auto tokens = lexer.tokenize(); + ContentParser parser(tokens); + auto operations = parser.parse(); + + nlohmann::json j_array = nlohmann::json::array(); + for (const auto& op : operations) { + nlohmann::json j_op = nlohmann::json::object(); + j_op["op"] = op.op; + if (!op.operands.empty()) { + nlohmann::json j_args = nlohmann::json::array(); + for (const auto& arg : op.operands) { + if (arg->type == AstNodeType::Number) { + j_args.push_back(arg->numberValue); + } else if (arg->type == AstNodeType::Name || arg->type == AstNodeType::String) { + j_args.push_back(arg->stringValue); + } else { + j_args.push_back(""); + } + } + j_op["args"] = j_args; + } + j_array.push_back(j_op); + } + return j_array.dump(); +#else + return std::unexpected(EngineError::Unknown); +#endif +} + +std::expected, EngineError> PdfiumPage::extractImageXObject(const std::string& name) const { + (void)name; + return std::unexpected(EngineError::Unknown); // Stub for now +} + void PdfiumDocument::invalidateCaches() { { std::lock_guard lock(fontsMutex_); diff --git a/engine/src/parser/pdfium_document.hpp b/engine/src/parser/pdfium_document.hpp index d458d3f..af08d35 100644 --- a/engine/src/parser/pdfium_document.hpp +++ b/engine/src/parser/pdfium_document.hpp @@ -55,6 +55,9 @@ public: std::expected getGlyphWidth(const std::string& fontName, uint32_t charcode, double fontSize) const override; + std::expected extractDisplayListJson() const override; + std::expected, EngineError> extractImageXObject(const std::string& name) const override; + DevicePoint pageToDevice(const Point2D& pagePoint, int deviceWidth, int deviceHeight, int rotate = 0) const noexcept override; Point2D deviceToPage(const DevicePoint& devicePoint, int deviceWidth, int deviceHeight, int rotate = 0) const noexcept override; @@ -102,6 +105,8 @@ public: std::string lastReflowLayout() const { return lastReflowLayout_; } bool lastReflowOverflowed() const { return lastReflowOverflowed_; } + const std::vector& getMemoryBuffer() const { return memoryBuffer_; } + private: mutable std::string lastReflowLayout_; mutable bool lastReflowOverflowed_ = false; diff --git a/engine/tests/display_list_test.cpp b/engine/tests/display_list_test.cpp index afbc4cb..8ed2834 100644 --- a/engine/tests/display_list_test.cpp +++ b/engine/tests/display_list_test.cpp @@ -16,6 +16,7 @@ public: void visit(const DrawTextCommand& cmd) override { calls.push_back("DrawText(" + cmd.text + ")"); } void visit(const FillPathCommand&) override { calls.push_back("FillPath"); } void visit(const StrokePathCommand&) override { calls.push_back("StrokePath"); } + void visit(const FillStrokePathCommand&) override { calls.push_back("FillStrokePath"); } void visit(const DrawImageCommand&) override { calls.push_back("DrawImage"); } }; diff --git a/frontend/src/lib/gatewayService.ts b/frontend/src/lib/gatewayService.ts index 49078f2..beedd58 100644 --- a/frontend/src/lib/gatewayService.ts +++ b/frontend/src/lib/gatewayService.ts @@ -530,6 +530,16 @@ class GatewayService { return response.json(); } + async getPageDisplayList(documentId: string, pageIndex: number): Promise { + const response = await fetch(`${this.baseUrl}/documents/${documentId}/pages/${pageIndex}/display_list`); + if (!response.ok) throw new Error(`Failed to get display list: ${response.statusText}`); + return response.json(); + } + + getImageXObjectUrl(documentId: string, pageIndex: number, name: string): string { + return `${this.baseUrl}/documents/${documentId}/pages/${pageIndex}/xobjects/${encodeURIComponent(name)}`; + } + private getMockDocuments(): DocumentInfo[] { return [ { diff --git a/frontend/src/viewer/CanvasLayer.tsx b/frontend/src/viewer/CanvasLayer.tsx index ecc3f61..c184e12 100644 --- a/frontend/src/viewer/CanvasLayer.tsx +++ b/frontend/src/viewer/CanvasLayer.tsx @@ -1,6 +1,8 @@ -import React, { useRef, useEffect } from 'react'; +import React, { useRef, useEffect, useState } from 'react'; +import { gatewayService } from '../lib/gatewayService'; interface CanvasLayerProps { + documentId: string; pageIndex: number; imageUrl: string; zoom: number; @@ -11,6 +13,7 @@ interface CanvasLayerProps { } export const CanvasLayer: React.FC = ({ + documentId, pageIndex, imageUrl, zoom, @@ -20,6 +23,13 @@ export const CanvasLayer: React.FC = ({ onRenderComplete, }) => { const canvasRef = useRef(null); + const [vectorOps, setVectorOps] = useState(null); + + useEffect(() => { + gatewayService.getPageDisplayList(documentId, pageIndex) + .then(ops => setVectorOps(ops)) + .catch(console.error); + }, [documentId, pageIndex]); useEffect(() => { const canvas = canvasRef.current; @@ -50,11 +60,49 @@ export const CanvasLayer: React.FC = ({ ctx.drawImage(img, 0, 0, width, height); ctx.restore(); + // Render vector ops on top + if (vectorOps && vectorOps.length > 0) { + ctx.save(); + ctx.translate(0, height); + ctx.scale(1, -1); + ctx.strokeStyle = 'rgba(255, 0, 0, 0.5)'; + ctx.fillStyle = 'rgba(0, 0, 255, 0.2)'; + ctx.lineWidth = 1; + + ctx.beginPath(); + for (const op of vectorOps) { + if (op.op === 'm' && op.args && op.args.length === 2) { + ctx.moveTo(op.args[0], op.args[1]); + } else if (op.op === 'l' && op.args && op.args.length === 2) { + ctx.lineTo(op.args[0], op.args[1]); + } else if (op.op === 'c' && op.args && op.args.length === 6) { + ctx.bezierCurveTo(op.args[0], op.args[1], op.args[2], op.args[3], op.args[4], op.args[5]); + } else if (op.op === 're' && op.args && op.args.length === 4) { + ctx.rect(op.args[0], op.args[1], op.args[2], op.args[3]); + } else if (op.op === 'h') { + ctx.closePath(); + } else if (op.op === 'S' || op.op === 's') { + ctx.stroke(); + ctx.beginPath(); + } else if (op.op === 'f' || op.op === 'F') { + ctx.fill(); + ctx.beginPath(); + } else if (op.op === 'B' || op.op === 'b') { + ctx.fill(); + ctx.stroke(); + ctx.beginPath(); + } else if (op.op === 'n') { + ctx.beginPath(); + } + } + ctx.restore(); + } + onRenderComplete?.(pageIndex); }; img.src = imageUrl; - }, [imageUrl, zoom, rotation, width, height, onRenderComplete]); + }, [imageUrl, zoom, rotation, width, height, onRenderComplete, vectorOps]); return ( (({ {imageUrl ? ( <>