feat: implemented text extraction with bounding boxes

This commit is contained in:
Furqan-14
2026-05-25 14:47:38 +05:30
parent 5ce96ebd5d
commit f961c579bc
8 changed files with 165 additions and 3 deletions
+15
View File
@@ -96,6 +96,21 @@ PYBIND11_MODULE(pdfengine, m) {
.def("extract_text", [](const pdfengine::PdfPage& self) {
return get_or_throw(self.extractText());
})
.def("extract_text_with_bounds", [](const pdfengine::PdfPage& self) {
auto res = get_or_throw(self.extractTextWithBounds());
py::list py_list;
for (const auto& glyph : res) {
py::dict d;
d["text"] = glyph.text;
d["x"] = glyph.x;
d["y"] = glyph.y;
d["w"] = glyph.w;
d["h"] = glyph.h;
d["fontSize"] = glyph.fontSize;
py_list.append(d);
}
return py_list;
})
.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,
+11
View File
@@ -44,6 +44,15 @@ struct DevicePoint {
int y;
};
struct GlyphBounds {
std::string text;
double x;
double y;
double w;
double h;
double fontSize;
};
class PdfPage {
public:
virtual ~PdfPage() = default;
@@ -55,6 +64,8 @@ public:
[[nodiscard]] virtual std::expected<std::string, EngineError> extractText() const = 0;
[[nodiscard]] virtual std::expected<std::vector<GlyphBounds>, EngineError> extractTextWithBounds() const = 0;
[[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;
};
+97
View File
@@ -33,6 +33,28 @@ std::string utf16le_to_utf8(const char16_t* utf16, size_t length) {
return utf8;
}
std::string code_point_to_utf8(unsigned int cp) {
std::string utf8;
if (cp == 0) return "";
if (cp < 0x80) {
utf8 += static_cast<char>(cp);
} else if (cp < 0x800) {
utf8 += static_cast<char>(0xC0 | (cp >> 6));
utf8 += static_cast<char>(0x80 | (cp & 0x3F));
} else if (cp < 0x10000) {
utf8 += static_cast<char>(0xE0 | (cp >> 12));
utf8 += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
utf8 += static_cast<char>(0x80 | (cp & 0x3F));
} else if (cp < 0x200000) {
utf8 += static_cast<char>(0xF0 | (cp >> 18));
utf8 += static_cast<char>(0x80 | ((cp >> 12) & 0x3F));
utf8 += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
utf8 += static_cast<char>(0x80 | (cp & 0x3F));
}
return utf8;
}
#ifdef PDFENGINE_WITH_PDFIUM
struct VectorWriter : public FPDF_FILEWRITE {
std::vector<uint8_t> buffer;
@@ -301,6 +323,81 @@ std::expected<std::string, EngineError> PdfiumPage::extractText() const {
#endif
}
std::expected<std::vector<GlyphBounds>, EngineError> PdfiumPage::extractTextWithBounds() const {
#ifdef PDFENGINE_WITH_PDFIUM
if (!page_) {
return std::unexpected(EngineError::Unknown);
}
ensureTextPageLoaded();
if (!textPage_) {
return std::unexpected(EngineError::Unknown);
}
int charCount = FPDFText_CountChars(textPage_);
std::vector<GlyphBounds> result;
if (charCount <= 0) {
return result;
}
result.reserve(charCount);
for (int i = 0; i < charCount; ++i) {
unsigned int codeUnit = FPDFText_GetUnicode(textPage_, i);
unsigned int cp = codeUnit;
double left = 0, right = 0, bottom = 0, top = 0;
double fontSize = FPDFText_GetFontSize(textPage_, i);
if (codeUnit >= 0xD800 && codeUnit <= 0xDBFF && i + 1 < charCount) {
unsigned int nextUnit = FPDFText_GetUnicode(textPage_, i + 1);
if (nextUnit >= 0xDC00 && nextUnit <= 0xDFFF) {
cp = 0x10000 + ((codeUnit - 0xD800) << 10) + (nextUnit - 0xDC00);
double l1 = 0, r1 = 0, b1 = 0, t1 = 0;
FPDFText_GetCharBox(textPage_, i, &l1, &r1, &b1, &t1);
double l2 = 0, r2 = 0, b2 = 0, t2 = 0;
FPDFText_GetCharBox(textPage_, i + 1, &l2, &r2, &b2, &t2);
left = (std::min)(l1, l2);
right = (std::max)(r1, r2);
bottom = (std::min)(b1, b2);
top = (std::max)(t1, t2);
++i;
} else {
FPDFText_GetCharBox(textPage_, i, &left, &right, &bottom, &top);
}
} else {
FPDFText_GetCharBox(textPage_, i, &left, &right, &bottom, &top);
}
std::string utf8_char = code_point_to_utf8(cp);
if (utf8_char.empty()) {
continue;
}
double x = (std::min)(left, right);
double y = (std::min)(bottom, top);
double w = std::abs(right - left);
double h = std::abs(top - bottom);
GlyphBounds gb;
gb.text = std::move(utf8_char);
gb.x = x;
gb.y = y;
gb.w = w;
gb.h = h;
gb.fontSize = fontSize;
result.push_back(gb);
}
return result;
#else
return std::unexpected(EngineError::Unknown);
#endif
}
DevicePoint PdfiumPage::pageToDevice(const Point2D& pagePoint, int deviceWidth, int deviceHeight, int rotate) const noexcept {
#ifdef PDFENGINE_WITH_PDFIUM
if (!page_) return {0, 0};
+1
View File
@@ -39,6 +39,7 @@ public:
std::expected<PageImage, EngineError> render(int dpi = 96) const override;
std::expected<std::string, EngineError> extractText() const override;
std::expected<std::vector<GlyphBounds>, EngineError> extractTextWithBounds() 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;
+31
View File
@@ -343,4 +343,35 @@ TEST(TextExtractionTest, ExtractUtf8ExtendedText) {
EXPECT_FALSE(text.empty());
}
TEST(TextExtractionTest, ExtractTextWithBounds) {
SKIP_IF_NO_PDFIUM();
auto path = getCorpusPath("basic", "hello_world.pdf");
if (!std::filesystem::exists(path)) {
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
}
auto docRes = PdfDocument::loadFromFile(path.string());
ASSERT_TRUE(docRes.has_value());
auto pageRes = (*docRes)->getPage(0);
ASSERT_TRUE(pageRes.has_value());
auto boundsRes = (*pageRes)->extractTextWithBounds();
ASSERT_TRUE(boundsRes.has_value());
const auto& glyphs = *boundsRes;
ASSERT_FALSE(glyphs.empty());
bool found_hello = false;
for (const auto& glyph : glyphs) {
EXPECT_FALSE(glyph.text.empty());
EXPECT_GT(glyph.w, 0.0);
EXPECT_GT(glyph.h, 0.0);
EXPECT_GT(glyph.fontSize, 0.0);
if (glyph.text == "H" || glyph.text == "e" || glyph.text == "l" || glyph.text == "o") {
found_hello = true;
}
}
EXPECT_TRUE(found_hello);
}
}
+2 -1
View File
@@ -44,7 +44,8 @@ def extract_page_text(document_id: str, page_index: int):
doc = doc_info["doc_instance"]
page = doc.get_page(page_index)
text = page.extract_text()
return {"text": text}
glyphs = page.extract_text_with_bounds()
return {"text": text, "glyphs": glyphs}
except IndexError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Page index out of bounds")
except Exception as e:
+2 -2
View File
@@ -1,6 +1,6 @@
import threading
import uuid
from datetime import datetime
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
class DocumentStore:
@@ -10,7 +10,7 @@ class DocumentStore:
def add_document(self, filename: str, bytes_data: bytes, doc_instance: Any) -> Dict[str, Any]:
doc_id = str(uuid.uuid4())
uploaded_at = datetime.utcnow().isoformat() + "Z"
uploaded_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
info = {
"id": doc_id,
+6
View File
@@ -124,6 +124,12 @@ def test_extract_page_text(client: TestClient):
payload = text_resp.json()
assert "text" in payload
assert "hello" in payload["text"].lower()
assert "glyphs" in payload
glyphs = payload["glyphs"]
assert len(glyphs) > 0
first_glyph = glyphs[0]
for key in ["text", "x", "y", "w", "h", "fontSize"]:
assert key in first_glyph
def test_apply_edits_and_incremental_save(client: TestClient):
with open(HELLO_WORLD_PDF, "rb") as f: