feat: implemented text extraction with bounding boxes
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user