diff --git a/src/parse/pdf_resources/page_font/base_fonts.h b/src/parse/pdf_resources/page_font/base_fonts.h index 12e4ac64..259477bd 100644 --- a/src/parse/pdf_resources/page_font/base_fonts.h +++ b/src/parse/pdf_resources/page_font/base_fonts.h @@ -42,6 +42,8 @@ namespace pdflib std::string read_fontname(std::string filename); + void register_standard_aliases(); + private: bool initialized; @@ -236,6 +238,8 @@ namespace pdflib } } + register_standard_aliases(); + initialized = true; } @@ -289,6 +293,45 @@ namespace pdflib return fontname; } + void base_fonts::register_standard_aliases() + { + // A PDF may name a Core-14 font by a common family alias (Arial for + // Helvetica, Times New Roman for Times, Courier New for Courier) and ship it + // without a /Widths table. The resolver only matches when the canonical + // PostScript name is contained in the font name, which these aliases are + // not, so bind them to the Core-14 metrics that viewers substitute. + static const std::vector > aliases = + { + {"arial", "helvetica"}, + {"arial-bold", "helvetica-bold"}, + {"arial-italic", "helvetica-oblique"}, + {"arial-bolditalic", "helvetica-boldoblique"}, + {"couriernew", "courier"}, + {"couriernew-bold", "courier-bold"}, + {"couriernew-italic", "courier-oblique"}, + {"couriernew-bolditalic", "courier-boldoblique"}, + {"times", "times-roman"}, + {"timesnewroman", "times-roman"}, + {"timesnewroman-bold", "times-bold"}, + {"timesnewroman-italic", "times-italic"}, + {"timesnewroman-bolditalic", "times-bolditalic"}, + }; + + for(auto& alias : aliases) + { + if(name_to_basefont.count(alias.first)==1 or + name_to_basefont.count(alias.second)==0) + { + continue; + } + + base_font bf = name_to_basefont.at(alias.second); + name_to_basefont.emplace(alias.first, bf); + + core_14_fonts.insert(alias.first); + } + } + } #endif diff --git a/src/render/blend2d_font_resolver.h b/src/render/blend2d_font_resolver.h index 3a5a0233..0a2216d4 100644 --- a/src/render/blend2d_font_resolver.h +++ b/src/render/blend2d_font_resolver.h @@ -112,6 +112,9 @@ namespace pdflib // Strips leading PDF name slash and six-letter subset prefixes such as // ABCDEF+Times-Roman. static std::string strip_subset_prefix(const std::string& name); + static bool is_pdf_resource_font_key(const std::string& name); + static std::string select_font_query(const std::string& font_name, + const std::string& base_font); // Normalizes a PDF/system font name into the comparable form used by the // resolver index. This strips PDF subset prefixes, replaces punctuation @@ -205,8 +208,7 @@ namespace pdflib bool resolve_fonts, float font_similarity_cutoff) { - const std::string& cache_key = (not font_name.empty() and font_name != "null") - ? font_name : base_font; + const std::string cache_key = select_font_query(font_name, base_font); LOG_S(INFO) << "blend2d font resolver: resolve_font_face" << " font_name=`" << font_name << "`" @@ -287,6 +289,33 @@ namespace pdflib return s; } + inline bool blend2d_font_resolver::is_pdf_resource_font_key(const std::string& name) + { + const std::string s = strip_subset_prefix(name); + if (s.size() < 2 or (s[0] != 'F' and s[0] != 'f')) { return false; } + + return std::all_of(s.begin() + 1, s.end(), + [](char c) + { + return std::isdigit(static_cast(c)); + }); + } + + inline std::string blend2d_font_resolver::select_font_query( + const std::string& font_name, + const std::string& base_font) + { + const bool has_base_font = not base_font.empty() and base_font != "null"; + if (has_base_font and + (font_name.empty() or font_name == "null" or is_pdf_resource_font_key(font_name))) + { + return base_font; + } + + if (not font_name.empty() and font_name != "null") { return font_name; } + return base_font; + } + inline std::string blend2d_font_resolver::normalize_font_name(const std::string& name) { std::string s = strip_subset_prefix(name); @@ -608,10 +637,47 @@ namespace pdflib inline bool blend2d_font_resolver::lookup_alias(font_request& request) { const std::string n = request.normalized_name; + std::string compact; + for (char c : n) + { + if (c != ' ') { compact += c; } + } + auto set_family = [&request](const std::string& family) { request.family = normalize_font_name(family); }; + auto set_standard_family = [&request](const std::string& family) + { + request.normalized_name = normalize_font_name(family); + request.family = request.normalized_name; + request.standard_14 = true; + }; + + if (compact == "arial" or compact == "arialbold" or + compact == "arialitalic" or compact == "arialbolditalic") + { + set_standard_family("Helvetica"); + request.bold = request.bold or compact.find("bold") != std::string::npos; + request.italic = request.italic or compact.find("italic") != std::string::npos; + return true; + } + if (compact == "couriernew" or compact == "couriernewbold" or + compact == "couriernewitalic" or compact == "couriernewbolditalic") + { + set_standard_family("Courier"); + request.bold = request.bold or compact.find("bold") != std::string::npos; + request.italic = request.italic or compact.find("italic") != std::string::npos; + return true; + } + if (compact == "timesnewroman" or compact == "timesnewromanbold" or + compact == "timesnewromanitalic" or compact == "timesnewromanbolditalic") + { + set_standard_family("Times"); + request.bold = request.bold or compact.find("bold") != std::string::npos; + request.italic = request.italic or compact.find("italic") != std::string::npos; + return true; + } if (n == "times" or n == "times roman") { diff --git a/tests/test_standard_font_widths.py b/tests/test_standard_font_widths.py new file mode 100644 index 00000000..67c114ac --- /dev/null +++ b/tests/test_standard_font_widths.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python +"""Character widths for standard Type1 fonts without an explicit /Widths table. + +A PDF may reference one of the Core-14 fonts (or a common family alias such as +Arial or Times New Roman) and rely on the built-in font metrics instead of +shipping a /Widths array. These tests build such PDFs in memory and check that +the parser reports the real per-glyph advances rather than a flat fallback. +""" + +from io import BytesIO +from itertools import pairwise +from typing import List + +from docling_parse.pdf_parser import DoclingPdfParser + + +def _build_pdf( + base_font: str, text: str, font_size: int = 10, widths: str = "" +) -> bytes: + content = f"BT\n/F001 {font_size} Tf\n72 720 Td\n({text}) Tj\nET\n" + font = ( + f"<< /Type /Font /Subtype /Type1 /BaseFont /{base_font} /Name /F001" + f"{widths} /Encoding /WinAnsiEncoding >>" + ) + objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 857 792] /Contents 4 0 R " + "/Resources << /Font << /F001 5 0 R >> >> >>", + f"<< /Length {len(content)} >>\nstream\n{content}\nendstream", + font, + ] + + out = b"%PDF-1.4\n" + offsets = [] + for index, obj in enumerate(objects, start=1): + offsets.append(len(out)) + out += f"{index} 0 obj\n{obj}\nendobj\n".encode("latin-1") + + startxref = len(out) + out += f"xref\n0 {len(objects) + 1}\n".encode("latin-1") + out += b"0000000000 65535 f \n" + for offset in offsets: + out += f"{offset:010d} 00000 n \n".encode("latin-1") + out += ( + f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n" + f"startxref\n{startxref}\n%%EOF" + ).encode("latin-1") + return out + + +def _char_advances(base_font: str, text: str, **kwargs) -> List[float]: + """Left-edge spacing between consecutive glyphs of the first text line.""" + parser = DoclingPdfParser(loglevel="fatal") + doc = parser.load(path_or_stream=BytesIO(_build_pdf(base_font, text, **kwargs))) + + _, page = next(doc.iterate_pages()) + cells = list(page.char_cells) + assert cells, f"no char cells for {base_font}" + + def left(cell) -> float: + rect = cell.rect + return min(rect.r_x0, rect.r_x1, rect.r_x2, rect.r_x3) + + lefts = sorted(left(cell) for cell in cells) + return [round(b - a, 3) for a, b in pairwise(lefts)] + + +FLAT_FALLBACK_ADVANCE = 5.0 # 500 units/em at 10pt, the pre-fix wrong value + + +def test_courier_char_advance_is_six_points(): + """Courier is monospace at 600 units/em, so every 10pt glyph advances 6pt.""" + advances = _char_advances("Courier", "ABC12345 COMPANY") + assert advances + for advance in advances: + assert abs(advance - 6.0) < 0.01 + + +def test_helvetica_widths_vary_and_are_not_flat(): + """A proportional Core-14 font yields real per-glyph widths, not a flat 5pt.""" + # "imW" exercises a narrow, a wide and an extra-wide glyph. + advances = _char_advances("Helvetica", "imW") + assert len(advances) >= 2 + assert len(set(advances)) > 1 + for advance in advances: + assert abs(advance - FLAT_FALLBACK_ADVANCE) > 0.01 + + +def test_arial_alias_resolves_to_helvetica_metrics(): + """Arial has no bundled AFM, so it must fall back to the Helvetica metrics. + + Without the alias it drops to the flat 5pt fallback; with it the advances + match Helvetica glyph for glyph. + """ + text = "imWl0" + arial = _char_advances("Arial", text) + helvetica = _char_advances("Helvetica", text) + + assert arial == helvetica + for advance in arial: + assert abs(advance - FLAT_FALLBACK_ADVANCE) > 0.01 + + +def test_times_aliases_resolve_to_times_roman_metrics(): + """Both "Times" and "TimesNewRoman" map onto the Times-Roman metrics.""" + text = "imWl0" + times_roman = _char_advances("Times-Roman", text) + + assert _char_advances("Times", text) == times_roman + assert _char_advances("TimesNewRoman", text) == times_roman + assert times_roman[0] != times_roman[1] + + +def test_explicit_widths_take_precedence_over_base_metrics(): + """An explicit /Widths table overrides the built-in Core-14 metrics.""" + widths = " /FirstChar 65 /LastChar 90 /Widths [" + " ".join(["1000"] * 26) + "]" + advances = _char_advances("Courier", "AWX", widths=widths) + + assert advances + for advance in advances: + assert abs(advance - 10.0) < 0.01