From e1e1c91736d050468f6ebaacde4f45c4b2498e6c Mon Sep 17 00:00:00 2001 From: saqib mir Date: Mon, 22 Jun 2026 10:17:51 +0530 Subject: [PATCH] fix the issue --- docs.json | 0 engine/include/pdfengine/content_object.hpp | 16 +++ engine/src/parser/content_builder.cpp | 99 +++++++++++++++++ engine/src/parser/content_builder.hpp | 3 + engine/tests/content_builder_test.cpp | 114 ++++++++++++++++++++ engine/tests/image_xobject_test.cpp | 73 ++++++++++--- frontend/src/viewer/StreamEditLayer.tsx | 43 +++++--- gateway/app/routers/documents.py | 8 +- 8 files changed, 321 insertions(+), 35 deletions(-) create mode 100644 docs.json 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/parser/content_builder.cpp b/engine/src/parser/content_builder.cpp index 5c29986..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::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/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 e1b93b2..53cde7a 100644 --- a/engine/tests/image_xobject_test.cpp +++ b/engine/tests/image_xobject_test.cpp @@ -721,6 +721,62 @@ TEST(ImageXObjectVerification, LoadRenderCloseStress100Pages1000ImageDraws) { 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"; @@ -734,20 +790,5 @@ TEST(ImageXObjectVerification, LoadRenderCloseStress100Pages1000ImageDraws) { } document = {}; - QPDF qpdf; - qpdf.processMemoryFile("image-xobject-stress", reinterpret_cast(pdf.data()), pdf.size()); - auto pages = qpdf.getAllPages(); - ASSERT_EQ(pages.size(), static_cast(kPages)); - - size_t decodedImages = 0; - for (const auto& page : pages) { - auto pageObjects = buildPageObjects(page); - auto parsedImages = imageObjects(pageObjects); - ASSERT_EQ(parsedImages.size(), static_cast(kImagesPerPage)); - for (const auto* image : parsedImages) { - EXPECT_EQ(image->pixelData.size(), 4u); - } - decodedImages += parsedImages.size(); - } - EXPECT_EQ(decodedImages, static_cast(kPages * kImagesPerPage)); + verifyCustomDecode("after-pdfium-render"); } 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(