Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/parse/pdf_resources/page_font/base_fonts.h
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ namespace pdflib

std::string read_fontname(std::string filename);

void register_standard_aliases();

private:

bool initialized;
Expand Down Expand Up @@ -236,6 +238,8 @@ namespace pdflib
}
}

register_standard_aliases();

initialized = true;
}

Expand Down Expand Up @@ -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<std::pair<std::string, std::string> > 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
70 changes: 68 additions & 2 deletions src/render/blend2d_font_resolver.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 << "`"
Expand Down Expand Up @@ -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<unsigned char>(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);
Expand Down Expand Up @@ -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")
{
Expand Down
122 changes: 122 additions & 0 deletions tests/test_standard_font_widths.py
Original file line number Diff line number Diff line change
@@ -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
Loading