This commit is contained in:
azeeee05
2026-07-01 15:06:08 +05:30
22 changed files with 583 additions and 178 deletions
+2 -1
View File
@@ -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"
}
},
+4
View File
@@ -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());
});
}
+4 -1
View File
@@ -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(); }
};
}
+8
View File
@@ -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) {
+18
View File
@@ -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_);
+3
View File
@@ -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);
+49
View File
@@ -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
}
}
+43 -12
View File
@@ -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) {
+71 -50
View File
@@ -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");
}
+31 -30
View File
@@ -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");
+119
View File
@@ -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
}
}
+14 -3
View File
@@ -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.
+4
View File
@@ -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 {
+1 -1
View File
@@ -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' }));
+197 -30
View File
@@ -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')); }}
+5 -1
View File
@@ -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 -4
View File
@@ -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",
+4
View File
@@ -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):
-44
View File
@@ -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()
+4
View File
@@ -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"