diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index 587962c..67ff2cc 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -29,6 +29,8 @@ add_library(pdfengine OBJECT src/fonts/pdf_fonts/font_fallback.cpp src/fonts/pdf_fonts/font_subset.cpp src/fonts/pdf_fonts/font_cmap_builder.cpp + src/fonts/pdf_fonts/font_validator.cpp + src/fonts/pdf_fonts/font_extraction_service.cpp src/fonts/pdf_fonts/embedded_font_reconstructor.cpp src/fonts/pdf_fonts/encoding/encoding.cpp src/fonts/pdf_fonts/encoding/tounicode_parser.cpp diff --git a/engine/src/fonts/pdf_fonts/font_extraction_service.cpp b/engine/src/fonts/pdf_fonts/font_extraction_service.cpp new file mode 100644 index 0000000..a9c1f1b --- /dev/null +++ b/engine/src/fonts/pdf_fonts/font_extraction_service.cpp @@ -0,0 +1,49 @@ +#include "font_extraction_service.hpp" +#include "font_validator.hpp" +#include "../../qpdf/qpdf_font_extractor.hpp" +#include + +namespace pdfengine::fonts::pdf_fonts { + +std::expected, EngineError> +FontExtractionService::getFontProgram(const std::string& internalFontId, + const std::string& baseFontName, + const std::vector& documentBuffer, + const std::vector& pdfiumRawBytes) { + + std::lock_guard lock(cacheMutex_); + if (cache_.count(internalFontId)) { + return cache_[internalFontId]; + } + + // 1. Try PDFium Bytes (Standard fonts) + if (!pdfiumRawBytes.empty() && FontValidator::isValidSFNT(pdfiumRawBytes)) { + auto prog = std::make_shared(); + prog->fontName = baseFontName; + prog->bytes = pdfiumRawBytes; + prog->source = FontExtractionSource::PDFium; + + cache_[internalFontId] = prog; + spdlog::info("[FontExtraction] internalId='{}' source=PDFium size={}", internalFontId, prog->bytes.size()); + return prog; + } + + // 2. Try QPDF Fallback (Type0 CIDFonts) + if (!documentBuffer.empty()) { + qpdf_layer::QpdfFontExtractor qpdfExtractor; + auto qpdfProg = qpdfExtractor.extractFontProgram(documentBuffer, baseFontName); + if (qpdfProg && FontValidator::isValidSFNT(qpdfProg->bytes)) { + auto prog = std::make_shared(*qpdfProg); + prog->source = FontExtractionSource::QPDF_Raw; + cache_[internalFontId] = prog; + spdlog::info("[FontExtraction] internalId='{}' source=QPDF_Raw size={}", internalFontId, prog->bytes.size()); + return prog; + } + } + + // Tier 2 Reconstruction would happen here if needed, but for now we return not found + spdlog::warn("[FontExtraction] Failed to extract valid SFNT for '{}'", internalFontId); + return std::unexpected(EngineError::FileNotFound); +} + +} // namespace pdfengine::fonts::pdf_fonts diff --git a/engine/src/fonts/pdf_fonts/font_extraction_service.hpp b/engine/src/fonts/pdf_fonts/font_extraction_service.hpp new file mode 100644 index 0000000..934115b --- /dev/null +++ b/engine/src/fonts/pdf_fonts/font_extraction_service.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "font_extraction_types.hpp" +#include + +namespace pdfengine::fonts::pdf_fonts { + +class FontExtractionService { +public: + static FontExtractionService& getInstance() { + static FontExtractionService instance; + return instance; + } + + std::expected, EngineError> + getFontProgram(const std::string& internalFontId, + const std::string& baseFontName, + const std::vector& documentBuffer, + const std::vector& pdfiumRawBytes); + +private: + FontExtractionService() = default; + + std::mutex cacheMutex_; + std::unordered_map> cache_; +}; + +} // namespace pdfengine::fonts::pdf_fonts diff --git a/engine/src/fonts/pdf_fonts/font_extraction_types.hpp b/engine/src/fonts/pdf_fonts/font_extraction_types.hpp new file mode 100644 index 0000000..ac390bb --- /dev/null +++ b/engine/src/fonts/pdf_fonts/font_extraction_types.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include +#include +#include + +namespace pdfengine::fonts::pdf_fonts { + +enum class FontProgramType { Unknown, TrueType, CFF, OpenType, Type1 }; + +enum class FontExtractionSource { PDFium, QPDF_Raw, QPDF_Reconstructed, Cache }; + +struct EmbeddedFontProgram { + std::string fontName; + std::vector bytes; + FontProgramType type = FontProgramType::Unknown; + FontExtractionSource source = FontExtractionSource::PDFium; +}; + +} // namespace pdfengine::fonts::pdf_fonts diff --git a/engine/src/fonts/pdf_fonts/font_validator.cpp b/engine/src/fonts/pdf_fonts/font_validator.cpp new file mode 100644 index 0000000..45dd576 --- /dev/null +++ b/engine/src/fonts/pdf_fonts/font_validator.cpp @@ -0,0 +1,28 @@ +#include "font_validator.hpp" + +namespace pdfengine::fonts::pdf_fonts { + +bool FontValidator::isValidSFNT(const std::vector& bytes) { + if (bytes.size() < 12) { // Minimum SFNT header size + return false; + } + + // Check SFNT version / magic bytes + // 0x00010000 for TrueType + // 0x4F54544F ("OTTO") for OpenType CFF + // 0x74727565 ("true") for Apple TrueType + // 0x74797031 ("typ1") for Mac PostScript Type 1 + const uint8_t* b = bytes.data(); + uint32_t magic = (static_cast(b[0]) << 24) | + (static_cast(b[1]) << 16) | + (static_cast(b[2]) << 8) | + static_cast(b[3]); + + if (magic == 0x00010000 || magic == 0x4F54544F || magic == 0x74727565 || magic == 0x74797031) { + return true; + } + + return false; +} + +} // namespace pdfengine::fonts::pdf_fonts diff --git a/engine/src/fonts/pdf_fonts/font_validator.hpp b/engine/src/fonts/pdf_fonts/font_validator.hpp new file mode 100644 index 0000000..38b0ea4 --- /dev/null +++ b/engine/src/fonts/pdf_fonts/font_validator.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +namespace pdfengine::fonts::pdf_fonts { + +class FontValidator { +public: + static bool isValidSFNT(const std::vector& bytes); +}; + +} // namespace pdfengine::fonts::pdf_fonts diff --git a/engine/src/parser/pdfium_fonts.cpp b/engine/src/parser/pdfium_fonts.cpp index f47706c..4153c22 100644 --- a/engine/src/parser/pdfium_fonts.cpp +++ b/engine/src/parser/pdfium_fonts.cpp @@ -1,5 +1,5 @@ #include "parser/pdfium_internal.hpp" - +#include "fonts/pdf_fonts/font_extraction_service.hpp" namespace pdfengine::parser { void PdfiumDocument::registerAuxFont(const std::string& internalFontId, const std::vector& sfnt) { @@ -607,6 +607,13 @@ std::expected, EngineError> PdfiumDocument::getFontData(con std::string expectedFontName = baseNameFromInternalFontId(internalFontId); + // 1. Try cache via service + auto cachedProg = fonts::pdf_fonts::FontExtractionService::getInstance().getFontProgram( + internalFontId, expectedFontName, memoryBuffer_, {}); + if (cachedProg) { + return (*cachedProg)->bytes; + } + int numPages = FPDF_GetPageCount(doc_); int startPage = 0; @@ -639,37 +646,31 @@ std::expected, EngineError> PdfiumDocument::getFontData(con FPDF_FONT font = FPDFTextObj_GetFont(obj); if (!font) continue; - size_t nameLen = FPDFFont_GetBaseFontName(font, nullptr, 0); if (nameLen > 0) { std::vector nameBuf(nameLen); if (FPDFFont_GetBaseFontName(font, nameBuf.data(), nameLen) > 0) { std::string fontName(nameBuf.data()); - - std::lock_guard lock(fontsMutex_); - - if (fontDataCache_.find(fontName) == fontDataCache_.end()) { + if (fontName == expectedFontName) { + std::vector pdfiumBytes; size_t buflen = 0; FPDFFont_GetFontData(font, nullptr, 0, &buflen); if (buflen > 0) { - std::vector buffer(buflen); + pdfiumBytes.resize(buflen); size_t actual_len = 0; - if (FPDFFont_GetFontData(font, buffer.data(), buflen, &actual_len)) { - fontDataCache_[fontName] = buffer; - } else { - fontDataCache_[fontName] = std::vector(); - } - } else { - fontDataCache_[fontName] = std::vector(); + FPDFFont_GetFontData(font, pdfiumBytes.data(), buflen, &actual_len); } - } - - if (fontName == expectedFontName) { - const auto& cachedBuf = fontDataCache_[fontName]; - if (!cachedBuf.empty()) { - FPDF_ClosePage(page); - fontDataScannedPages_ = i; - return cachedBuf; + + FPDF_ClosePage(page); + fontDataScannedPages_ = i; + + auto prog = fonts::pdf_fonts::FontExtractionService::getInstance().getFontProgram( + internalFontId, expectedFontName, memoryBuffer_, pdfiumBytes); + + if (prog) { + return (*prog)->bytes; + } else { + return std::unexpected(prog.error()); } } } @@ -681,12 +682,15 @@ std::expected, EngineError> PdfiumDocument::getFontData(con fontDataScannedPages_ = i + 1; } - { - std::lock_guard lock(fontsMutex_); - if (fontDataCache_.find(expectedFontName) == fontDataCache_.end()) { - fontDataCache_[expectedFontName] = std::vector(); - } + // If we searched all pages and didn't find the expected font name in FPDF objects, + // let the FontExtractionService try QPDF fallback on the whole document as a last resort. + auto finalProg = fonts::pdf_fonts::FontExtractionService::getInstance().getFontProgram( + internalFontId, expectedFontName, memoryBuffer_, {}); + + if (finalProg) { + return (*finalProg)->bytes; } + return std::unexpected(EngineError::FileNotFound); #else (void)internalFontId; diff --git a/engine/src/qpdf/qpdf_font_extractor.cpp b/engine/src/qpdf/qpdf_font_extractor.cpp index e52d04f..3211309 100644 --- a/engine/src/qpdf/qpdf_font_extractor.cpp +++ b/engine/src/qpdf/qpdf_font_extractor.cpp @@ -11,6 +11,7 @@ #endif #include +#include namespace pdfengine::qpdf_layer { @@ -46,6 +47,7 @@ QpdfFontExtractor::extractMapping(const std::vector& pdf, const std::st ::QPDF qpdf; qpdf.processMemoryFile("memory", reinterpret_cast(pdf.data()), pdf.size()); + std::optional bestObj; for (QPDFObjectHandle obj : qpdf.getAllObjects()) { if (!obj.isDictionary()) continue; if (!obj.hasKey("/Type") || !obj.getKey("/Type").isName() || @@ -53,43 +55,60 @@ QpdfFontExtractor::extractMapping(const std::vector& pdf, const std::st continue; if (!baseFontMatches(obj, baseFontName)) continue; - EmbeddedFontMapping m; - m.subtype = obj.hasKey("/Subtype") && obj.getKey("/Subtype").isName() - ? obj.getKey("/Subtype").getName() : ""; - - if (obj.hasKey("/ToUnicode") && obj.getKey("/ToUnicode").isStream()) { - std::string s = decodeStream(obj.getKey("/ToUnicode")); - if (!s.empty() && fonts::pdf_fonts::ToUnicodeParser::parse(s, m.codeToUnicode)) - m.hasToUnicode = !m.codeToUnicode.empty(); + bestObj = obj; + if (obj.hasKey("/ToUnicode")) { + break; // Found the parent Type0 font! } + } - if (m.subtype == "/Type0" && obj.hasKey("/DescendantFonts")) { - QPDFObjectHandle df = obj.getKey("/DescendantFonts"); - QPDFObjectHandle cid = df.isArray() && df.getArrayNItems() > 0 ? df.getArrayItem(0) - : QPDFObjectHandle(); - if (cid.isDictionary() && cid.hasKey("/CIDToGIDMap")) { - QPDFObjectHandle c2g = cid.getKey("/CIDToGIDMap"); - if (c2g.isStream()) { - std::string s = decodeStream(c2g); - m.identityCidToGid = false; - for (size_t i = 0; i + 1 < s.size(); i += 2) { - uint16_t gid = static_cast((static_cast(s[i]) << 8) | - static_cast(s[i + 1])); - if (gid != 0) m.codeToGid[static_cast(i / 2)] = gid; - } - } else { - m.identityCidToGid = true; + if (!bestObj) return std::unexpected(QpdfError::Unknown); + QPDFObjectHandle obj = *bestObj; + + spdlog::info("[DEBUG] Found best matching font dictionary for {}", baseFontName); + + EmbeddedFontMapping m; + m.subtype = obj.hasKey("/Subtype") && obj.getKey("/Subtype").isName() + ? obj.getKey("/Subtype").getName() : ""; + + if (obj.hasKey("/ToUnicode") && obj.getKey("/ToUnicode").isStream()) { + std::string s = decodeStream(obj.getKey("/ToUnicode")); + if (s.empty()) { + spdlog::info("[DEBUG] ToUnicode stream is empty after decodeStream!"); + } else { + bool parseOk = fonts::pdf_fonts::ToUnicodeParser::parse(s, m.codeToUnicode); + if (!parseOk) { + spdlog::info("[DEBUG] ToUnicodeParser::parse returned false!"); + } + m.hasToUnicode = !m.codeToUnicode.empty(); + } + } + + if (m.subtype == "/Type0" && obj.hasKey("/DescendantFonts")) { + QPDFObjectHandle df = obj.getKey("/DescendantFonts"); + QPDFObjectHandle cid = df.isArray() && df.getArrayNItems() > 0 ? df.getArrayItem(0) + : QPDFObjectHandle(); + if (cid.isDictionary() && cid.hasKey("/CIDToGIDMap")) { + QPDFObjectHandle c2g = cid.getKey("/CIDToGIDMap"); + if (c2g.isStream()) { + std::string s = decodeStream(c2g); + m.identityCidToGid = false; + for (size_t i = 0; i + 1 < s.size(); i += 2) { + uint16_t gid = static_cast((static_cast(s[i]) << 8) | + static_cast(s[i + 1])); + if (gid != 0) m.codeToGid[static_cast(i / 2)] = gid; } } else { - m.identityCidToGid = true; + m.identityCidToGid = true; } } else { m.identityCidToGid = true; } - - m.ok = m.hasToUnicode; - return m; + } else { + m.identityCidToGid = true; } + + m.ok = m.hasToUnicode; + return m; return std::unexpected(QpdfError::Unknown); } catch (const QPDFExc& e) { fprintf(stderr, "QpdfFontExtractor QPDFExc: %s\n", e.what()); @@ -103,5 +122,72 @@ QpdfFontExtractor::extractMapping(const std::vector& pdf, const std::st return std::unexpected(QpdfError::NotSupported); #endif } +std::optional +QpdfFontExtractor::extractFontProgram(const std::vector& pdf, const std::string& baseFontName) const { +#ifdef PDFENGINE_WITH_QPDF + try { + ::QPDF qpdf; + qpdf.processMemoryFile("memory", reinterpret_cast(pdf.data()), pdf.size()); + + for (QPDFObjectHandle obj : qpdf.getAllObjects()) { + if (!obj.isDictionary()) continue; + if (!obj.hasKey("/Type") || !obj.getKey("/Type").isName() || + obj.getKey("/Type").getName() != "/Font") + continue; + if (!baseFontMatches(obj, baseFontName)) continue; + + std::string subtype = obj.hasKey("/Subtype") && obj.getKey("/Subtype").isName() + ? obj.getKey("/Subtype").getName() : ""; + + QPDFObjectHandle fontDesc; + + if (subtype == "/Type0" && obj.hasKey("/DescendantFonts")) { + QPDFObjectHandle df = obj.getKey("/DescendantFonts"); + if (df.isArray() && df.getArrayNItems() > 0) { + QPDFObjectHandle cid = df.getArrayItem(0); + if (cid.isDictionary() && cid.hasKey("/FontDescriptor")) { + fontDesc = cid.getKey("/FontDescriptor"); + } + } + } else if (obj.hasKey("/FontDescriptor")) { + fontDesc = obj.getKey("/FontDescriptor"); + } + + if (!fontDesc.isDictionary()) continue; + + QPDFObjectHandle streamObj; + fonts::pdf_fonts::FontProgramType ftype = fonts::pdf_fonts::FontProgramType::Unknown; + + if (fontDesc.hasKey("/FontFile2") && fontDesc.getKey("/FontFile2").isStream()) { + streamObj = fontDesc.getKey("/FontFile2"); + ftype = fonts::pdf_fonts::FontProgramType::TrueType; + } else if (fontDesc.hasKey("/FontFile3") && fontDesc.getKey("/FontFile3").isStream()) { + streamObj = fontDesc.getKey("/FontFile3"); + ftype = fonts::pdf_fonts::FontProgramType::CFF; + } else if (fontDesc.hasKey("/FontFile") && fontDesc.getKey("/FontFile").isStream()) { + streamObj = fontDesc.getKey("/FontFile"); + ftype = fonts::pdf_fonts::FontProgramType::Type1; + } + + if (streamObj.isStream()) { + std::string s = decodeStream(streamObj); + if (!s.empty()) { + fonts::pdf_fonts::EmbeddedFontProgram fp; + fp.fontName = baseFontName; + fp.bytes = std::vector(s.begin(), s.end()); + fp.type = ftype; + fp.source = fonts::pdf_fonts::FontExtractionSource::QPDF_Raw; + return fp; + } + } + } + } catch (const std::exception& e) { + fprintf(stderr, "QpdfFontExtractor extractFontProgram exception: %s\n", e.what()); + } +#else + (void)pdf; (void)baseFontName; +#endif + return std::nullopt; +} } \ No newline at end of file diff --git a/engine/src/qpdf/qpdf_font_extractor.hpp b/engine/src/qpdf/qpdf_font_extractor.hpp index c6e85ad..5279456 100644 --- a/engine/src/qpdf/qpdf_font_extractor.hpp +++ b/engine/src/qpdf/qpdf_font_extractor.hpp @@ -7,6 +7,8 @@ #include #include "qpdf_extractor.hpp" +#include +#include "fonts/pdf_fonts/font_extraction_types.hpp" namespace pdfengine::qpdf_layer { @@ -23,6 +25,9 @@ class QpdfFontExtractor { public: std::expected extractMapping(const std::vector& pdf, const std::string& baseFontName) const; + + std::optional + extractFontProgram(const std::vector& pdf, const std::string& baseFontName) const; }; } \ No newline at end of file diff --git a/engine/tests/CMakeLists.txt b/engine/tests/CMakeLists.txt index faee519..d7338cd 100644 --- a/engine/tests/CMakeLists.txt +++ b/engine/tests/CMakeLists.txt @@ -19,6 +19,7 @@ add_executable(pdfengine_smoke ast_serializer_test.cpp content_serializer_test.cpp qpdf_writer_test.cpp + font_extraction_test.cpp ) if(PDFENGINE_WITH_QPDF) diff --git a/engine/tests/font_extraction_test.cpp b/engine/tests/font_extraction_test.cpp new file mode 100644 index 0000000..211c117 --- /dev/null +++ b/engine/tests/font_extraction_test.cpp @@ -0,0 +1,32 @@ +#include +#include "../src/fonts/pdf_fonts/font_extraction_service.hpp" +#include "../src/fonts/pdf_fonts/font_validator.hpp" + +using namespace pdfengine::fonts::pdf_fonts; + +TEST(FontValidatorTest, ValidatesSFNT) { + // TrueType Magic: 0x00010000 + std::vector ttf = {0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + EXPECT_TRUE(FontValidator::isValidSFNT(ttf)); + + // OTTO Magic + std::vector otto = {'O', 'T', 'T', 'O', 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + EXPECT_TRUE(FontValidator::isValidSFNT(otto)); + + // Invalid Magic + std::vector invalid = {0x12, 0x34, 0x56, 0x78, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + EXPECT_FALSE(FontValidator::isValidSFNT(invalid)); + + // Too small + std::vector small = {0x00, 0x01, 0x00, 0x00}; + EXPECT_FALSE(FontValidator::isValidSFNT(small)); +} + +TEST(FontExtractionServiceTest, FallbacksToQPDFIfPdfiumFails) { + auto& service = FontExtractionService::getInstance(); + + // We expect it to return FileNotFound because we provided empty buffers + // but this ensures the code compiles and the service instance is accessible. + auto res = service.getFontProgram("F1", "TestFont", {}, {}); + EXPECT_FALSE(res.has_value()); +} diff --git a/frontend/index.html b/frontend/index.html index 9a8a311..d8cc374 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,6 +5,9 @@ PDF Editor + + +
diff --git a/frontend/src/lib/fontFaceLoader.ts b/frontend/src/lib/fontFaceLoader.ts index 0731f92..4534758 100644 --- a/frontend/src/lib/fontFaceLoader.ts +++ b/frontend/src/lib/fontFaceLoader.ts @@ -30,7 +30,10 @@ export function loadPdfFont( if (existing) return existing; const p = (async (): Promise => { - const bytes = await gatewayService.getFontData(documentId, internalFontId); + let bytes = await gatewayService.getReconstructedFontData(documentId, internalFontId); + if (!bytes || bytes.byteLength === 0) { + bytes = await gatewayService.getFontData(documentId, internalFontId); + } if (!bytes || bytes.byteLength === 0) return null; const hint = (cssFamilyHint || '').replace(/^[A-Z]{6}\+/, '').trim(); const family = hint diff --git a/frontend/src/viewer/ParagraphEditor.tsx b/frontend/src/viewer/ParagraphEditor.tsx index c5a787b..e11e03a 100644 --- a/frontend/src/viewer/ParagraphEditor.tsx +++ b/frontend/src/viewer/ParagraphEditor.tsx @@ -74,7 +74,7 @@ function median(xs: number[]): number { function paraEffSize(lines: any[]): number { let nominal = 0; for (const line of lines) for (const r of (line.runs ?? [])) { - const sz = Math.max(r.font_size ?? 0, r.h ?? 0); + const sz = r.font_size && r.font_size > 0 ? r.font_size : (r.h ?? 0) * 0.8; if (sz > nominal) nominal = sz; } return nominal || 12; @@ -105,7 +105,7 @@ function computeLayout(para: any): ParagraphLayout { } const adv = perRun[ri]; const safeColor = sanitizeTextColor(r.color); - const rSize = Math.max(r.font_size ?? 0, r.h ?? 0) || effSize; + const rSize = (r.font_size && r.font_size > 0 ? r.font_size : (r.h ?? 0) * 0.8) || effSize; seedRuns.push({ text, fid: r.internal_font_id ?? '', size: rSize, color: safeColor, fontName: r.font_name ?? '', advances: adv }); if (orig) lineFrags.push({ text: orig, fid: r.internal_font_id ?? '', size: rSize, color: safeColor, advances: adv }); } @@ -631,9 +631,26 @@ export const ParagraphEditor: React.FC = ({ const colWidthPx = (columnRight - columnLeft) * zoom; // Fix 1: use extracted PDF font name (loaded via @font-face), not a generic Arial/Times/Courier map. - const extractedFamily = (domFontName || '').replace(/^[A-Z]{6}\+/, '').trim() || 'sans-serif'; - const fallbackFamily = /times|serif/i.test(extractedFamily) ? 'Times New Roman, serif' - : /courier|mono/i.test(extractedFamily) ? 'Courier New, monospace' : 'Arial, sans-serif'; + let extractedFamily = (domFontName || '').replace(/^[A-Z]{6}\+/, '').trim() || 'sans-serif'; + // FIX: DO NOT split on hyphens here. If the font was downloaded via @font-face (e.g. "Arial-BoldMT"), + // we must use the exact string "Arial-BoldMT" so the browser maps to the downloaded font, not the OS font. + + // FIX: Chrome on Windows forcefully aliases exactly "Helvetica" to "Arial" at the OS layer. + // We bypass this hardcoded alias only if the original name is EXACTLY Helvetica or Arial. + const rawFamily = extractedFamily.toLowerCase(); + if (rawFamily === 'helvetica' || rawFamily === 'arial') { + extractedFamily = `"Inter", ${extractedFamily}`; + } + + let fallbackFamily = '"Arimo", Arial, sans-serif'; + if (/times|serif/i.test(extractedFamily)) { + fallbackFamily = 'Times New Roman, serif'; + } else if (/courier|mono/i.test(extractedFamily)) { + fallbackFamily = 'Courier New, monospace'; + } else { + fallbackFamily = '"Inter", "Arimo", Arial, sans-serif'; + } + const [measureFamily, setMeasureFamily] = useState( extractedFamily !== 'sans-serif' ? `'${extractedFamily}', ${fallbackFamily}` : fallbackFamily, ); @@ -1011,7 +1028,7 @@ export const ParagraphEditor: React.FC = ({ for (const ln of lines) { for (const r of (ln.runs ?? [])) { pdfFontName = r.font_name || pdfFontName; - pdfFontSize = Math.max(pdfFontSize, r.font_size ?? 0, r.h ?? 0); + pdfFontSize = Math.max(pdfFontSize, r.font_size && r.font_size > 0 ? r.font_size : (r.h ?? 0) * 0.8); for (const g of (r.glyphs ?? [])) { const x0 = g.bbox_x, y0 = g.bbox_y, x1 = g.bbox_x + g.bbox_w, y1 = g.bbox_y + g.bbox_h; pdfMinX = Math.min(pdfMinX, x0); pdfMinY = Math.min(pdfMinY, y0); diff --git a/frontend/src/viewer/TextEditLayer.tsx b/frontend/src/viewer/TextEditLayer.tsx index f4fad56..25cc9e9 100644 --- a/frontend/src/viewer/TextEditLayer.tsx +++ b/frontend/src/viewer/TextEditLayer.tsx @@ -233,13 +233,29 @@ interface TextEditLayerProps { function fallbackFamily(fontName: string): string { const n = (fontName || '').toLowerCase(); + + let baseFallback = '"Arimo", Arial, "Helvetica Neue", Helvetica, sans-serif'; if (n.includes('times') || (n.includes('serif') && !n.includes('sans'))) { - return '"Times New Roman", Times, Georgia, serif'; + baseFallback = '"Times New Roman", Times, Georgia, serif'; + } else if (n.includes('courier') || n.includes('mono')) { + baseFallback = '"Courier New", Courier, monospace'; + } else { + baseFallback = '"Inter", "Arimo", Arial, "Helvetica Neue", Helvetica, sans-serif'; } - if (n.includes('courier') || n.includes('mono')) { - return '"Courier New", Courier, monospace'; + + let cleanName = (fontName || '').replace(/^[A-Z]{6}\+/, '').trim(); + + // FIX: Chrome on Windows forcefully aliases exactly "Helvetica" to "Arial" at the OS layer. + // By explicitly returning Inter here, we bypass this hardcoded alias so it renders the modern font instead of Arial. + const rawName = cleanName.toLowerCase(); + if (rawName === 'helvetica' || rawName === 'arial') { + return `"Inter", ${baseFallback}`; } - return 'Arial, "Helvetica Neue", Helvetica, sans-serif'; + + if (cleanName && !['arial', 'helvetica', 'times', 'courier', 'arimo', 'inter'].includes(rawName)) { + return `"${cleanName}", ${baseFallback}`; + } + return baseFallback; } function flattenRuns(model: any): EditableRun[] { @@ -259,7 +275,7 @@ function flattenRuns(model: any): EditableRun[] { text: r.text, x: r.x, y: r.y, w: r.w, h: r.h, baselineY, - fontSize: Math.max(r.font_size ?? 0, r.h ?? 0), + fontSize: r.font_size && r.font_size > 0 ? r.font_size : (r.h ?? 0) * 0.8, objectIndices, internalFontId: r.internal_font_id ?? '', fontName: r.font_name ?? '', @@ -281,7 +297,8 @@ function median(xs: number[]): number { } function displayFontSize(r: EditableRun): number { - return Math.max(r.fontSize ?? 0, r.h ?? 0); + if (r.fontSize && r.fontSize > 0) return r.fontSize; + return (r.h ?? 0) * 0.8; } function buildReflowPayload(model: any, run: EditableRun, newText: string): ReflowParagraphPayload | null { diff --git a/gateway/debug_preview_region.png b/gateway/debug_preview_region.png index 29930e0..7f51b73 100644 Binary files a/gateway/debug_preview_region.png and b/gateway/debug_preview_region.png differ