Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ The release run heads these entries with the version and opens a fresh

## Unreleased

- SFNT output uses format 12 for large character maps and U+FFFF mappings,
writes Unicode font names correctly, and validates table and name sizes.
Synthesized PostScript names now obey OpenType character and length limits.

- SFNT fonts check character-map ranges before they expand them. A broken or
unsupported character map no longer hides the next supported one.

Expand Down
2 changes: 1 addition & 1 deletion src/odr/internal/font/sfnt_font.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,7 @@ std::string SfntFont::write() const {
if (tag == "cmap" || !is_kept_table(tag)) {
continue;
}
std::string data = table_data(location);
std::string data = std::string(table_data(location));
if (tag == "hmtx") {
// OTS rejects an `hmtx` shorter than `hhea` and `maxp` say.
const std::size_t side_bearings =
Expand Down
76 changes: 63 additions & 13 deletions src/odr/internal/font/sfnt_transform.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,21 @@

#include <odr/internal/font/sfnt_font.hpp>
#include <odr/internal/util/byte_string.hpp>
#include <odr/internal/util/string_util.hpp>

#include <algorithm>
#include <array>
#include <bit>
#include <cstdint>
#include <limits>
#include <map>
#include <ranges>
#include <stdexcept>
#include <string_view>
#include <utility>

#include <utf8cpp/utf8/cpp17.h>

namespace odr::internal::font {

namespace {
Expand Down Expand Up @@ -89,6 +95,10 @@ char32_t font::pua_code_point(const std::uint16_t glyph) noexcept {
std::string
font::build_sfnt(const std::uint32_t sfnt_version,
std::vector<std::pair<std::string, std::string>> tables) {
if (tables.size() > 4095) {
throw std::runtime_error(
"sfnt: table directory exceeds 16-bit search fields");
}
std::ranges::sort(
tables, {}, [](const auto &e) -> const std::string & { return e.first; });

Expand All @@ -111,6 +121,15 @@ font::build_sfnt(const std::uint32_t sfnt_version,
std::uint32_t bodies_checksum = 0;
for (std::size_t i = 0; i < tables.size(); ++i) {
auto &[tag, data] = tables[i];
if (tag.size() != 4 || (i > 0 && tag == tables[i - 1].first)) {
throw std::runtime_error("sfnt: invalid or duplicate table tag");
}
const std::size_t padding = (4 - data.size() % 4) % 4;
const std::uint32_t remaining =
std::numeric_limits<std::uint32_t>::max() - offset;
if (data.size() > remaining || padding > remaining - data.size()) {
throw std::runtime_error("sfnt: table data exceeds 32-bit offsets");
}
const auto length = static_cast<std::uint32_t>(data.size());
pad4(data);
if (tag == "head" && data.size() >= 12) {
Expand Down Expand Up @@ -201,9 +220,11 @@ serialize_cmap_format12(const std::map<char32_t, std::uint16_t> &map) {
} // namespace

std::string font::serialize_cmap(const std::map<char32_t, std::uint16_t> &map) {
// Format 4 tops out at the BMP; a map that overflows into the Supplementary
// PUA needs format 12's 32-bit code ranges instead.
if (!map.empty() && map.rbegin()->first > 0xffff) {
if (!map.empty() && map.rbegin()->first > 0x10FFFF) {
throw std::runtime_error("sfnt: character map exceeds Unicode range");
}
// Format 4 reserves U+FFFF for its terminator.
if (!map.empty() && map.rbegin()->first >= 0xffff) {
return serialize_cmap_format12(map);
}

Expand All @@ -228,6 +249,9 @@ std::string font::serialize_cmap(const std::map<char32_t, std::uint16_t> &map) {
// Mandatory terminator segment: 0xFFFF -> 0 (delta 1).
segments.push_back({0xffff, 0xffff, 1});

if (segments.size() > (std::numeric_limits<std::uint16_t>::max() - 16) / 8) {
return serialize_cmap_format12(map);
}
const auto seg_count = static_cast<std::uint16_t>(segments.size());
const auto [search_range, entry_selector, range_shift] =
search_hints(seg_count, 2);
Expand Down Expand Up @@ -266,24 +290,36 @@ std::string font::serialize_cmap(const std::map<char32_t, std::uint16_t> &map) {
}

std::string font::serialize_name(const std::string &font_name) {
const std::string family = font_name.empty() ? "ODR Font" : font_name;
const auto utf16be = [](const std::string &ascii) {
// A CFF name may hold any bytes, and the UTF-16 conversion throws on them.
const std::string family =
font_name.empty() ? "ODR Font" : utf8::replace_invalid(font_name);
const auto utf16be = [](const std::string &value) {
std::string out;
for (const char c : ascii) {
bs::put_u16_be(out, static_cast<std::uint8_t>(c));
for (const char16_t c : util::string::string_to_u16string(value)) {
bs::put_u16_be(out, c);
}
return out;
};
std::string postscript;
for (const char16_t c : util::string::string_to_u16string(family)) {
if (postscript.size() == 63) {
break;
}
postscript +=
c < 33 || c > 126 || std::u16string_view(u"[](){}<>/%").contains(c)
? '-'
: static_cast<char>(c);
}
struct Record {
std::uint16_t name_id;
std::string value;
};
const std::vector<Record> records = {
const std::array<Record, 4> records = {{
{1, utf16be(family)},
{2, utf16be("Regular")},
{4, utf16be(family)},
{6, utf16be(family)},
};
{6, utf16be(postscript)},
}};

const auto count = static_cast<std::uint16_t>(records.size());
const std::uint16_t storage_offset = 6 + count * 12;
Expand All @@ -294,14 +330,28 @@ std::string font::serialize_name(const std::string &font_name) {
bs::put_u16_be(table, storage_offset);

std::string storage;
std::map<std::string, std::uint16_t> offsets;
for (const Record &record : records) {
if (!std::in_range<std::uint16_t>(record.value.size())) {
throw std::runtime_error("sfnt: name exceeds 16-bit length");
}
auto found = offsets.find(record.value);
if (found == offsets.end()) {
if (!std::in_range<std::uint16_t>(storage.size())) {
throw std::runtime_error("sfnt: name storage exceeds 16-bit offsets");
}
found =
offsets
.emplace(record.value, static_cast<std::uint16_t>(storage.size()))
.first;
storage += record.value;
}
bs::put_u16_be(table, 3); // platformID: Windows
bs::put_u16_be(table, 1); // encodingID: Unicode BMP
bs::put_u16_be(table, 0x409); // languageID: en-US
bs::put_u16_be(table, record.name_id);
bs::put_u16_be(table, static_cast<std::uint16_t>(record.value.size()));
bs::put_u16_be(table, static_cast<std::uint16_t>(storage.size()));
storage += record.value;
bs::put_u16_be(table, found->second);
}
table += storage;
return table;
Expand All @@ -327,7 +377,7 @@ std::string font::serialize_os2(const std::uint16_t units_per_em,
const std::uint16_t first_char,
const std::uint16_t last_char) {
// Scale a default expressed against a 1000-unit em to this font's em.
const auto em = [&](const int per_1000) {
const auto em = [&](const std::int32_t per_1000) {
return static_cast<std::uint16_t>(per_1000 * units_per_em / 1000);
};
// Ascender/descender from the bounding box, falling back to 0.8/0.2 em when
Expand Down
48 changes: 10 additions & 38 deletions src/odr/internal/font/sfnt_transform.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,43 +12,27 @@ namespace sfnt {
class SfntFont;
}

/// The deterministic Private Use Area code point the uniform re-encode assigns
/// to glyph @p glyph: `U+E000 + glyph` while the BMP PUA lasts (6400 slots),
/// then Supplementary PUA-A from `U+F0000`. Every consumer derives the
/// displayed code point from this — no per-font table needed.
/// Map a glyph to the BMP Private Use Area, spilling into PUA-A after 6400.
[[nodiscard]] char32_t pua_code_point(std::uint16_t glyph) noexcept;

/// The uniform PUA re-encode as a `cmap` model: `pua_code_point(glyph) ->
/// glyph` for every glyph below @p glyph_count, plus @p extra's real-Unicode
/// entries alongside it (keys must be in the BMP and outside `U+E000..U+F8FF`
/// so they never shadow a glyph's own PUA code point; the caller guarantees
/// this). An @p extra entry naming a glyph the font does not have is dropped —
/// a single `cmap` reference past `numGlyphs` makes the OTS sanitizer reject
/// the *entire* font, so every glyph would render as tofu.
/// Map every glyph to its PUA code point, plus valid-glyph entries from @p
/// extra. Extra keys must be BMP code points outside U+E000..U+F8FF. An entry
/// past `numGlyphs` is dropped, because OTS then rejects the whole font.
[[nodiscard]] std::map<char32_t, std::uint16_t>
pua_cmap(std::uint16_t glyph_count,
const std::map<char32_t, std::uint16_t> &extra = {});

/// Serialize an SFNT from its tables, computing the table directory, per-table
/// checksums and `head.checkSumAdjustment`. @p tables need not be sorted; a
/// `head` table is patched in place with the final adjustment.
/// Serialize unordered tables with their checksums and head.checkSumAdjustment.
[[nodiscard]] std::string
build_sfnt(std::uint32_t sfnt_version,
std::vector<std::pair<std::string, std::string>> tables);

/// Serialize a code point -> glyph map into a `cmap` table: one Windows
/// subtable, format 4 (3,1) while the map stays in the BMP, format 12 (3,10)
/// once it reaches beyond it. Both split the map into maximal runs of
/// consecutive code points mapping to consecutive glyphs, so format 4 never
/// needs a `glyphIdArray`.
/// Serialize a Unicode-to-glyph map using format 4, or format 12 when the
/// code points or encoded length exceed format 4's limits.
[[nodiscard]] std::string
serialize_cmap(const std::map<char32_t, std::uint16_t> &map);

// OTS (the font sanitizer in Chrome/Firefox) rejects a font that is missing
// `name`, `post` or `OS/2` — the browser then drops the `@font-face` and
// renders tofu — and PDF-embedded fonts routinely omit all three. The three
// below synthesize minimal stand-ins; OTS reconciles the fields they leave
// neutral (e.g. `fsSelection` against `head`).
// PDF subsets may omit these tables, which browsers require.

/// nameIDs 1/2/4/6 (family / subfamily / full / PostScript), Windows (3,1),
/// UTF-16BE. An empty @p font_name falls back to "ODR Font".
Expand All @@ -57,25 +41,13 @@ serialize_cmap(const std::map<char32_t, std::uint16_t> &map);
/// Version-3.0 `post`: the header alone, i.e. "no glyph names".
[[nodiscard]] std::string serialize_post();

/// Version-4 `OS/2`, neutral weight and width. @p units_per_em scales the
/// sub/superscript and strikeout defaults; @p y_min / @p y_max are the font
/// bounding box (ascender/descender fall back to 0.8/0.2 em when degenerate);
/// @p first_char / @p last_char bound the `cmap`.
/// Synthesize OS/2 metrics from the em size, bounding box and cmap bounds.
[[nodiscard]] std::string serialize_os2(std::uint16_t units_per_em,
std::int16_t y_min, std::int16_t y_max,
std::uint16_t first_char,
std::uint16_t last_char);

/// Re-encode @p font in place for the browser: replace its `cmap` with a fresh
/// map from the deterministic PUA code points (`pua_code_point`) to *every*
/// glyph, so the font renders every glyph — including ones the original `cmap`
/// never reached — when loaded via `@font-face`. `font.write()` then emits the
/// re-encoded SFNT.
///
/// @p extra adds real-Unicode -> glyph entries alongside the PUA range (see
/// `pua_cmap`), so a run whose codes map 1:1 to those scalars can render the
/// *real* Unicode directly — the HTML layer then collapses its dual
/// selectable/visible spans into one.
/// Replace the cmap with pua_cmap(glyph_count, extra) for browser rendering.
void reencode_to_pua(sfnt::SfntFont &font,
const std::map<char32_t, std::uint16_t> &extra = {});

Expand Down
53 changes: 52 additions & 1 deletion test/src/internal/font/sfnt_transform.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ TEST(SfntTransform, write_synthesizes_name_when_absent) {
const std::string out = reencoded(font);
const std::optional<std::string> name = table(out, "name");
ASSERT_TRUE(name.has_value());
EXPECT_EQ(parse(out).name(), "ODR Font");
EXPECT_EQ(parse(out).name(), "ODR-Font");
EXPECT_EQ(file_checksum(out), 0xb1b0afbaU);
}

Expand Down Expand Up @@ -381,3 +381,54 @@ TEST(SfntTransform, write_keeps_existing_os2) {
// The source `OS/2` is copied through verbatim, not replaced.
EXPECT_EQ(table(out, "OS/2"), original_os2);
}

TEST(SfntTransform, cmap_format_tracks_encoded_size_and_unicode_range) {
std::map<char32_t, std::uint16_t> mapping;
for (char32_t code = 0; code < 8188 * 2; code += 2) {
mapping.emplace(code, 1);
}
EXPECT_EQ(bs::read_u16_be(serialize_cmap(mapping).substr(12)), 4);
mapping.emplace(8188 * 2, 1);
const std::string large = serialize_cmap(mapping);
EXPECT_EQ(bs::read_u16_be(large.substr(12)), 12);
EXPECT_EQ(parse(build_sfnt(0x00010000, {{"cmap", large}})).cmap(), mapping);

std::map<char32_t, std::uint16_t> edge{{0xFFFF, 1}};
EXPECT_EQ(bs::read_u16_be(serialize_cmap(edge).substr(12)), 12);
edge.emplace(0x10FFFF, 2);
const std::string cmap = serialize_cmap(edge);
EXPECT_EQ(parse(build_sfnt(0x00010000, {{"cmap", cmap}})).cmap(), edge);
EXPECT_THROW((void)serialize_cmap({{0x110000, 1}}), std::runtime_error);
}

TEST(SfntTransform, names_preserve_unicode_and_bound_postscript_names) {
const std::string name = serialize_name("Fönt / 😀");
const std::uint16_t storage = bs::read_u16_be(name.substr(4));
const auto value = [&](const std::size_t index) {
const std::size_t record = 6 + index * 12;
return name.substr(storage + bs::read_u16_be(name.substr(record + 10)),
bs::read_u16_be(name.substr(record + 8)));
};
const std::string expected("\0F\0\xf6\0n\0t\0 \0/\0 \xd8\x3d\xde\0", 18);
EXPECT_EQ(value(0), expected);
EXPECT_EQ(value(2), expected);
EXPECT_EQ(parse(build_sfnt(0x00010000, {{"name", name}})).name(),
"F-nt-----");
const std::string long_name = serialize_name(std::string(30000, 'A'));
EXPECT_EQ(parse(build_sfnt(0x00010000, {{"name", long_name}})).name(),
std::string(63, 'A'));
EXPECT_LT(long_name.size(), 61000);
EXPECT_THROW((void)serialize_name(std::string(32768, 'A')),
std::runtime_error);
EXPECT_EQ(
parse(build_sfnt(0x00010000, {{"name", serialize_name("A\xff")}})).name(),
"A-");
}

TEST(SfntTransform, rejects_unrepresentable_table_directories) {
EXPECT_THROW((void)build_sfnt(0x00010000, {{"abc", ""}}), std::runtime_error);
EXPECT_THROW((void)build_sfnt(0x00010000, {{"head", ""}, {"head", ""}}),
std::runtime_error);
const std::vector<std::pair<std::string, std::string>> tables(4096);
EXPECT_THROW((void)build_sfnt(0x00010000, tables), std::runtime_error);
}
Loading