feat: updated text handling bugs
This commit is contained in:
+2
-1
@@ -119,7 +119,8 @@
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Release",
|
||||
"PDFENGINE_BUILD_TESTS": "OFF",
|
||||
"PDFENGINE_WITH_PDFIUM": "ON"
|
||||
"PDFENGINE_WITH_PDFIUM": "ON",
|
||||
"PDFENGINE_WITH_QPDF": "ON"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -492,5 +492,9 @@ PYBIND11_MODULE(pdfengine, m) {
|
||||
.def("save_full", [](const pdfengine::PdfDocument& self) {
|
||||
std::vector<uint8_t> res = get_or_throw(self.saveFull());
|
||||
return py::bytes(reinterpret_cast<const char*>(res.data()), res.size());
|
||||
})
|
||||
.def("save_full_for_export", [](const pdfengine::PdfDocument& self) {
|
||||
std::vector<uint8_t> res = get_or_throw(self.saveFullForExport());
|
||||
return py::bytes(reinterpret_cast<const char*>(res.data()), res.size());
|
||||
});
|
||||
}
|
||||
@@ -265,8 +265,11 @@ public:
|
||||
[[nodiscard]] virtual std::expected<std::vector<uint8_t>, EngineError>
|
||||
saveIncremental() const = 0;
|
||||
|
||||
[[nodiscard]] virtual std::expected<std::vector<uint8_t>, EngineError>
|
||||
[[nodiscard]] virtual std::expected<std::vector<uint8_t>, EngineError>
|
||||
saveFull() const = 0;
|
||||
|
||||
[[nodiscard]] virtual std::expected<std::vector<uint8_t>, EngineError>
|
||||
saveFullForExport() const { return saveFull(); }
|
||||
};
|
||||
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
#include <hb.h>
|
||||
#include <hb-ft.h>
|
||||
#include <mutex>
|
||||
#include <spdlog/spdlog.h>
|
||||
#include <ft2build.h>
|
||||
#include FT_FREETYPE_H
|
||||
|
||||
namespace pdfengine::fonts {
|
||||
|
||||
@@ -25,8 +28,13 @@ std::vector<ShapedGlyph> HbShaper::shapeRun(
|
||||
std::lock_guard<std::mutex> lock(font.getMutex());
|
||||
|
||||
if (FT_Set_Pixel_Sizes(ftFace, 0, fontSize)) {
|
||||
spdlog::warn("HbShaper: FT_Set_Pixel_Sizes({}) failed (numFixedSizes={} scalable={})",
|
||||
fontSize, ftFace->num_fixed_sizes, (FT_IS_SCALABLE(ftFace) ? 1 : 0));
|
||||
return result;
|
||||
}
|
||||
if (FT_Select_Charmap(ftFace, FT_ENCODING_UNICODE) != 0 && ftFace->num_charmaps > 0) {
|
||||
FT_Set_Charmap(ftFace, ftFace->charmaps[0]);
|
||||
}
|
||||
|
||||
hb_font_t* hbFont = hb_ft_font_create_referenced(ftFace);
|
||||
if (!hbFont) {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
#include "parser/pdfium_internal.hpp"
|
||||
|
||||
#if defined(PDFENGINE_WITH_PDFIUM) && defined(PDFENGINE_WITH_QPDF)
|
||||
#include "qpdf/qpdf_writer.hpp"
|
||||
#endif
|
||||
|
||||
namespace pdfengine {
|
||||
|
||||
std::expected<std::shared_ptr<PdfDocument>, EngineError>
|
||||
@@ -240,6 +244,20 @@ std::expected<std::vector<uint8_t>, EngineError> PdfiumDocument::saveFull() cons
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<std::vector<uint8_t>, EngineError> PdfiumDocument::saveFullForExport() const {
|
||||
auto bytes = saveFull();
|
||||
#if defined(PDFENGINE_WITH_PDFIUM) && defined(PDFENGINE_WITH_QPDF)
|
||||
if (bytes) {
|
||||
qpdf_layer::QpdfWriter writer;
|
||||
auto withAppearances = writer.setNeedAppearances(*bytes);
|
||||
if (withAppearances) return *withAppearances;
|
||||
spdlog::warn("saveFullForExport: NeedAppearances pass failed ({}); returning plain save",
|
||||
withAppearances.error());
|
||||
}
|
||||
#endif
|
||||
return bytes;
|
||||
}
|
||||
|
||||
void PdfiumDocument::invalidateCaches() {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(fontsMutex_);
|
||||
|
||||
@@ -103,6 +103,7 @@ public:
|
||||
std::expected<void, EngineError> applyEdits(const std::string& editsJson) override;
|
||||
std::expected<std::vector<uint8_t>, EngineError> saveIncremental() const override;
|
||||
std::expected<std::vector<uint8_t>, EngineError> saveFull() const override;
|
||||
std::expected<std::vector<uint8_t>, EngineError> saveFullForExport() const override;
|
||||
|
||||
std::string lastReflowLayout() const { return lastReflowLayout_; }
|
||||
bool lastReflowOverflowed() const { return lastReflowOverflowed_; }
|
||||
@@ -158,6 +159,8 @@ private:
|
||||
const fonts::pdf_fonts::ReconstructedFont* getReconstructedEmbeddedFont(const std::string& internalFontId);
|
||||
#endif
|
||||
|
||||
void rebalanceEditedPages(const std::vector<int>& editedPages);
|
||||
|
||||
std::expected<void, EngineError> applyOp_replaceText(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_reflow(const nlohmann::json& op, int pageIndex);
|
||||
std::expected<void, EngineError> applyOp_textOverlay(const nlohmann::json& op, int pageIndex);
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
#include "parser/pdfium_internal.hpp"
|
||||
|
||||
#include <set>
|
||||
#include <algorithm>
|
||||
#if defined(PDFENGINE_WITH_PDFIUM) && defined(PDFENGINE_WITH_QPDF)
|
||||
#include "qpdf/qpdf_writer.hpp"
|
||||
#endif
|
||||
|
||||
namespace pdfengine::parser {
|
||||
|
||||
std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& editsJson) {
|
||||
@@ -10,6 +16,12 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
||||
|
||||
lastReflowLayout_.clear();
|
||||
|
||||
std::vector<int> editedPages;
|
||||
auto markEdited = [&editedPages](int p) {
|
||||
if (std::find(editedPages.begin(), editedPages.end(), p) == editedPages.end())
|
||||
editedPages.push_back(p);
|
||||
};
|
||||
|
||||
try {
|
||||
auto root = nlohmann::json::parse(editsJson);
|
||||
if (!root.contains("version") || root["version"] != "1.0") {
|
||||
@@ -29,6 +41,12 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
||||
return std::unexpected(EngineError::PageOutOfBounds);
|
||||
}
|
||||
|
||||
static const std::set<std::string> kContentOps = {
|
||||
"replace_text", "reflow_paragraph", "text_overlay", "add_text",
|
||||
"underline", "strikeout", "squiggly", "redaction",
|
||||
"image_overlay", "highlight", "free_text", "comment", "freehand"};
|
||||
if (kContentOps.count(type)) markEdited(pageIndex);
|
||||
|
||||
std::expected<void, EngineError> r{};
|
||||
if (type == "replace_text") {
|
||||
r = applyOp_replaceText(op, pageIndex);
|
||||
@@ -78,6 +96,7 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
rebalanceEditedPages(editedPages);
|
||||
invalidateCaches();
|
||||
return {};
|
||||
#else
|
||||
@@ -86,4 +105,34 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
||||
#endif
|
||||
}
|
||||
|
||||
void PdfiumDocument::rebalanceEditedPages(const std::vector<int>& editedPages) {
|
||||
#if defined(PDFENGINE_WITH_PDFIUM) && defined(PDFENGINE_WITH_QPDF)
|
||||
if (!doc_ || editedPages.empty()) return;
|
||||
|
||||
auto bytesRes = saveFull();
|
||||
if (!bytesRes || bytesRes->empty()) return;
|
||||
|
||||
qpdf_layer::QpdfWriter writer;
|
||||
auto reb = writer.rebalanceContentStreams(*bytesRes, editedPages);
|
||||
if (!reb) {
|
||||
spdlog::warn("rebalanceEditedPages: {}", reb.error());
|
||||
return;
|
||||
}
|
||||
if (!reb->has_value()) return;
|
||||
|
||||
std::vector<uint8_t> newBytes = std::move(**reb);
|
||||
FPDF_DOCUMENT nd = FPDF_LoadMemDocument(newBytes.data(), static_cast<int>(newBytes.size()), nullptr);
|
||||
if (!nd) {
|
||||
spdlog::warn("rebalanceEditedPages: reload of repaired doc failed; keeping original");
|
||||
return;
|
||||
}
|
||||
FPDF_CloseDocument(doc_);
|
||||
doc_ = nd;
|
||||
memoryBuffer_ = std::move(newBytes);
|
||||
spdlog::info("rebalanceEditedPages: repaired q/Q underflow on edited page(s) and reloaded doc");
|
||||
#else
|
||||
(void)editedPages;
|
||||
#endif
|
||||
}
|
||||
|
||||
}
|
||||
@@ -67,6 +67,7 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
||||
for (const auto& v : data["lineX"]) lineX.push_back(v.get<double>());
|
||||
double columnWidth = columnRight - columnLeft;
|
||||
double pushColumnLeft = data.value("pushColumnLeft", columnLeft);
|
||||
double hangingIndent = data.value("hangingIndent", 0.0);
|
||||
std::string paraId;
|
||||
if (data.contains("paraId") && data["paraId"].is_string())
|
||||
paraId = data["paraId"].get<std::string>();
|
||||
@@ -278,6 +279,7 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
||||
};
|
||||
|
||||
auto isSpace = [](char ch) { return ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r'; };
|
||||
constexpr size_t kHardBreak = static_cast<size_t>(-1);
|
||||
struct Seg { int runIdx; std::string text; double width; size_t off; };
|
||||
struct Word { std::vector<Seg> segs; double width; double spaceAfter; };
|
||||
std::vector<Word> words;
|
||||
@@ -291,42 +293,68 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
||||
std::vector<size_t> out;
|
||||
size_t i = 0;
|
||||
while (i < ft.size()) {
|
||||
if (isSpace(ft[i])) { i++; continue; }
|
||||
if (isSpace(ft[i])) { if (ft[i] == '\n') out.push_back(kHardBreak); i++; continue; }
|
||||
Word w; w.width = 0.0; w.spaceAfter = 0.0;
|
||||
while (i < ft.size() && !isSpace(ft[i])) {
|
||||
int st = sof[i]; size_t segOff = cof[i]; std::string frag; double fw = 0.0;
|
||||
while (i < ft.size() && !isSpace(ft[i]) && sof[i] == st) { frag.push_back(ft[i]); fw += charAdvAt(st, cof[i]); i++; }
|
||||
w.segs.push_back({st, frag, fw, segOff}); w.width += fw;
|
||||
}
|
||||
while (i < ft.size() && isSpace(ft[i])) { w.spaceAfter += charAdvAt(sof[i], cof[i]); i++; }
|
||||
size_t newlines = 0;
|
||||
while (i < ft.size() && isSpace(ft[i])) {
|
||||
if (ft[i] == '\n') newlines++;
|
||||
else if (newlines == 0) w.spaceAfter += charAdvAt(sof[i], cof[i]);
|
||||
i++;
|
||||
}
|
||||
out.push_back(words.size());
|
||||
words.push_back(std::move(w));
|
||||
for (size_t n = 0; n < newlines; ++n) out.push_back(kHardBreak);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
std::vector<std::vector<size_t>> lines;
|
||||
std::vector<char> lineCont;
|
||||
std::vector<char> lineSegEnd;
|
||||
if (hasProvidedLines) {
|
||||
for (const auto& lineRunIdxs : providedLines) {
|
||||
auto wi = tokenize(lineRunIdxs);
|
||||
if (!wi.empty()) lines.push_back(std::move(wi));
|
||||
wi.erase(std::remove(wi.begin(), wi.end(), kHardBreak), wi.end());
|
||||
if (!wi.empty()) { lines.push_back(std::move(wi)); lineCont.push_back(0); lineSegEnd.push_back(0); }
|
||||
}
|
||||
for (size_t li = 0; li < lineSegEnd.size(); ++li)
|
||||
lineSegEnd[li] = (li + 1 == lineSegEnd.size()) ? 1 : 0;
|
||||
} else {
|
||||
std::vector<int> allRuns(runs.size());
|
||||
for (size_t ri = 0; ri < runs.size(); ++ri) allRuns[ri] = static_cast<int>(ri);
|
||||
auto allWords = tokenize(allRuns);
|
||||
std::vector<size_t> cur; double curW = 0.0;
|
||||
size_t prevWord = kHardBreak;
|
||||
bool firstLineOfSeg = true;
|
||||
auto pushLine = [&](char segEnd) {
|
||||
lines.push_back(cur);
|
||||
lineCont.push_back(firstLineOfSeg ? 0 : 1);
|
||||
lineSegEnd.push_back(segEnd);
|
||||
};
|
||||
for (size_t k = 0; k < allWords.size(); ++k) {
|
||||
size_t wi = allWords[k];
|
||||
double gap = cur.empty() ? 0.0 : words[allWords[k - 1]].spaceAfter;
|
||||
if (!cur.empty() && curW + gap + words[wi].width > columnWidth) {
|
||||
lines.push_back(cur); cur.clear();
|
||||
if (wi == kHardBreak) { // forced break: end the current line (may be blank)
|
||||
pushLine(1);
|
||||
cur.clear(); curW = 0.0; prevWord = kHardBreak; firstLineOfSeg = true;
|
||||
continue;
|
||||
}
|
||||
double effW = columnWidth - (firstLineOfSeg ? 0.0 : hangingIndent);
|
||||
double gap = (cur.empty() || prevWord == kHardBreak) ? 0.0 : words[prevWord].spaceAfter;
|
||||
if (!cur.empty() && curW + gap + words[wi].width > effW) {
|
||||
pushLine(0);
|
||||
cur.clear(); firstLineOfSeg = false;
|
||||
cur.push_back(wi); curW = words[wi].width;
|
||||
} else {
|
||||
cur.push_back(wi); curW += gap + words[wi].width;
|
||||
}
|
||||
prevWord = wi;
|
||||
}
|
||||
if (!cur.empty()) lines.push_back(cur);
|
||||
if (!cur.empty()) pushLine(1);
|
||||
}
|
||||
if (words.empty() || lines.empty()) { FPDF_ClosePage(page); return {}; }
|
||||
int newLineCount = static_cast<int>(lines.size());
|
||||
@@ -369,17 +397,20 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
||||
naturalW += words[lw[k]].width;
|
||||
if (k > 0) naturalW += words[lw[k - 1]].spaceAfter;
|
||||
}
|
||||
bool justifyThis = (align == "justify") && (li + 1 < lines.size()) && lw.size() > 1;
|
||||
double indent = (li < lineCont.size() && lineCont[li]) ? hangingIndent : 0.0;
|
||||
double effCol = columnWidth - indent;
|
||||
bool segEnd = (li < lineSegEnd.size()) ? (lineSegEnd[li] != 0) : (li + 1 == lines.size());
|
||||
bool justifyThis = (align == "justify") && !segEnd && lw.size() > 1;
|
||||
double extraPerGap = 0.0;
|
||||
if (justifyThis) { double slack = columnWidth - naturalW; if (slack > 0) extraPerGap = slack / static_cast<double>(lw.size() - 1); }
|
||||
if (justifyThis) { double slack = effCol - naturalW; if (slack > 0) extraPerGap = slack / static_cast<double>(lw.size() - 1); }
|
||||
|
||||
std::string lineText;
|
||||
std::vector<double> adv;
|
||||
double lineFontSize = 0.0;
|
||||
double x = (li < lineX.size()) ? lineX[li] : columnLeft;
|
||||
double x = (li < lineX.size()) ? lineX[li] : (columnLeft + indent);
|
||||
if (li >= lineX.size()) {
|
||||
if (align == "right") x = columnLeft + (columnWidth - naturalW);
|
||||
else if (align == "center") x = columnLeft + (columnWidth - naturalW) / 2.0;
|
||||
if (align == "right") x = columnLeft + indent + (effCol - naturalW);
|
||||
else if (align == "center") x = columnLeft + indent + (effCol - naturalW) / 2.0;
|
||||
}
|
||||
const double lineStartX = x;
|
||||
for (size_t k = 0; k < lw.size(); ++k) {
|
||||
|
||||
@@ -176,7 +176,22 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_replaceText(const nlohm
|
||||
|
||||
bool fontSupportsAll = true;
|
||||
double totalWidth = 0.0;
|
||||
|
||||
|
||||
std::shared_ptr<fonts::FontFace> measureFace;
|
||||
{
|
||||
std::vector<uint8_t> mb;
|
||||
if (auto perObj = getFontDataFromObjects(pageIndex, objectIndices, internalFontId);
|
||||
perObj.has_value() && !perObj->empty()) mb = std::move(*perObj);
|
||||
else if (!internalFontId.empty()) {
|
||||
if (auto fd = getFontData(internalFontId); fd.has_value() && !fd->empty())
|
||||
mb = std::move(fd.value());
|
||||
}
|
||||
if (!mb.empty()) {
|
||||
auto mf = std::make_shared<fonts::FontFace>();
|
||||
if (mf->loadFromMemory(mb)) measureFace = mf;
|
||||
}
|
||||
}
|
||||
|
||||
auto utf16 = utf8_to_utf16le(newText);
|
||||
std::vector<uint32_t> unicodeCodepoints;
|
||||
for (size_t i = 0; i < utf16.size(); ) {
|
||||
@@ -214,23 +229,25 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_replaceText(const nlohm
|
||||
}
|
||||
|
||||
bool shapedSuccessful = false;
|
||||
if (resolvedFont) {
|
||||
constexpr unsigned int kRefMeasure = 1000;
|
||||
fonts::FontFace* mface = measureFace ? measureFace.get()
|
||||
: (resolvedFont ? &resolvedFont->getFontFace() : nullptr);
|
||||
if (mface) {
|
||||
try {
|
||||
fonts::HbShaper shaper;
|
||||
unsigned int uFontSize = static_cast<unsigned int>(fontSize > 0.0 ? fontSize : 12.0);
|
||||
auto shapedGlyphs = shaper.shapeRun(newText, resolvedFont->getFontFace(), uFontSize);
|
||||
auto shapedGlyphs = shaper.shapeRun(newText, *mface, kRefMeasure);
|
||||
if (!shapedGlyphs.empty()) {
|
||||
totalWidth = 0.0;
|
||||
for (const auto& sg : shapedGlyphs) {
|
||||
totalWidth += sg.advanceX;
|
||||
}
|
||||
double sum = 0.0;
|
||||
for (const auto& sg : shapedGlyphs) sum += sg.advanceX;
|
||||
totalWidth = sum * (fontSize > 0.0 ? fontSize : 12.0) / static_cast<double>(kRefMeasure);
|
||||
shapedSuccessful = true;
|
||||
spdlog::info("Font Engine: HarfBuzz shaped '{}' glyphs, total advance width = {}", shapedGlyphs.size(), totalWidth);
|
||||
spdlog::info("Font Engine: measured {} chars -> width {:.2f} ({} face)",
|
||||
newText.size(), totalWidth, measureFace ? "embedded" : "resolved");
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
spdlog::warn("Font Engine: HarfBuzz shaping failed: {}", e.what());
|
||||
spdlog::warn("Font Engine: shaping failed: {}", e.what());
|
||||
} catch (...) {
|
||||
spdlog::warn("Font Engine: HarfBuzz shaping failed with unknown exception");
|
||||
spdlog::warn("Font Engine: shaping failed (unknown)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +286,9 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_replaceText(const nlohm
|
||||
|
||||
bool axisAligned = (std::abs(b) < 1e-6 && std::abs(c) < 1e-6 && a > 0.0 && d > 0.0);
|
||||
double colRight = origRight;
|
||||
bool sawSibling = false;
|
||||
bool sawSibling = false;
|
||||
bool hasFollowingText = false;
|
||||
double rowTolFollow = (std::max)(4.0, (origTop - origBottom) * 0.6);
|
||||
if (axisAligned && hasOrigBounds) {
|
||||
int nObjForCol = FPDFPage_CountObjects(page);
|
||||
for (int k = 0; k < nObjForCol; ++k) {
|
||||
@@ -279,44 +298,16 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_replaceText(const nlohm
|
||||
float l = 0, bo = 0, rr = 0, tt = 0;
|
||||
if (!FPDFPageObj_GetBounds(o, &l, &bo, &rr, &tt)) continue;
|
||||
if (std::abs(l - origLeft) <= 3.0) { sawSibling = true; if (rr > colRight) colRight = rr; }
|
||||
if (std::abs((bo + tt) / 2.0 - origCenterY) <= rowTolFollow && l > origRight + 1.0)
|
||||
hasFollowingText = true;
|
||||
}
|
||||
}
|
||||
double justifyTol = (std::max)(4.0, (colRight - origLeft) * 0.02);
|
||||
bool wasJustified = !disableJustify && axisAligned && resolvedFont && hasOrigBounds && sawSibling &&
|
||||
!hasFollowingText &&
|
||||
(colRight - origLeft) > 20.0 && (origRight >= colRight - justifyTol);
|
||||
|
||||
double deltaX = 0.0;
|
||||
if (hasOrigBounds) {
|
||||
deltaX = totalWidth - origWidth;
|
||||
spdlog::info("Reflow Engine: origWidth = {}, newWidth = {}, deltaX = {}", origWidth, totalWidth, deltaX);
|
||||
}
|
||||
|
||||
if (!wasJustified && !disableJustify && hasOrigBounds && std::abs(deltaX) > 0.001) {
|
||||
int pageObjCount = FPDFPage_CountObjects(page);
|
||||
double tolerance = (std::max)(5.0, fontSize * 0.5);
|
||||
int reflowedCount = 0;
|
||||
|
||||
for (int k = 0; k < pageObjCount; ++k) {
|
||||
if (std::find(objectIndices.begin(), objectIndices.end(), k) != objectIndices.end()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
FPDF_PAGEOBJECT otherObj = FPDFPage_GetObject(page, k);
|
||||
if (otherObj && FPDFPageObj_GetType(otherObj) == FPDF_PAGEOBJ_TEXT) {
|
||||
float otherLeft = 0.0f, otherBottom = 0.0f, otherRight = 0.0f, otherTop = 0.0f;
|
||||
if (FPDFPageObj_GetBounds(otherObj, &otherLeft, &otherBottom, &otherRight, &otherTop)) {
|
||||
double otherCenterY = (otherBottom + otherTop) / 2.0;
|
||||
if (std::abs(otherCenterY - origCenterY) <= tolerance) {
|
||||
if (otherLeft >= (origRight - 2.0f)) {
|
||||
FPDFPageObj_Transform(otherObj, 1.0, 0.0, 0.0, 1.0, deltaX, 0.0);
|
||||
reflowedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
spdlog::info("Reflow Engine: shifted {} subsequent text objects on the same line by {}", reflowedCount, deltaX);
|
||||
}
|
||||
bool doSiblingShift = !wasJustified && hasOrigBounds;
|
||||
|
||||
for (int idx : objectIndices) {
|
||||
FPDF_PAGEOBJECT objToRemove = FPDFPage_GetObject(page, idx);
|
||||
@@ -358,6 +349,7 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_replaceText(const nlohm
|
||||
|
||||
FPDF_FONT reconFont = nullptr;
|
||||
const fonts::pdf_fonts::ReconstructedFont* reconRf = nullptr;
|
||||
bool reconFullyCovered = false;
|
||||
if (matchedFontInfo && matchedFontInfo->isEmbedded && classifyFontFidelity(*matchedFontInfo) != "exact") {
|
||||
reconRf = lookupReconFont(matchedFontInfo->internalFontId);
|
||||
if (reconRf && reconRf->ok) {
|
||||
@@ -371,13 +363,9 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_replaceText(const nlohm
|
||||
if (reconFont) loadedFontsCache_[rkey] = reconFont;
|
||||
}
|
||||
if (reconFont) {
|
||||
bool fullyCovered = true;
|
||||
reconFullyCovered = true;
|
||||
for (uint32_t cp : unicodeCodepoints)
|
||||
if (cp >= 0x20 && !reconRf->coveredUnicode.count(cp)) { fullyCovered = false; break; }
|
||||
if (fullyCovered && !font) {
|
||||
font = reconFont;
|
||||
spdlog::info("Tier-2: replace_text exact reconstructed font '{}'", matchedFontInfo->internalFontId);
|
||||
}
|
||||
if (cp >= 0x20 && !reconRf->coveredUnicode.count(cp)) { reconFullyCovered = false; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -419,6 +407,11 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_replaceText(const nlohm
|
||||
}
|
||||
}
|
||||
|
||||
if (!font && reconFont && reconFullyCovered) {
|
||||
font = reconFont;
|
||||
spdlog::info("Tier-2: replace_text reconstructed fallback '{}'", matchedFontInfo->internalFontId);
|
||||
}
|
||||
|
||||
if (!font) {
|
||||
spdlog::info("Font Engine: Loading standard PDF font for replace_text: {}", fontName);
|
||||
font = FPDFText_LoadStandardFont(doc_, fontName.c_str());
|
||||
@@ -432,7 +425,10 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_replaceText(const nlohm
|
||||
loadedFontsCache_[cacheKey] = font;
|
||||
}
|
||||
}
|
||||
|
||||
if (font && useEmbedded) reconFont = nullptr;
|
||||
|
||||
std::vector<FPDF_PAGEOBJECT> emittedObjs;
|
||||
if (font) {
|
||||
constexpr unsigned int kRef = 1000;
|
||||
double emToPage = (fontSize > 0.0 ? fontSize : 1.0) * a / static_cast<double>(kRef);
|
||||
@@ -495,6 +491,7 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_replaceText(const nlohm
|
||||
FPDFText_SetText(wobj, reinterpret_cast<FPDF_WIDESTRING>(wu.data()));
|
||||
FPDFPageObj_Transform(wobj, a, b, c, d, penX, f);
|
||||
FPDFPage_InsertObjectAtIndex(page, wobj, minIndex);
|
||||
emittedObjs.push_back(wobj);
|
||||
}
|
||||
penX += (wpx[wi] + estSpace) * k + extraPerGap;
|
||||
}
|
||||
@@ -547,6 +544,7 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_replaceText(const nlohm
|
||||
FPDFText_SetText(obj, reinterpret_cast<FPDF_WIDESTRING>(seg.data()));
|
||||
FPDFPageObj_Transform(obj, a, b, c, d, penX, f);
|
||||
FPDFPage_InsertObjectAtIndex(page, obj, minIndex);
|
||||
emittedObjs.push_back(obj);
|
||||
}
|
||||
double adv = segWidthPage(segFace, seg8);
|
||||
if (adv <= 0.0) { // shaping unavailable -> fall back to ink bbox
|
||||
@@ -588,6 +586,7 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_replaceText(const nlohm
|
||||
FPDFPageObj_Transform(newTextObj, aScale, b, c, d, e, f);
|
||||
|
||||
FPDFPage_InsertObjectAtIndex(page, newTextObj, minIndex);
|
||||
emittedObjs.push_back(newTextObj);
|
||||
} else {
|
||||
spdlog::error("Failed to create new text object");
|
||||
}
|
||||
@@ -595,6 +594,28 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_replaceText(const nlohm
|
||||
}
|
||||
}
|
||||
|
||||
if (doSiblingShift) {
|
||||
double shift = totalWidth - origWidth;
|
||||
if (std::abs(shift) > 0.05) {
|
||||
double rowTol = (std::max)(5.0, fontSize * 0.5);
|
||||
int nObj = FPDFPage_CountObjects(page);
|
||||
int moved = 0;
|
||||
for (int k = 0; k < nObj; ++k) {
|
||||
FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, k);
|
||||
if (!o || FPDFPageObj_GetType(o) != FPDF_PAGEOBJ_TEXT) continue;
|
||||
if (std::find(emittedObjs.begin(), emittedObjs.end(), o) != emittedObjs.end()) continue;
|
||||
float l = 0, bo = 0, rr = 0, tt = 0;
|
||||
if (!FPDFPageObj_GetBounds(o, &l, &bo, &rr, &tt)) continue;
|
||||
if (std::abs((bo + tt) / 2.0 - origCenterY) <= rowTol && l >= origRight - 2.0f) {
|
||||
FPDFPageObj_Transform(o, 1.0, 0.0, 0.0, 1.0, shift, 0.0);
|
||||
moved++;
|
||||
}
|
||||
}
|
||||
spdlog::info("replace_text: sibling shift {} obj by {:.2f} (deltaX = newW {:.2f} - origW {:.2f})",
|
||||
moved, shift, totalWidth, origWidth);
|
||||
}
|
||||
}
|
||||
|
||||
if (!FPDFPage_GenerateContent(page)) {
|
||||
spdlog::error("Failed to generate page content after replace_text");
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
|
||||
cacheKey += "#" + std::to_string(h);
|
||||
}
|
||||
|
||||
if (useEmbedded && reuseFont && !isSubsetFont) {
|
||||
if (useEmbedded && reuseFont) {
|
||||
out.font = reuseFont;
|
||||
if (auto perObj = getFontDataFromObjects(pageIndex, srcObjects, internalFontId);
|
||||
perObj.has_value() && !perObj->empty()) {
|
||||
@@ -150,35 +150,6 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
|
||||
}
|
||||
if (out.font) return out;
|
||||
|
||||
if (matchedFontInfo && matchedFontInfo->isEmbedded && classifyFontFidelity(*matchedFontInfo) != "exact") {
|
||||
const auto* rf = lookupReconFont(internalFontId);
|
||||
if (rf && rf->ok) {
|
||||
bool covered = true;
|
||||
for (uint32_t cp : codepoints) {
|
||||
if (cp >= 0x20 && !rf->coveredUnicode.count(cp)) { covered = false; break; }
|
||||
}
|
||||
if (covered) {
|
||||
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
||||
const std::string rkey = "recon_" + internalFontId;
|
||||
if (loadedFontsCache_.count(rkey)) {
|
||||
out.font = loadedFontsCache_[rkey];
|
||||
auto mit = loadedMeasureFaces_.find(rkey);
|
||||
if (mit != loadedMeasureFaces_.end()) out.measureFace = mit->second;
|
||||
} else {
|
||||
loadedFontDataBuffers_[rkey] = rf->sfnt;
|
||||
const auto& bytes = loadedFontDataBuffers_[rkey];
|
||||
out.font = FPDFText_LoadFont(doc_, bytes.data(), static_cast<uint32_t>(bytes.size()), FPDF_FONT_TRUETYPE, true);
|
||||
if (out.font) {
|
||||
loadedFontsCache_[rkey] = out.font;
|
||||
auto mf = std::make_shared<fonts::FontFace>();
|
||||
if (mf->loadFromMemory(bytes)) { loadedMeasureFaces_[rkey] = mf; out.measureFace = mf; }
|
||||
}
|
||||
}
|
||||
if (out.font) { spdlog::info("Tier-2: emit reconstructed embedded font '{}'", internalFontId); return out; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (useEmbedded) {
|
||||
std::vector<uint8_t> sourceBytes;
|
||||
if (auto perObj = getFontDataFromObjects(pageIndex, srcObjects, matchedFontInfo->internalFontId);
|
||||
@@ -208,6 +179,36 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!out.font && matchedFontInfo && matchedFontInfo->isEmbedded &&
|
||||
classifyFontFidelity(*matchedFontInfo) != "exact") {
|
||||
const auto* rf = lookupReconFont(internalFontId);
|
||||
if (rf && rf->ok) {
|
||||
bool covered = true;
|
||||
for (uint32_t cp : codepoints) {
|
||||
if (cp >= 0x20 && !rf->coveredUnicode.count(cp)) { covered = false; break; }
|
||||
}
|
||||
if (covered) {
|
||||
std::lock_guard<std::mutex> lock(loadedFontsMutex_);
|
||||
const std::string rkey = "recon_" + internalFontId;
|
||||
if (loadedFontsCache_.count(rkey)) {
|
||||
out.font = loadedFontsCache_[rkey];
|
||||
auto mit = loadedMeasureFaces_.find(rkey);
|
||||
if (mit != loadedMeasureFaces_.end()) out.measureFace = mit->second;
|
||||
} else {
|
||||
loadedFontDataBuffers_[rkey] = rf->sfnt;
|
||||
const auto& bytes = loadedFontDataBuffers_[rkey];
|
||||
out.font = FPDFText_LoadFont(doc_, bytes.data(), static_cast<uint32_t>(bytes.size()), FPDF_FONT_TRUETYPE, true);
|
||||
if (out.font) {
|
||||
loadedFontsCache_[rkey] = out.font;
|
||||
auto mf = std::make_shared<fonts::FontFace>();
|
||||
if (mf->loadFromMemory(bytes)) { loadedMeasureFaces_[rkey] = mf; out.measureFace = mf; }
|
||||
}
|
||||
}
|
||||
if (out.font) { spdlog::info("Tier-2: emit reconstructed embedded font '{}' (faithful load unavailable)", internalFontId); return out; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!out.font) {
|
||||
out.font = FPDFText_LoadStandardFont(doc_, fontName.c_str());
|
||||
if (!out.font) out.font = FPDFText_LoadStandardFont(doc_, "Helvetica");
|
||||
|
||||
@@ -5,10 +5,35 @@
|
||||
#include <qpdf/QPDFWriter.hh>
|
||||
#include <qpdf/QPDFPageDocumentHelper.hh>
|
||||
#include <qpdf/QPDFPageObjectHelper.hh>
|
||||
#include <qpdf/QPDFObjectHandle.hh>
|
||||
#include <qpdf/Pl_Buffer.hh>
|
||||
#include <qpdf/Buffer.hh>
|
||||
#include <algorithm>
|
||||
#endif
|
||||
|
||||
namespace pdfengine::qpdf_layer {
|
||||
|
||||
#ifdef PDFENGINE_WITH_QPDF
|
||||
namespace {
|
||||
class QQDepthCounter : public QPDFObjectHandle::ParserCallbacks {
|
||||
public:
|
||||
int depth = 0;
|
||||
int minDepth = 0;
|
||||
void handleObject(QPDFObjectHandle obj, size_t, size_t) override {
|
||||
if (!obj.isOperator()) return;
|
||||
const std::string op = obj.getOperatorValue();
|
||||
if (op == "q") {
|
||||
++depth;
|
||||
} else if (op == "Q") {
|
||||
--depth;
|
||||
if (depth < minDepth) minDepth = depth;
|
||||
}
|
||||
}
|
||||
void handleEOF() override {}
|
||||
};
|
||||
}
|
||||
#endif
|
||||
|
||||
std::expected<void, std::string>
|
||||
QpdfWriter::replacePageStreamAndSave(const std::string& sourcePath,
|
||||
const std::string& destPath,
|
||||
@@ -46,4 +71,98 @@ QpdfWriter::replacePageStreamAndSave(const std::string& sourcePath,
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<std::optional<std::vector<uint8_t>>, std::string>
|
||||
QpdfWriter::rebalanceContentStreams(const std::vector<uint8_t>& pdfBytes,
|
||||
const std::vector<int>& pageIndices) const {
|
||||
#ifndef PDFENGINE_WITH_QPDF
|
||||
(void)pdfBytes; (void)pageIndices;
|
||||
return std::unexpected("QPDF support is not enabled in this build.");
|
||||
#else
|
||||
try {
|
||||
QPDF pdf;
|
||||
pdf.processMemoryFile("rebalance.pdf",
|
||||
reinterpret_cast<const char*>(pdfBytes.data()),
|
||||
pdfBytes.size());
|
||||
|
||||
QPDFPageDocumentHelper pdh(pdf);
|
||||
auto pages = pdh.getAllPages();
|
||||
const int pageCount = static_cast<int>(pages.size());
|
||||
|
||||
std::vector<int> targets = pageIndices;
|
||||
if (targets.empty()) {
|
||||
for (int i = 0; i < pageCount; ++i) targets.push_back(i);
|
||||
}
|
||||
|
||||
bool changed = false;
|
||||
for (int idx : targets) {
|
||||
if (idx < 0 || idx >= pageCount) continue;
|
||||
QPDFPageObjectHelper& page = pages[static_cast<size_t>(idx)];
|
||||
|
||||
QQDepthCounter counter;
|
||||
page.parsePageContents(&counter);
|
||||
if (counter.minDepth >= 0) continue;
|
||||
|
||||
const int n = -counter.minDepth;
|
||||
Pl_Buffer collected("page-contents");
|
||||
page.pipePageContents(&collected);
|
||||
collected.finish();
|
||||
auto raw = collected.getBufferSharedPointer();
|
||||
std::string content(reinterpret_cast<const char*>(raw->getBuffer()), raw->getSize());
|
||||
|
||||
std::string wrapped;
|
||||
for (int k = 0; k < n; ++k) wrapped += "q\n";
|
||||
wrapped.append(content);
|
||||
wrapped.push_back('\n');
|
||||
for (int k = 0; k < n; ++k) wrapped += "Q\n";
|
||||
|
||||
QPDFObjectHandle newStream = QPDFObjectHandle::newStream(&pdf, wrapped);
|
||||
page.getObjectHandle().replaceKey("/Contents", newStream);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) return std::nullopt;
|
||||
|
||||
QPDFWriter writer(pdf);
|
||||
writer.setOutputMemory();
|
||||
writer.setStreamDataMode(qpdf_s_preserve);
|
||||
writer.write();
|
||||
auto out = writer.getBufferSharedPointer();
|
||||
return std::vector<uint8_t>(out->getBuffer(), out->getBuffer() + out->getSize());
|
||||
} catch (const std::exception& e) {
|
||||
return std::unexpected(std::string("QPDF rebalance error: ") + e.what());
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<std::vector<uint8_t>, std::string>
|
||||
QpdfWriter::setNeedAppearances(const std::vector<uint8_t>& pdfBytes) const {
|
||||
#ifndef PDFENGINE_WITH_QPDF
|
||||
(void)pdfBytes;
|
||||
return std::unexpected("QPDF support is not enabled in this build.");
|
||||
#else
|
||||
try {
|
||||
QPDF pdf;
|
||||
pdf.processMemoryFile("appearances.pdf",
|
||||
reinterpret_cast<const char*>(pdfBytes.data()),
|
||||
pdfBytes.size());
|
||||
|
||||
QPDFObjectHandle root = pdf.getRoot();
|
||||
if (!root.hasKey("/AcroForm")) return pdfBytes;
|
||||
QPDFObjectHandle acro = root.getKey("/AcroForm");
|
||||
if (!acro.isDictionary()) return pdfBytes;
|
||||
|
||||
acro.replaceKey("/NeedAppearances", QPDFObjectHandle::newBool(true));
|
||||
|
||||
QPDFWriter writer(pdf);
|
||||
writer.setOutputMemory();
|
||||
writer.setStreamDataMode(qpdf_s_preserve);
|
||||
writer.write();
|
||||
auto out = writer.getBufferSharedPointer();
|
||||
return std::vector<uint8_t>(out->getBuffer(), out->getBuffer() + out->getSize());
|
||||
} catch (const std::exception& e) {
|
||||
return std::unexpected(std::string("QPDF setNeedAppearances error: ") + e.what());
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <optional>
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
|
||||
class QPDF;
|
||||
@@ -14,10 +16,19 @@ public:
|
||||
|
||||
[[nodiscard]]
|
||||
std::expected<void, std::string>
|
||||
replacePageStreamAndSave(const std::string& sourcePath,
|
||||
const std::string& destPath,
|
||||
int pageIndex,
|
||||
replacePageStreamAndSave(const std::string& sourcePath,
|
||||
const std::string& destPath,
|
||||
int pageIndex,
|
||||
const std::string& newStreamData) const;
|
||||
|
||||
[[nodiscard]]
|
||||
std::expected<std::optional<std::vector<uint8_t>>, std::string>
|
||||
rebalanceContentStreams(const std::vector<uint8_t>& pdfBytes,
|
||||
const std::vector<int>& pageIndices) const;
|
||||
|
||||
[[nodiscard]]
|
||||
std::expected<std::vector<uint8_t>, std::string>
|
||||
setNeedAppearances(const std::vector<uint8_t>& pdfBytes) const;
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -262,6 +262,10 @@ export interface ReflowParagraphData {
|
||||
lines?: ReflowFragment[][];
|
||||
lineBaselineY?: number[];
|
||||
lineX?: number[];
|
||||
hangingIndent?: number;
|
||||
listMarker?: string;
|
||||
listKind?: 'bullet' | 'ordered';
|
||||
listLevel?: number;
|
||||
}
|
||||
|
||||
export interface DeleteAnnotationData {
|
||||
|
||||
@@ -18,7 +18,7 @@ function getModule(): Promise<PdfiumModule | null> {
|
||||
if (!modulePromise) {
|
||||
modulePromise = (async () => {
|
||||
try {
|
||||
const V = '20260626-auxfont';
|
||||
const V = '20260630-fontfix3';
|
||||
const resp = await fetch(`/pdfium-engine.mjs?v=${V}`, { cache: 'no-store' });
|
||||
if (!resp.ok) throw new Error(`pdfium-engine.mjs ${resp.status}`);
|
||||
const blobUrl = URL.createObjectURL(new Blob([await resp.text()], { type: 'text/javascript' }));
|
||||
|
||||
@@ -153,32 +153,117 @@ function resolveStyleEl(node: Text, root: HTMLElement): HTMLElement | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const BLOCK_TAGS = /^(DIV|P|LI|BLOCKQUOTE|PRE)$/;
|
||||
|
||||
function extractFlatRuns(editable: HTMLElement, dominantFid: string, domSize: number, domColor: string): ReflowFragment[] {
|
||||
const out: ReflowFragment[] = [];
|
||||
const walker = document.createTreeWalker(editable, NodeFilter.SHOW_TEXT);
|
||||
let node = walker.nextNode() as Text | null;
|
||||
while (node) {
|
||||
const styleEl = resolveStyleEl(node, editable);
|
||||
const fid = styleEl?.getAttribute('data-fid') || dominantFid;
|
||||
const size = parseFloat(styleEl?.getAttribute('data-size') ?? '') || domSize;
|
||||
const color = styleEl?.getAttribute('data-color') ?? domColor;
|
||||
const text = node.textContent ?? '';
|
||||
if (text) {
|
||||
const frag: ReflowFragment = { text, internalFontId: fid, fontSize: size, color };
|
||||
const aRaw = node.parentElement === styleEl ? styleEl?.getAttribute('data-advances') : null;
|
||||
if (aRaw) {
|
||||
try {
|
||||
const a = JSON.parse(aRaw) as number[];
|
||||
if (Array.isArray(a) && a.length === text.length) frag.advances = a;
|
||||
} catch { }
|
||||
const pushBreak = () => {
|
||||
if (!out.length) return; // ignore a leading break (no preceding line to end)
|
||||
const prev = out[out.length - 1];
|
||||
if (prev.text === '\n') return; // collapse consecutive structural breaks
|
||||
out.push({ text: '\n', internalFontId: prev.internalFontId, fontSize: prev.fontSize, color: prev.color });
|
||||
};
|
||||
const walk = (node: Node) => {
|
||||
for (let child = node.firstChild; child; child = child.nextSibling) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const raw = child.textContent ?? '';
|
||||
if (!raw) continue;
|
||||
const styleEl = resolveStyleEl(child as Text, editable);
|
||||
const fid = styleEl?.getAttribute('data-fid') || dominantFid;
|
||||
const size = parseFloat(styleEl?.getAttribute('data-size') ?? '') || domSize;
|
||||
const color = styleEl?.getAttribute('data-color') ?? domColor;
|
||||
const parts = raw.split('\n');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (i > 0) pushBreak();
|
||||
const text = parts[i];
|
||||
if (!text) continue;
|
||||
const frag: ReflowFragment = { text, internalFontId: fid, fontSize: size, color };
|
||||
const aRaw = (child as Text).parentElement === styleEl ? styleEl?.getAttribute('data-advances') : null;
|
||||
if (aRaw) {
|
||||
try {
|
||||
const a = JSON.parse(aRaw) as number[];
|
||||
if (Array.isArray(a) && a.length === text.length) frag.advances = a;
|
||||
} catch { }
|
||||
}
|
||||
out.push(frag);
|
||||
}
|
||||
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = child as HTMLElement;
|
||||
if (el.tagName === 'BR') { pushBreak(); continue; }
|
||||
if (BLOCK_TAGS.test(el.tagName)) pushBreak(); // a block boundary is a line break
|
||||
walk(el);
|
||||
}
|
||||
out.push(frag);
|
||||
}
|
||||
node = walker.nextNode() as Text | null;
|
||||
}
|
||||
};
|
||||
walk(editable);
|
||||
return out;
|
||||
}
|
||||
|
||||
function domTextWithBreaks(editable: HTMLElement): string {
|
||||
let s = '';
|
||||
const walk = (node: Node) => {
|
||||
for (let child = node.firstChild; child; child = child.nextSibling) {
|
||||
if (child.nodeType === Node.TEXT_NODE) s += child.textContent ?? '';
|
||||
else if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = child as HTMLElement;
|
||||
if (el.tagName === 'BR') s += '\n';
|
||||
else { if (BLOCK_TAGS.test(el.tagName) && s && !s.endsWith('\n')) s += '\n'; walk(el); }
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(editable);
|
||||
return s;
|
||||
}
|
||||
|
||||
function normalizeForCompare(s: string): string {
|
||||
return s.replace(/[ \t]+/g, ' ').replace(/ *\n */g, '\n').replace(/\n+$/, '').replace(/^\n+/, '').trim();
|
||||
}
|
||||
|
||||
export type ListKind = 'bullet' | 'ordered';
|
||||
|
||||
function splitSegmentsByBreak(runs: ReflowFragment[]): ReflowFragment[][] {
|
||||
const segs: ReflowFragment[][] = [[]];
|
||||
for (const r of runs) {
|
||||
const parts = r.text.split('\n');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (i > 0) segs.push([]);
|
||||
if (parts[i]) segs[segs.length - 1].push({ ...r, text: parts[i] });
|
||||
}
|
||||
}
|
||||
return segs;
|
||||
}
|
||||
|
||||
let markerMeasureCanvas: HTMLCanvasElement | null = null;
|
||||
function measureTextWidth(text: string, sizePt: number, family: string): number {
|
||||
if (!markerMeasureCanvas) markerMeasureCanvas = document.createElement('canvas');
|
||||
const ctx = markerMeasureCanvas.getContext('2d');
|
||||
if (!ctx) return sizePt;
|
||||
ctx.font = `${sizePt}px ${family}`;
|
||||
return ctx.measureText(text).width;
|
||||
}
|
||||
|
||||
function applyListMarkers(
|
||||
runs: ReflowFragment[],
|
||||
kind: ListKind,
|
||||
dom: { fid: string; size: number; color: string; family: string },
|
||||
): { runs: ReflowFragment[]; hangingIndent: number; marker: string } {
|
||||
const segs = splitSegmentsByBreak(runs);
|
||||
let n = 0;
|
||||
let lastMarker = kind === 'ordered' ? '1. ' : '• ';
|
||||
const out: ReflowFragment[] = [];
|
||||
segs.forEach((seg, si) => {
|
||||
if (si > 0) out.push({ text: '\n', internalFontId: dom.fid, fontSize: dom.size, color: dom.color });
|
||||
if (!seg.some((r) => r.text.trim())) { out.push(...seg); return; }
|
||||
n++;
|
||||
const marker = kind === 'ordered' ? `${n}. ` : '• ';
|
||||
lastMarker = marker;
|
||||
out.push({ text: marker, internalFontId: dom.fid, fontSize: dom.size, color: dom.color });
|
||||
out.push(...seg);
|
||||
});
|
||||
const hangingIndent = measureTextWidth(lastMarker, dom.size, dom.family);
|
||||
return { runs: out, hangingIndent, marker: lastMarker };
|
||||
}
|
||||
|
||||
function globalCaretOffset(el: HTMLElement): number {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return 0;
|
||||
@@ -256,6 +341,9 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
const [caretBox, setCaretBox] = useState<{ left: number; top: number; height: number } | null>(null);
|
||||
const [edited, setEdited] = useState(false);
|
||||
const [wasmFailed, setWasmFailed] = useState(false);
|
||||
const [listKind, setListKind] = useState<ListKind | null>(null);
|
||||
const [indentLevel, setIndentLevel] = useState(0);
|
||||
const INDENT_STEP = 18; // pt per nesting level (whole-block indent; per-item nesting is a known limit)
|
||||
|
||||
const dominantFid = layout.seedRuns.find((r) => r.text.trim() && r.fid)?.fid
|
||||
?? layout.seedRuns.find((r) => r.fid)?.fid ?? '';
|
||||
@@ -272,8 +360,22 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
const editorTop = firstBaselineScreen - (leadingPx + fontPx * 0.7) / 2;
|
||||
const bandTop = Math.max(0, Math.min(editorTop - leadingPx * 0.5, firstBaselineScreen - fontPx * 1.15));
|
||||
|
||||
const measureFamily = /times|serif/i.test(domFontName) ? 'Times New Roman, serif'
|
||||
: /courier|mono/i.test(domFontName) ? 'Courier New, monospace' : 'Arial, sans-serif';
|
||||
|
||||
const buildReflowData = (runs: ReflowFragment[], origLines?: OrigLine[]) => {
|
||||
const linesData = origLines && origLines.length ? {
|
||||
const listActive = listKind !== null;
|
||||
let outRuns = runs;
|
||||
let effColumnLeft = columnLeft;
|
||||
let listFields: Record<string, unknown> = {};
|
||||
if (listActive) {
|
||||
const { runs: marked, hangingIndent, marker } = applyListMarkers(
|
||||
runs, listKind, { fid: dominantFid, size: domSize, color: domColor, family: measureFamily });
|
||||
outRuns = marked;
|
||||
effColumnLeft = columnLeft + indentLevel * INDENT_STEP;
|
||||
listFields = { hangingIndent, listKind, listLevel: indentLevel, listMarker: marker };
|
||||
}
|
||||
const linesData = (!listActive && origLines && origLines.length) ? {
|
||||
lines: origLines.map((l) => l.frags.map((f) => ({
|
||||
text: f.text, internalFontId: f.fid, fontSize: f.size, color: f.color,
|
||||
...(f.advances ? { advances: f.advances } : {}),
|
||||
@@ -283,13 +385,15 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
} : {};
|
||||
return {
|
||||
objectIndices: layout.objectIndices,
|
||||
runs: runs.length ? runs : [{ text: ' ', internalFontId: dominantFid, fontSize: domSize, color: '#000000' }],
|
||||
runs: outRuns.length ? outRuns : [{ text: ' ', internalFontId: dominantFid, fontSize: domSize, color: '#000000' }],
|
||||
...linesData,
|
||||
columnLeft, columnRight,
|
||||
columnLeft: effColumnLeft, columnRight,
|
||||
pushColumnLeft: pushColumnLeft ?? columnLeft,
|
||||
firstBaselineY: layout.firstBaselineY, leading,
|
||||
oldLineCount: layout.oldLineCount, align,
|
||||
oldLineCount: layout.oldLineCount,
|
||||
align: listActive ? 'left' : align,
|
||||
paraId: paraIdRef.current,
|
||||
...listFields,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -364,8 +468,6 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
const renderPreview = async () => {
|
||||
const el = editRef.current;
|
||||
if (!el) return;
|
||||
// Tier-2 parity: register cmap-augmented embedded fonts with WASM so the reflow preview
|
||||
// reuses the document's real glyphs (matching the native save).
|
||||
const fids = new Set<string>([dominantFid, ...layout.seedRuns.map((r) => r.fid)].filter(Boolean));
|
||||
await Promise.all([...fids].map((f) => wasmEnsureAuxFont(documentId, f)));
|
||||
const dpi = Math.round(72 * zoom);
|
||||
@@ -452,7 +554,7 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
el.appendChild(span);
|
||||
}
|
||||
el.focus();
|
||||
initialTextRef.current = (el.textContent ?? '').replace(/\s+/g, ' ').trim();
|
||||
initialTextRef.current = normalizeForCompare(domTextWithBreaks(el));
|
||||
}, []);
|
||||
|
||||
const fallbackVisible = wasmFailed && !hasPreview;
|
||||
@@ -500,8 +602,8 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
committedRef.current = true;
|
||||
const el = editRef.current;
|
||||
if (!el) { onCancel(); return; }
|
||||
const nowText = (el.textContent ?? '').replace(/\s+/g, ' ').trim();
|
||||
if (nowText === initialTextRef.current) { onCancel(); return; }
|
||||
const nowText = normalizeForCompare(domTextWithBreaks(el));
|
||||
if (nowText === initialTextRef.current && listKind === null) { onCancel(); return; }
|
||||
const flat = extractFlatRuns(el, dominantFid, domSize, domColor);
|
||||
if (flat.length === 0) { onCancel(); return; }
|
||||
const cv = previewCanvasRef.current;
|
||||
@@ -520,6 +622,34 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
};
|
||||
const cancel = () => { committedRef.current = true; onOverflowPreview?.([]); onOverflowCaret?.(null); onCancel(); };
|
||||
|
||||
const markEditedAndRender = () => {
|
||||
editedRef.current = true;
|
||||
if (!edited) setEdited(true);
|
||||
positionCaret();
|
||||
scheduleRender();
|
||||
};
|
||||
|
||||
const insertHardBreak = () => {
|
||||
const el = editRef.current;
|
||||
if (!el) return;
|
||||
document.execCommand('insertLineBreak'); // inserts a <br>; extractFlatRuns maps it to '\n'
|
||||
markEditedAndRender();
|
||||
};
|
||||
|
||||
const toggleList = (kind: ListKind) => {
|
||||
setListKind((prev) => (prev === kind ? null : kind));
|
||||
if (listKind === null) setIndentLevel(0);
|
||||
editRef.current?.focus();
|
||||
markEditedAndRender();
|
||||
};
|
||||
const changeIndent = (delta: number) => {
|
||||
setIndentLevel((lv) => Math.max(0, Math.min(5, lv + delta)));
|
||||
editRef.current?.focus();
|
||||
markEditedAndRender();
|
||||
};
|
||||
|
||||
useEffect(() => { if (edited) scheduleRender(); /* eslint-disable-next-line */ }, [listKind, indentLevel]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`@keyframes pe-caret-blink{0%,49%{opacity:1}50%,100%{opacity:0}}`}</style>
|
||||
@@ -545,6 +675,36 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{!fallbackVisible && (
|
||||
<div
|
||||
className="absolute z-[40] flex items-center gap-0.5 rounded-md border border-slate-200 bg-white px-1 py-0.5 shadow-md"
|
||||
style={{ left: colLeftPx, top: Math.max(0, editorTop - 34) }}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
title="Bullet list"
|
||||
onClick={() => toggleList('bullet')}
|
||||
className={`px-1.5 py-0.5 rounded text-sm leading-none ${listKind === 'bullet' ? 'bg-blue-100 text-blue-700' : 'text-slate-600 hover:bg-slate-100'}`}
|
||||
>• List</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Numbered list"
|
||||
onClick={() => toggleList('ordered')}
|
||||
className={`px-1.5 py-0.5 rounded text-sm leading-none ${listKind === 'ordered' ? 'bg-blue-100 text-blue-700' : 'text-slate-600 hover:bg-slate-100'}`}
|
||||
>1. List</button>
|
||||
{listKind !== null && (
|
||||
<>
|
||||
<span className="mx-0.5 h-4 w-px bg-slate-200" />
|
||||
<button type="button" title="Decrease indent (Shift+Tab)" onClick={() => changeIndent(-1)}
|
||||
className="px-1.5 py-0.5 rounded text-sm leading-none text-slate-600 hover:bg-slate-100 disabled:opacity-40" disabled={indentLevel === 0}>⇤</button>
|
||||
<button type="button" title="Increase indent (Tab)" onClick={() => changeIndent(1)}
|
||||
className="px-1.5 py-0.5 rounded text-sm leading-none text-slate-600 hover:bg-slate-100 disabled:opacity-40" disabled={indentLevel >= 5}>⇥</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={editRef}
|
||||
contentEditable
|
||||
@@ -561,8 +721,15 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
onKeyUp={positionCaret}
|
||||
onKeyDown={(e) => {
|
||||
if (e.nativeEvent.isComposing || composingRef.current) return;
|
||||
if (e.key === 'Enter') { e.preventDefault(); commit(); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) commit();
|
||||
else insertHardBreak();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault(); cancel();
|
||||
} else if (e.key === 'Tab' && listKind !== null) {
|
||||
e.preventDefault(); changeIndent(e.shiftKey ? -1 : 1);
|
||||
}
|
||||
}}
|
||||
onBlur={commit}
|
||||
onPaste={(e) => { e.preventDefault(); document.execCommand('insertText', false, e.clipboardData.getData('text/plain')); }}
|
||||
|
||||
@@ -52,6 +52,10 @@ export interface ReflowParagraphPayload {
|
||||
lines?: ReflowFragment[][];
|
||||
lineBaselineY?: number[];
|
||||
lineX?: number[];
|
||||
hangingIndent?: number;
|
||||
listMarker?: string;
|
||||
listKind?: 'bullet' | 'ordered';
|
||||
listLevel?: number;
|
||||
}
|
||||
|
||||
export function isBulletMarker(text?: string): boolean {
|
||||
@@ -166,7 +170,7 @@ function isFlowingParagraph(para: any): boolean {
|
||||
}
|
||||
|
||||
function tableCells(line: any): any[] {
|
||||
return (line?.runs ?? []).filter((r: any) => (r.text ?? '').trim());
|
||||
return (line?.runs ?? []).filter((r: any) => (r.text ?? '').trim() && !isBulletMarker(r.text));
|
||||
}
|
||||
function paraEm(para: any): number {
|
||||
let capH = 0;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from fastapi import APIRouter, HTTPException, Response, status
|
||||
|
||||
from app.services import engine
|
||||
from app.services.export import apply_need_appearances
|
||||
from app.services.store import document_store
|
||||
|
||||
router = APIRouter(tags=["documents"])
|
||||
@@ -27,13 +26,11 @@ def export_document(document_id: str):
|
||||
|
||||
try:
|
||||
doc = d["doc_instance"]
|
||||
bytes_data = doc.save_full()
|
||||
bytes_data = doc.save_full_for_export()
|
||||
filename = d["filename"]
|
||||
if not filename.endswith(".pdf"):
|
||||
filename += ".pdf"
|
||||
|
||||
bytes_data = apply_need_appearances(bytes_data)
|
||||
|
||||
return Response(
|
||||
content=bytes_data,
|
||||
media_type="application/pdf",
|
||||
|
||||
@@ -246,6 +246,10 @@ class ReflowParagraphData(BaseModel):
|
||||
lines: list[list[ReflowRun]] | None = None
|
||||
lineBaselineY: list[float] | None = None
|
||||
lineX: list[float] | None = None
|
||||
hangingIndent: float = 0.0
|
||||
listMarker: str | None = None
|
||||
listKind: Literal["bullet", "ordered"] | None = None
|
||||
listLevel: int | None = None
|
||||
|
||||
|
||||
class ReflowParagraphOperation(BaseModel):
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
"""Export pipeline: full-save + a pypdf pass that sets /NeedAppearances.
|
||||
|
||||
NOTE: ``pypdf`` is intentionally imported lazily inside the function. It is not a
|
||||
declared gateway dependency and may only be resolvable via the roaming
|
||||
site-packages path appended below, so importing it at module load would prevent
|
||||
the whole app from starting on machines without it. Behavior here is preserved
|
||||
verbatim from the original ``routers.documents.export_document``; if pypdf is
|
||||
later added to ``pyproject.toml`` dependencies, the sys.path augmentation can be
|
||||
removed and the import hoisted to module scope.
|
||||
"""
|
||||
|
||||
|
||||
def apply_need_appearances(bytes_data: bytes) -> bytes:
|
||||
"""Round-trip the PDF through pypdf to set AcroForm /NeedAppearances=true."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
roaming_path = os.path.join(
|
||||
os.environ.get("APPDATA", "C:\\Users\\azeem\\AppData\\Roaming"),
|
||||
"Python",
|
||||
"Python312",
|
||||
"site-packages",
|
||||
)
|
||||
if roaming_path not in sys.path:
|
||||
sys.path.append(roaming_path)
|
||||
|
||||
import io
|
||||
|
||||
import pypdf
|
||||
|
||||
reader = pypdf.PdfReader(io.BytesIO(bytes_data))
|
||||
writer = pypdf.PdfWriter()
|
||||
writer.append(reader)
|
||||
|
||||
acro_form = writer.root_object.get("/AcroForm")
|
||||
if acro_form is not None:
|
||||
acro_form_dict = acro_form.get_object()
|
||||
acro_form_dict[pypdf.generic.NameObject("/NeedAppearances")] = pypdf.generic.BooleanObject(
|
||||
True
|
||||
)
|
||||
|
||||
out_stream = io.BytesIO()
|
||||
writer.write(out_stream)
|
||||
return out_stream.getvalue()
|
||||
@@ -43,11 +43,15 @@ target_include_directories(pdfengine_wasm PRIVATE
|
||||
|
||||
target_link_libraries(pdfengine_wasm PRIVATE pdfengine::pdfengine)
|
||||
|
||||
target_link_directories(pdfengine_wasm PRIVATE
|
||||
"${CMAKE_BINARY_DIR}/vcpkg_installed/wasm32-emscripten/lib")
|
||||
|
||||
set(_wasm_font_src "${CMAKE_SOURCE_DIR}/engine/assets/fonts")
|
||||
set(_wasm_font_dir "${CMAKE_BINARY_DIR}/embed_fonts")
|
||||
file(COPY "${_wasm_font_src}/" DESTINATION "${_wasm_font_dir}")
|
||||
|
||||
target_link_options(pdfengine_wasm PRIVATE
|
||||
"-Wl,--allow-multiple-definition"
|
||||
"--embed-file" "${_wasm_font_dir}@/fonts"
|
||||
"-sMODULARIZE=1"
|
||||
"-sEXPORT_ES6=1"
|
||||
|
||||
Reference in New Issue
Block a user