feat: updated text handling bugs
This commit is contained in:
+2
-1
@@ -119,7 +119,8 @@
|
|||||||
"cacheVariables": {
|
"cacheVariables": {
|
||||||
"CMAKE_BUILD_TYPE": "Release",
|
"CMAKE_BUILD_TYPE": "Release",
|
||||||
"PDFENGINE_BUILD_TESTS": "OFF",
|
"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) {
|
.def("save_full", [](const pdfengine::PdfDocument& self) {
|
||||||
std::vector<uint8_t> res = get_or_throw(self.saveFull());
|
std::vector<uint8_t> res = get_or_throw(self.saveFull());
|
||||||
return py::bytes(reinterpret_cast<const char*>(res.data()), res.size());
|
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>
|
[[nodiscard]] virtual std::expected<std::vector<uint8_t>, EngineError>
|
||||||
saveIncremental() const = 0;
|
saveIncremental() const = 0;
|
||||||
|
|
||||||
[[nodiscard]] virtual std::expected<std::vector<uint8_t>, EngineError>
|
[[nodiscard]] virtual std::expected<std::vector<uint8_t>, EngineError>
|
||||||
saveFull() const = 0;
|
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.h>
|
||||||
#include <hb-ft.h>
|
#include <hb-ft.h>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
|
#include <spdlog/spdlog.h>
|
||||||
|
#include <ft2build.h>
|
||||||
|
#include FT_FREETYPE_H
|
||||||
|
|
||||||
namespace pdfengine::fonts {
|
namespace pdfengine::fonts {
|
||||||
|
|
||||||
@@ -25,8 +28,13 @@ std::vector<ShapedGlyph> HbShaper::shapeRun(
|
|||||||
std::lock_guard<std::mutex> lock(font.getMutex());
|
std::lock_guard<std::mutex> lock(font.getMutex());
|
||||||
|
|
||||||
if (FT_Set_Pixel_Sizes(ftFace, 0, fontSize)) {
|
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;
|
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);
|
hb_font_t* hbFont = hb_ft_font_create_referenced(ftFace);
|
||||||
if (!hbFont) {
|
if (!hbFont) {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
#include "parser/pdfium_internal.hpp"
|
#include "parser/pdfium_internal.hpp"
|
||||||
|
|
||||||
|
#if defined(PDFENGINE_WITH_PDFIUM) && defined(PDFENGINE_WITH_QPDF)
|
||||||
|
#include "qpdf/qpdf_writer.hpp"
|
||||||
|
#endif
|
||||||
|
|
||||||
namespace pdfengine {
|
namespace pdfengine {
|
||||||
|
|
||||||
std::expected<std::shared_ptr<PdfDocument>, EngineError>
|
std::expected<std::shared_ptr<PdfDocument>, EngineError>
|
||||||
@@ -240,6 +244,20 @@ std::expected<std::vector<uint8_t>, EngineError> PdfiumDocument::saveFull() cons
|
|||||||
#endif
|
#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() {
|
void PdfiumDocument::invalidateCaches() {
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(fontsMutex_);
|
std::lock_guard<std::mutex> lock(fontsMutex_);
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ public:
|
|||||||
std::expected<void, EngineError> applyEdits(const std::string& editsJson) override;
|
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> saveIncremental() const override;
|
||||||
std::expected<std::vector<uint8_t>, EngineError> saveFull() 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_; }
|
std::string lastReflowLayout() const { return lastReflowLayout_; }
|
||||||
bool lastReflowOverflowed() const { return lastReflowOverflowed_; }
|
bool lastReflowOverflowed() const { return lastReflowOverflowed_; }
|
||||||
@@ -158,6 +159,8 @@ private:
|
|||||||
const fonts::pdf_fonts::ReconstructedFont* getReconstructedEmbeddedFont(const std::string& internalFontId);
|
const fonts::pdf_fonts::ReconstructedFont* getReconstructedEmbeddedFont(const std::string& internalFontId);
|
||||||
#endif
|
#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_replaceText(const nlohmann::json& op, int pageIndex);
|
||||||
std::expected<void, EngineError> applyOp_reflow(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);
|
std::expected<void, EngineError> applyOp_textOverlay(const nlohmann::json& op, int pageIndex);
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
#include "parser/pdfium_internal.hpp"
|
#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 {
|
namespace pdfengine::parser {
|
||||||
|
|
||||||
std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& editsJson) {
|
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();
|
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 {
|
try {
|
||||||
auto root = nlohmann::json::parse(editsJson);
|
auto root = nlohmann::json::parse(editsJson);
|
||||||
if (!root.contains("version") || root["version"] != "1.0") {
|
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);
|
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{};
|
std::expected<void, EngineError> r{};
|
||||||
if (type == "replace_text") {
|
if (type == "replace_text") {
|
||||||
r = applyOp_replaceText(op, pageIndex);
|
r = applyOp_replaceText(op, pageIndex);
|
||||||
@@ -78,6 +96,7 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
|||||||
return std::unexpected(EngineError::Unknown);
|
return std::unexpected(EngineError::Unknown);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rebalanceEditedPages(editedPages);
|
||||||
invalidateCaches();
|
invalidateCaches();
|
||||||
return {};
|
return {};
|
||||||
#else
|
#else
|
||||||
@@ -86,4 +105,34 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
|||||||
#endif
|
#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>());
|
for (const auto& v : data["lineX"]) lineX.push_back(v.get<double>());
|
||||||
double columnWidth = columnRight - columnLeft;
|
double columnWidth = columnRight - columnLeft;
|
||||||
double pushColumnLeft = data.value("pushColumnLeft", columnLeft);
|
double pushColumnLeft = data.value("pushColumnLeft", columnLeft);
|
||||||
|
double hangingIndent = data.value("hangingIndent", 0.0);
|
||||||
std::string paraId;
|
std::string paraId;
|
||||||
if (data.contains("paraId") && data["paraId"].is_string())
|
if (data.contains("paraId") && data["paraId"].is_string())
|
||||||
paraId = data["paraId"].get<std::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'; };
|
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 Seg { int runIdx; std::string text; double width; size_t off; };
|
||||||
struct Word { std::vector<Seg> segs; double width; double spaceAfter; };
|
struct Word { std::vector<Seg> segs; double width; double spaceAfter; };
|
||||||
std::vector<Word> words;
|
std::vector<Word> words;
|
||||||
@@ -291,42 +293,68 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_reflow(const nlohmann::
|
|||||||
std::vector<size_t> out;
|
std::vector<size_t> out;
|
||||||
size_t i = 0;
|
size_t i = 0;
|
||||||
while (i < ft.size()) {
|
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;
|
Word w; w.width = 0.0; w.spaceAfter = 0.0;
|
||||||
while (i < ft.size() && !isSpace(ft[i])) {
|
while (i < ft.size() && !isSpace(ft[i])) {
|
||||||
int st = sof[i]; size_t segOff = cof[i]; std::string frag; double fw = 0.0;
|
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++; }
|
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;
|
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());
|
out.push_back(words.size());
|
||||||
words.push_back(std::move(w));
|
words.push_back(std::move(w));
|
||||||
|
for (size_t n = 0; n < newlines; ++n) out.push_back(kHardBreak);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
|
|
||||||
std::vector<std::vector<size_t>> lines;
|
std::vector<std::vector<size_t>> lines;
|
||||||
|
std::vector<char> lineCont;
|
||||||
|
std::vector<char> lineSegEnd;
|
||||||
if (hasProvidedLines) {
|
if (hasProvidedLines) {
|
||||||
for (const auto& lineRunIdxs : providedLines) {
|
for (const auto& lineRunIdxs : providedLines) {
|
||||||
auto wi = tokenize(lineRunIdxs);
|
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 {
|
} else {
|
||||||
std::vector<int> allRuns(runs.size());
|
std::vector<int> allRuns(runs.size());
|
||||||
for (size_t ri = 0; ri < runs.size(); ++ri) allRuns[ri] = static_cast<int>(ri);
|
for (size_t ri = 0; ri < runs.size(); ++ri) allRuns[ri] = static_cast<int>(ri);
|
||||||
auto allWords = tokenize(allRuns);
|
auto allWords = tokenize(allRuns);
|
||||||
std::vector<size_t> cur; double curW = 0.0;
|
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) {
|
for (size_t k = 0; k < allWords.size(); ++k) {
|
||||||
size_t wi = allWords[k];
|
size_t wi = allWords[k];
|
||||||
double gap = cur.empty() ? 0.0 : words[allWords[k - 1]].spaceAfter;
|
if (wi == kHardBreak) { // forced break: end the current line (may be blank)
|
||||||
if (!cur.empty() && curW + gap + words[wi].width > columnWidth) {
|
pushLine(1);
|
||||||
lines.push_back(cur); cur.clear();
|
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;
|
cur.push_back(wi); curW = words[wi].width;
|
||||||
} else {
|
} else {
|
||||||
cur.push_back(wi); curW += gap + words[wi].width;
|
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 {}; }
|
if (words.empty() || lines.empty()) { FPDF_ClosePage(page); return {}; }
|
||||||
int newLineCount = static_cast<int>(lines.size());
|
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;
|
naturalW += words[lw[k]].width;
|
||||||
if (k > 0) naturalW += words[lw[k - 1]].spaceAfter;
|
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;
|
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::string lineText;
|
||||||
std::vector<double> adv;
|
std::vector<double> adv;
|
||||||
double lineFontSize = 0.0;
|
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 (li >= lineX.size()) {
|
||||||
if (align == "right") x = columnLeft + (columnWidth - naturalW);
|
if (align == "right") x = columnLeft + indent + (effCol - naturalW);
|
||||||
else if (align == "center") x = columnLeft + (columnWidth - naturalW) / 2.0;
|
else if (align == "center") x = columnLeft + indent + (effCol - naturalW) / 2.0;
|
||||||
}
|
}
|
||||||
const double lineStartX = x;
|
const double lineStartX = x;
|
||||||
for (size_t k = 0; k < lw.size(); ++k) {
|
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;
|
bool fontSupportsAll = true;
|
||||||
double totalWidth = 0.0;
|
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);
|
auto utf16 = utf8_to_utf16le(newText);
|
||||||
std::vector<uint32_t> unicodeCodepoints;
|
std::vector<uint32_t> unicodeCodepoints;
|
||||||
for (size_t i = 0; i < utf16.size(); ) {
|
for (size_t i = 0; i < utf16.size(); ) {
|
||||||
@@ -214,23 +229,25 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_replaceText(const nlohm
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool shapedSuccessful = false;
|
bool shapedSuccessful = false;
|
||||||
if (resolvedFont) {
|
constexpr unsigned int kRefMeasure = 1000;
|
||||||
|
fonts::FontFace* mface = measureFace ? measureFace.get()
|
||||||
|
: (resolvedFont ? &resolvedFont->getFontFace() : nullptr);
|
||||||
|
if (mface) {
|
||||||
try {
|
try {
|
||||||
fonts::HbShaper shaper;
|
fonts::HbShaper shaper;
|
||||||
unsigned int uFontSize = static_cast<unsigned int>(fontSize > 0.0 ? fontSize : 12.0);
|
auto shapedGlyphs = shaper.shapeRun(newText, *mface, kRefMeasure);
|
||||||
auto shapedGlyphs = shaper.shapeRun(newText, resolvedFont->getFontFace(), uFontSize);
|
|
||||||
if (!shapedGlyphs.empty()) {
|
if (!shapedGlyphs.empty()) {
|
||||||
totalWidth = 0.0;
|
double sum = 0.0;
|
||||||
for (const auto& sg : shapedGlyphs) {
|
for (const auto& sg : shapedGlyphs) sum += sg.advanceX;
|
||||||
totalWidth += sg.advanceX;
|
totalWidth = sum * (fontSize > 0.0 ? fontSize : 12.0) / static_cast<double>(kRefMeasure);
|
||||||
}
|
|
||||||
shapedSuccessful = true;
|
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) {
|
} catch (const std::exception& e) {
|
||||||
spdlog::warn("Font Engine: HarfBuzz shaping failed: {}", e.what());
|
spdlog::warn("Font Engine: shaping failed: {}", e.what());
|
||||||
} catch (...) {
|
} 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);
|
bool axisAligned = (std::abs(b) < 1e-6 && std::abs(c) < 1e-6 && a > 0.0 && d > 0.0);
|
||||||
double colRight = origRight;
|
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) {
|
if (axisAligned && hasOrigBounds) {
|
||||||
int nObjForCol = FPDFPage_CountObjects(page);
|
int nObjForCol = FPDFPage_CountObjects(page);
|
||||||
for (int k = 0; k < nObjForCol; ++k) {
|
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;
|
float l = 0, bo = 0, rr = 0, tt = 0;
|
||||||
if (!FPDFPageObj_GetBounds(o, &l, &bo, &rr, &tt)) continue;
|
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(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);
|
double justifyTol = (std::max)(4.0, (colRight - origLeft) * 0.02);
|
||||||
bool wasJustified = !disableJustify && axisAligned && resolvedFont && hasOrigBounds && sawSibling &&
|
bool wasJustified = !disableJustify && axisAligned && resolvedFont && hasOrigBounds && sawSibling &&
|
||||||
|
!hasFollowingText &&
|
||||||
(colRight - origLeft) > 20.0 && (origRight >= colRight - justifyTol);
|
(colRight - origLeft) > 20.0 && (origRight >= colRight - justifyTol);
|
||||||
|
|
||||||
double deltaX = 0.0;
|
bool doSiblingShift = !wasJustified && hasOrigBounds;
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int idx : objectIndices) {
|
for (int idx : objectIndices) {
|
||||||
FPDF_PAGEOBJECT objToRemove = FPDFPage_GetObject(page, idx);
|
FPDF_PAGEOBJECT objToRemove = FPDFPage_GetObject(page, idx);
|
||||||
@@ -358,6 +349,7 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_replaceText(const nlohm
|
|||||||
|
|
||||||
FPDF_FONT reconFont = nullptr;
|
FPDF_FONT reconFont = nullptr;
|
||||||
const fonts::pdf_fonts::ReconstructedFont* reconRf = nullptr;
|
const fonts::pdf_fonts::ReconstructedFont* reconRf = nullptr;
|
||||||
|
bool reconFullyCovered = false;
|
||||||
if (matchedFontInfo && matchedFontInfo->isEmbedded && classifyFontFidelity(*matchedFontInfo) != "exact") {
|
if (matchedFontInfo && matchedFontInfo->isEmbedded && classifyFontFidelity(*matchedFontInfo) != "exact") {
|
||||||
reconRf = lookupReconFont(matchedFontInfo->internalFontId);
|
reconRf = lookupReconFont(matchedFontInfo->internalFontId);
|
||||||
if (reconRf && reconRf->ok) {
|
if (reconRf && reconRf->ok) {
|
||||||
@@ -371,13 +363,9 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_replaceText(const nlohm
|
|||||||
if (reconFont) loadedFontsCache_[rkey] = reconFont;
|
if (reconFont) loadedFontsCache_[rkey] = reconFont;
|
||||||
}
|
}
|
||||||
if (reconFont) {
|
if (reconFont) {
|
||||||
bool fullyCovered = true;
|
reconFullyCovered = true;
|
||||||
for (uint32_t cp : unicodeCodepoints)
|
for (uint32_t cp : unicodeCodepoints)
|
||||||
if (cp >= 0x20 && !reconRf->coveredUnicode.count(cp)) { fullyCovered = false; break; }
|
if (cp >= 0x20 && !reconRf->coveredUnicode.count(cp)) { reconFullyCovered = false; break; }
|
||||||
if (fullyCovered && !font) {
|
|
||||||
font = reconFont;
|
|
||||||
spdlog::info("Tier-2: replace_text exact reconstructed font '{}'", matchedFontInfo->internalFontId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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) {
|
if (!font) {
|
||||||
spdlog::info("Font Engine: Loading standard PDF font for replace_text: {}", fontName);
|
spdlog::info("Font Engine: Loading standard PDF font for replace_text: {}", fontName);
|
||||||
font = FPDFText_LoadStandardFont(doc_, fontName.c_str());
|
font = FPDFText_LoadStandardFont(doc_, fontName.c_str());
|
||||||
@@ -432,7 +425,10 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_replaceText(const nlohm
|
|||||||
loadedFontsCache_[cacheKey] = font;
|
loadedFontsCache_[cacheKey] = font;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (font && useEmbedded) reconFont = nullptr;
|
||||||
|
|
||||||
|
std::vector<FPDF_PAGEOBJECT> emittedObjs;
|
||||||
if (font) {
|
if (font) {
|
||||||
constexpr unsigned int kRef = 1000;
|
constexpr unsigned int kRef = 1000;
|
||||||
double emToPage = (fontSize > 0.0 ? fontSize : 1.0) * a / static_cast<double>(kRef);
|
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()));
|
FPDFText_SetText(wobj, reinterpret_cast<FPDF_WIDESTRING>(wu.data()));
|
||||||
FPDFPageObj_Transform(wobj, a, b, c, d, penX, f);
|
FPDFPageObj_Transform(wobj, a, b, c, d, penX, f);
|
||||||
FPDFPage_InsertObjectAtIndex(page, wobj, minIndex);
|
FPDFPage_InsertObjectAtIndex(page, wobj, minIndex);
|
||||||
|
emittedObjs.push_back(wobj);
|
||||||
}
|
}
|
||||||
penX += (wpx[wi] + estSpace) * k + extraPerGap;
|
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()));
|
FPDFText_SetText(obj, reinterpret_cast<FPDF_WIDESTRING>(seg.data()));
|
||||||
FPDFPageObj_Transform(obj, a, b, c, d, penX, f);
|
FPDFPageObj_Transform(obj, a, b, c, d, penX, f);
|
||||||
FPDFPage_InsertObjectAtIndex(page, obj, minIndex);
|
FPDFPage_InsertObjectAtIndex(page, obj, minIndex);
|
||||||
|
emittedObjs.push_back(obj);
|
||||||
}
|
}
|
||||||
double adv = segWidthPage(segFace, seg8);
|
double adv = segWidthPage(segFace, seg8);
|
||||||
if (adv <= 0.0) { // shaping unavailable -> fall back to ink bbox
|
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);
|
FPDFPageObj_Transform(newTextObj, aScale, b, c, d, e, f);
|
||||||
|
|
||||||
FPDFPage_InsertObjectAtIndex(page, newTextObj, minIndex);
|
FPDFPage_InsertObjectAtIndex(page, newTextObj, minIndex);
|
||||||
|
emittedObjs.push_back(newTextObj);
|
||||||
} else {
|
} else {
|
||||||
spdlog::error("Failed to create new text object");
|
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)) {
|
if (!FPDFPage_GenerateContent(page)) {
|
||||||
spdlog::error("Failed to generate page content after replace_text");
|
spdlog::error("Failed to generate page content after replace_text");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
|
|||||||
cacheKey += "#" + std::to_string(h);
|
cacheKey += "#" + std::to_string(h);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (useEmbedded && reuseFont && !isSubsetFont) {
|
if (useEmbedded && reuseFont) {
|
||||||
out.font = reuseFont;
|
out.font = reuseFont;
|
||||||
if (auto perObj = getFontDataFromObjects(pageIndex, srcObjects, internalFontId);
|
if (auto perObj = getFontDataFromObjects(pageIndex, srcObjects, internalFontId);
|
||||||
perObj.has_value() && !perObj->empty()) {
|
perObj.has_value() && !perObj->empty()) {
|
||||||
@@ -150,35 +150,6 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
|
|||||||
}
|
}
|
||||||
if (out.font) return out;
|
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) {
|
if (useEmbedded) {
|
||||||
std::vector<uint8_t> sourceBytes;
|
std::vector<uint8_t> sourceBytes;
|
||||||
if (auto perObj = getFontDataFromObjects(pageIndex, srcObjects, matchedFontInfo->internalFontId);
|
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) {
|
if (!out.font) {
|
||||||
out.font = FPDFText_LoadStandardFont(doc_, fontName.c_str());
|
out.font = FPDFText_LoadStandardFont(doc_, fontName.c_str());
|
||||||
if (!out.font) out.font = FPDFText_LoadStandardFont(doc_, "Helvetica");
|
if (!out.font) out.font = FPDFText_LoadStandardFont(doc_, "Helvetica");
|
||||||
|
|||||||
@@ -5,10 +5,35 @@
|
|||||||
#include <qpdf/QPDFWriter.hh>
|
#include <qpdf/QPDFWriter.hh>
|
||||||
#include <qpdf/QPDFPageDocumentHelper.hh>
|
#include <qpdf/QPDFPageDocumentHelper.hh>
|
||||||
#include <qpdf/QPDFPageObjectHelper.hh>
|
#include <qpdf/QPDFPageObjectHelper.hh>
|
||||||
|
#include <qpdf/QPDFObjectHandle.hh>
|
||||||
|
#include <qpdf/Pl_Buffer.hh>
|
||||||
|
#include <qpdf/Buffer.hh>
|
||||||
|
#include <algorithm>
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
namespace pdfengine::qpdf_layer {
|
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>
|
std::expected<void, std::string>
|
||||||
QpdfWriter::replacePageStreamAndSave(const std::string& sourcePath,
|
QpdfWriter::replacePageStreamAndSave(const std::string& sourcePath,
|
||||||
const std::string& destPath,
|
const std::string& destPath,
|
||||||
@@ -46,4 +71,98 @@ QpdfWriter::replacePageStreamAndSave(const std::string& sourcePath,
|
|||||||
#endif
|
#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 <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
#include <optional>
|
||||||
|
#include <cstdint>
|
||||||
#include <expected>
|
#include <expected>
|
||||||
|
|
||||||
class QPDF;
|
class QPDF;
|
||||||
@@ -14,10 +16,19 @@ public:
|
|||||||
|
|
||||||
[[nodiscard]]
|
[[nodiscard]]
|
||||||
std::expected<void, std::string>
|
std::expected<void, std::string>
|
||||||
replacePageStreamAndSave(const std::string& sourcePath,
|
replacePageStreamAndSave(const std::string& sourcePath,
|
||||||
const std::string& destPath,
|
const std::string& destPath,
|
||||||
int pageIndex,
|
int pageIndex,
|
||||||
const std::string& newStreamData) const;
|
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[][];
|
lines?: ReflowFragment[][];
|
||||||
lineBaselineY?: number[];
|
lineBaselineY?: number[];
|
||||||
lineX?: number[];
|
lineX?: number[];
|
||||||
|
hangingIndent?: number;
|
||||||
|
listMarker?: string;
|
||||||
|
listKind?: 'bullet' | 'ordered';
|
||||||
|
listLevel?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeleteAnnotationData {
|
export interface DeleteAnnotationData {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function getModule(): Promise<PdfiumModule | null> {
|
|||||||
if (!modulePromise) {
|
if (!modulePromise) {
|
||||||
modulePromise = (async () => {
|
modulePromise = (async () => {
|
||||||
try {
|
try {
|
||||||
const V = '20260626-auxfont';
|
const V = '20260630-fontfix3';
|
||||||
const resp = await fetch(`/pdfium-engine.mjs?v=${V}`, { cache: 'no-store' });
|
const resp = await fetch(`/pdfium-engine.mjs?v=${V}`, { cache: 'no-store' });
|
||||||
if (!resp.ok) throw new Error(`pdfium-engine.mjs ${resp.status}`);
|
if (!resp.ok) throw new Error(`pdfium-engine.mjs ${resp.status}`);
|
||||||
const blobUrl = URL.createObjectURL(new Blob([await resp.text()], { type: 'text/javascript' }));
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BLOCK_TAGS = /^(DIV|P|LI|BLOCKQUOTE|PRE)$/;
|
||||||
|
|
||||||
function extractFlatRuns(editable: HTMLElement, dominantFid: string, domSize: number, domColor: string): ReflowFragment[] {
|
function extractFlatRuns(editable: HTMLElement, dominantFid: string, domSize: number, domColor: string): ReflowFragment[] {
|
||||||
const out: ReflowFragment[] = [];
|
const out: ReflowFragment[] = [];
|
||||||
const walker = document.createTreeWalker(editable, NodeFilter.SHOW_TEXT);
|
const pushBreak = () => {
|
||||||
let node = walker.nextNode() as Text | null;
|
if (!out.length) return; // ignore a leading break (no preceding line to end)
|
||||||
while (node) {
|
const prev = out[out.length - 1];
|
||||||
const styleEl = resolveStyleEl(node, editable);
|
if (prev.text === '\n') return; // collapse consecutive structural breaks
|
||||||
const fid = styleEl?.getAttribute('data-fid') || dominantFid;
|
out.push({ text: '\n', internalFontId: prev.internalFontId, fontSize: prev.fontSize, color: prev.color });
|
||||||
const size = parseFloat(styleEl?.getAttribute('data-size') ?? '') || domSize;
|
};
|
||||||
const color = styleEl?.getAttribute('data-color') ?? domColor;
|
const walk = (node: Node) => {
|
||||||
const text = node.textContent ?? '';
|
for (let child = node.firstChild; child; child = child.nextSibling) {
|
||||||
if (text) {
|
if (child.nodeType === Node.TEXT_NODE) {
|
||||||
const frag: ReflowFragment = { text, internalFontId: fid, fontSize: size, color };
|
const raw = child.textContent ?? '';
|
||||||
const aRaw = node.parentElement === styleEl ? styleEl?.getAttribute('data-advances') : null;
|
if (!raw) continue;
|
||||||
if (aRaw) {
|
const styleEl = resolveStyleEl(child as Text, editable);
|
||||||
try {
|
const fid = styleEl?.getAttribute('data-fid') || dominantFid;
|
||||||
const a = JSON.parse(aRaw) as number[];
|
const size = parseFloat(styleEl?.getAttribute('data-size') ?? '') || domSize;
|
||||||
if (Array.isArray(a) && a.length === text.length) frag.advances = a;
|
const color = styleEl?.getAttribute('data-color') ?? domColor;
|
||||||
} catch { }
|
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;
|
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 {
|
function globalCaretOffset(el: HTMLElement): number {
|
||||||
const sel = window.getSelection();
|
const sel = window.getSelection();
|
||||||
if (!sel || sel.rangeCount === 0) return 0;
|
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 [caretBox, setCaretBox] = useState<{ left: number; top: number; height: number } | null>(null);
|
||||||
const [edited, setEdited] = useState(false);
|
const [edited, setEdited] = useState(false);
|
||||||
const [wasmFailed, setWasmFailed] = 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
|
const dominantFid = layout.seedRuns.find((r) => r.text.trim() && r.fid)?.fid
|
||||||
?? layout.seedRuns.find((r) => 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 editorTop = firstBaselineScreen - (leadingPx + fontPx * 0.7) / 2;
|
||||||
const bandTop = Math.max(0, Math.min(editorTop - leadingPx * 0.5, firstBaselineScreen - fontPx * 1.15));
|
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 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) => ({
|
lines: origLines.map((l) => l.frags.map((f) => ({
|
||||||
text: f.text, internalFontId: f.fid, fontSize: f.size, color: f.color,
|
text: f.text, internalFontId: f.fid, fontSize: f.size, color: f.color,
|
||||||
...(f.advances ? { advances: f.advances } : {}),
|
...(f.advances ? { advances: f.advances } : {}),
|
||||||
@@ -283,13 +385,15 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
|||||||
} : {};
|
} : {};
|
||||||
return {
|
return {
|
||||||
objectIndices: layout.objectIndices,
|
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,
|
...linesData,
|
||||||
columnLeft, columnRight,
|
columnLeft: effColumnLeft, columnRight,
|
||||||
pushColumnLeft: pushColumnLeft ?? columnLeft,
|
pushColumnLeft: pushColumnLeft ?? columnLeft,
|
||||||
firstBaselineY: layout.firstBaselineY, leading,
|
firstBaselineY: layout.firstBaselineY, leading,
|
||||||
oldLineCount: layout.oldLineCount, align,
|
oldLineCount: layout.oldLineCount,
|
||||||
|
align: listActive ? 'left' : align,
|
||||||
paraId: paraIdRef.current,
|
paraId: paraIdRef.current,
|
||||||
|
...listFields,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -364,8 +468,6 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
|||||||
const renderPreview = async () => {
|
const renderPreview = async () => {
|
||||||
const el = editRef.current;
|
const el = editRef.current;
|
||||||
if (!el) return;
|
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));
|
const fids = new Set<string>([dominantFid, ...layout.seedRuns.map((r) => r.fid)].filter(Boolean));
|
||||||
await Promise.all([...fids].map((f) => wasmEnsureAuxFont(documentId, f)));
|
await Promise.all([...fids].map((f) => wasmEnsureAuxFont(documentId, f)));
|
||||||
const dpi = Math.round(72 * zoom);
|
const dpi = Math.round(72 * zoom);
|
||||||
@@ -452,7 +554,7 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
|||||||
el.appendChild(span);
|
el.appendChild(span);
|
||||||
}
|
}
|
||||||
el.focus();
|
el.focus();
|
||||||
initialTextRef.current = (el.textContent ?? '').replace(/\s+/g, ' ').trim();
|
initialTextRef.current = normalizeForCompare(domTextWithBreaks(el));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fallbackVisible = wasmFailed && !hasPreview;
|
const fallbackVisible = wasmFailed && !hasPreview;
|
||||||
@@ -500,8 +602,8 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
|||||||
committedRef.current = true;
|
committedRef.current = true;
|
||||||
const el = editRef.current;
|
const el = editRef.current;
|
||||||
if (!el) { onCancel(); return; }
|
if (!el) { onCancel(); return; }
|
||||||
const nowText = (el.textContent ?? '').replace(/\s+/g, ' ').trim();
|
const nowText = normalizeForCompare(domTextWithBreaks(el));
|
||||||
if (nowText === initialTextRef.current) { onCancel(); return; }
|
if (nowText === initialTextRef.current && listKind === null) { onCancel(); return; }
|
||||||
const flat = extractFlatRuns(el, dominantFid, domSize, domColor);
|
const flat = extractFlatRuns(el, dominantFid, domSize, domColor);
|
||||||
if (flat.length === 0) { onCancel(); return; }
|
if (flat.length === 0) { onCancel(); return; }
|
||||||
const cv = previewCanvasRef.current;
|
const cv = previewCanvasRef.current;
|
||||||
@@ -520,6 +622,34 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
|||||||
};
|
};
|
||||||
const cancel = () => { committedRef.current = true; onOverflowPreview?.([]); onOverflowCaret?.(null); onCancel(); };
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<style>{`@keyframes pe-caret-blink{0%,49%{opacity:1}50%,100%{opacity:0}}`}</style>
|
<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
|
<div
|
||||||
ref={editRef}
|
ref={editRef}
|
||||||
contentEditable
|
contentEditable
|
||||||
@@ -561,8 +721,15 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
|||||||
onKeyUp={positionCaret}
|
onKeyUp={positionCaret}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.nativeEvent.isComposing || composingRef.current) return;
|
if (e.nativeEvent.isComposing || composingRef.current) return;
|
||||||
if (e.key === 'Enter') { e.preventDefault(); commit(); }
|
if (e.key === 'Enter') {
|
||||||
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
|
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}
|
onBlur={commit}
|
||||||
onPaste={(e) => { e.preventDefault(); document.execCommand('insertText', false, e.clipboardData.getData('text/plain')); }}
|
onPaste={(e) => { e.preventDefault(); document.execCommand('insertText', false, e.clipboardData.getData('text/plain')); }}
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ export interface ReflowParagraphPayload {
|
|||||||
lines?: ReflowFragment[][];
|
lines?: ReflowFragment[][];
|
||||||
lineBaselineY?: number[];
|
lineBaselineY?: number[];
|
||||||
lineX?: number[];
|
lineX?: number[];
|
||||||
|
hangingIndent?: number;
|
||||||
|
listMarker?: string;
|
||||||
|
listKind?: 'bullet' | 'ordered';
|
||||||
|
listLevel?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isBulletMarker(text?: string): boolean {
|
export function isBulletMarker(text?: string): boolean {
|
||||||
@@ -166,7 +170,7 @@ function isFlowingParagraph(para: any): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function tableCells(line: any): any[] {
|
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 {
|
function paraEm(para: any): number {
|
||||||
let capH = 0;
|
let capH = 0;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from fastapi import APIRouter, HTTPException, Response, status
|
from fastapi import APIRouter, HTTPException, Response, status
|
||||||
|
|
||||||
from app.services import engine
|
from app.services import engine
|
||||||
from app.services.export import apply_need_appearances
|
|
||||||
from app.services.store import document_store
|
from app.services.store import document_store
|
||||||
|
|
||||||
router = APIRouter(tags=["documents"])
|
router = APIRouter(tags=["documents"])
|
||||||
@@ -27,13 +26,11 @@ def export_document(document_id: str):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
doc = d["doc_instance"]
|
doc = d["doc_instance"]
|
||||||
bytes_data = doc.save_full()
|
bytes_data = doc.save_full_for_export()
|
||||||
filename = d["filename"]
|
filename = d["filename"]
|
||||||
if not filename.endswith(".pdf"):
|
if not filename.endswith(".pdf"):
|
||||||
filename += ".pdf"
|
filename += ".pdf"
|
||||||
|
|
||||||
bytes_data = apply_need_appearances(bytes_data)
|
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
content=bytes_data,
|
content=bytes_data,
|
||||||
media_type="application/pdf",
|
media_type="application/pdf",
|
||||||
|
|||||||
@@ -246,6 +246,10 @@ class ReflowParagraphData(BaseModel):
|
|||||||
lines: list[list[ReflowRun]] | None = None
|
lines: list[list[ReflowRun]] | None = None
|
||||||
lineBaselineY: list[float] | None = None
|
lineBaselineY: list[float] | None = None
|
||||||
lineX: 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):
|
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_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_src "${CMAKE_SOURCE_DIR}/engine/assets/fonts")
|
||||||
set(_wasm_font_dir "${CMAKE_BINARY_DIR}/embed_fonts")
|
set(_wasm_font_dir "${CMAKE_BINARY_DIR}/embed_fonts")
|
||||||
file(COPY "${_wasm_font_src}/" DESTINATION "${_wasm_font_dir}")
|
file(COPY "${_wasm_font_src}/" DESTINATION "${_wasm_font_dir}")
|
||||||
|
|
||||||
target_link_options(pdfengine_wasm PRIVATE
|
target_link_options(pdfengine_wasm PRIVATE
|
||||||
|
"-Wl,--allow-multiple-definition"
|
||||||
"--embed-file" "${_wasm_font_dir}@/fonts"
|
"--embed-file" "${_wasm_font_dir}@/fonts"
|
||||||
"-sMODULARIZE=1"
|
"-sMODULARIZE=1"
|
||||||
"-sEXPORT_ES6=1"
|
"-sEXPORT_ES6=1"
|
||||||
|
|||||||
Reference in New Issue
Block a user