diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e680b690..4be4f31d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/odr/internal/font/sfnt_font.cpp b/src/odr/internal/font/sfnt_font.cpp index 3a612be83..0e1347874 100644 --- a/src/odr/internal/font/sfnt_font.cpp +++ b/src/odr/internal/font/sfnt_font.cpp @@ -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 = diff --git a/src/odr/internal/font/sfnt_transform.cpp b/src/odr/internal/font/sfnt_transform.cpp index e0abb0591..9e41864fc 100644 --- a/src/odr/internal/font/sfnt_transform.cpp +++ b/src/odr/internal/font/sfnt_transform.cpp @@ -2,15 +2,21 @@ #include #include +#include #include +#include #include #include #include #include #include +#include +#include #include +#include + namespace odr::internal::font { namespace { @@ -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> 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; }); @@ -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::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(data.size()); pad4(data); if (tag == "head" && data.size() >= 12) { @@ -201,9 +220,11 @@ serialize_cmap_format12(const std::map &map) { } // namespace std::string font::serialize_cmap(const std::map &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); } @@ -228,6 +249,9 @@ std::string font::serialize_cmap(const std::map &map) { // Mandatory terminator segment: 0xFFFF -> 0 (delta 1). segments.push_back({0xffff, 0xffff, 1}); + if (segments.size() > (std::numeric_limits::max() - 16) / 8) { + return serialize_cmap_format12(map); + } const auto seg_count = static_cast(segments.size()); const auto [search_range, entry_selector, range_shift] = search_hints(seg_count, 2); @@ -266,24 +290,36 @@ std::string font::serialize_cmap(const std::map &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(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(c); + } struct Record { std::uint16_t name_id; std::string value; }; - const std::vector records = { + const std::array records = {{ {1, utf16be(family)}, {2, utf16be("Regular")}, {4, utf16be(family)}, - {6, utf16be(family)}, - }; + {6, utf16be(postscript)}, + }}; const auto count = static_cast(records.size()); const std::uint16_t storage_offset = 6 + count * 12; @@ -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 offsets; for (const Record &record : records) { + if (!std::in_range(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(storage.size())) { + throw std::runtime_error("sfnt: name storage exceeds 16-bit offsets"); + } + found = + offsets + .emplace(record.value, static_cast(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(record.value.size())); - bs::put_u16_be(table, static_cast(storage.size())); - storage += record.value; + bs::put_u16_be(table, found->second); } table += storage; return table; @@ -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(per_1000 * units_per_em / 1000); }; // Ascender/descender from the bounding box, falling back to 0.8/0.2 em when diff --git a/src/odr/internal/font/sfnt_transform.hpp b/src/odr/internal/font/sfnt_transform.hpp index 31b84d5f1..aa0a19977 100644 --- a/src/odr/internal/font/sfnt_transform.hpp +++ b/src/odr/internal/font/sfnt_transform.hpp @@ -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 pua_cmap(std::uint16_t glyph_count, const std::map &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> 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 &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". @@ -57,25 +41,13 @@ serialize_cmap(const std::map &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 &extra = {}); diff --git a/test/src/internal/font/sfnt_transform.cpp b/test/src/internal/font/sfnt_transform.cpp index aac0c6b34..5e6f426d1 100644 --- a/test/src/internal/font/sfnt_transform.cpp +++ b/test/src/internal/font/sfnt_transform.cpp @@ -333,7 +333,7 @@ TEST(SfntTransform, write_synthesizes_name_when_absent) { const std::string out = reencoded(font); const std::optional 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); } @@ -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 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 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> tables(4096); + EXPECT_THROW((void)build_sfnt(0x00010000, tables), std::runtime_error); +}