From a015a141839cf68930e2128ecb8a4946c759a21e Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Fri, 7 Aug 2026 23:29:31 +0200 Subject: [PATCH 01/13] fix(font): resolve CID-keyed CFF widths through /FDArray and /FDSelect A CID-keyed CFF keeps its Private DICTs per FD, so nominalWidthX stayed 0 and every charstring width resolved against it. Real subset fonts came out with negative advances that wrapped into an advanceWidthMax of 65431 - 65 em. Also hardens the binary readers these share: Type1 charstring and /Subrs bounds, CFF INDEX offset monotonicity, CFB name length and directory cycles, zip short-read reporting, AES-GCM length validation and the SVM header skip. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01D8GkXFfKmN7KaCoeYcAayS --- src/odr/internal/cfb/cfb_impl.cpp | 49 +----- src/odr/internal/cfb/cfb_impl.hpp | 15 -- src/odr/internal/cfb/cfb_util.cpp | 15 +- src/odr/internal/cfb/cfb_util.hpp | 11 +- src/odr/internal/crypto/crypto_util.cpp | 12 +- src/odr/internal/crypto/crypto_util.hpp | 3 + src/odr/internal/font/cff_builder.hpp | 15 +- src/odr/internal/font/cff_font.cpp | 167 +++++++++++++++------ src/odr/internal/font/cff_font.hpp | 25 ++- src/odr/internal/font/cff_transform.cpp | 21 +-- src/odr/internal/font/cff_transform.hpp | 18 +-- src/odr/internal/font/sfnt_font.cpp | 38 ++--- src/odr/internal/font/sfnt_transform.cpp | 38 ++--- src/odr/internal/font/sfnt_transform.hpp | 93 +++++------- src/odr/internal/font/type1_charstring.cpp | 27 +++- src/odr/internal/font/type1_charstring.hpp | 3 +- src/odr/internal/font/type1_font.cpp | 31 ++-- src/odr/internal/font/type1_font.hpp | 11 +- src/odr/internal/font/type1_transform.hpp | 8 +- src/odr/internal/svm/svm_format.cpp | 16 +- src/odr/internal/svm/svm_to_svg.cpp | 8 +- src/odr/internal/text/text_util.cpp | 9 +- src/odr/internal/zip/zip_archive.cpp | 13 +- src/odr/internal/zip/zip_util.cpp | 20 ++- 24 files changed, 352 insertions(+), 314 deletions(-) diff --git a/src/odr/internal/cfb/cfb_impl.cpp b/src/odr/internal/cfb/cfb_impl.cpp index 16ff813af..26375e91c 100644 --- a/src/odr/internal/cfb/cfb_impl.cpp +++ b/src/odr/internal/cfb/cfb_impl.cpp @@ -39,6 +39,14 @@ impl::CompoundFileEntry impl::parse_entry(std::istream &in) { namespace odr::internal::cfb::impl { std::string CompoundFileEntry::get_name() const { + // [MS-CFB] 2.6.1: `name_len` counts bytes including the terminating NUL and + // never exceeds the 64-byte name field. + if (name_len > sizeof(name)) { + throw CfbFileCorrupted(); + } + if (name_len < 2) { + return {}; + } return internal::util::string::c16str_to_string(name, name_len - 2); } @@ -109,47 +117,6 @@ void CompoundFileReader::read_file(std::istream &in, } } -void CompoundFileReader::visit_descendants( - std::istream &in, const CompoundFileEntry &entry, - const std::int32_t max_level, const EnumFilesCallback &callback) const { - const CompoundFileEntry child_entry = parse_entry(in, entry.child_id); - visit_descendants(in, child_entry, 0, max_level, std::u16string(), callback); -} - -void CompoundFileReader::visit_descendants( - std::istream &in, const CompoundFileEntry &entry, - const std::int32_t current_level, const std::int32_t max_level, - const std::u16string &dir, const EnumFilesCallback &callback) const { - if (max_level > 0 && current_level >= max_level) { - return; - } - - callback(entry, dir, current_level + 1); - - if (entry.child_id != NullId) { - const CompoundFileEntry child = parse_entry(in, entry.child_id); - - std::u16string new_dir = dir; - new_dir.append(entry.name, entry.name_len / 2); - visit_descendants(in, child, current_level + 1, max_level, new_dir, - callback); - } - - if (entry.left_sibling_id != NullId) { - const CompoundFileEntry left_sibling = - parse_entry(in, entry.left_sibling_id); - visit_descendants(in, left_sibling, current_level, max_level, dir, - callback); - } - - if (entry.right_sibling_id != NullId) { - const CompoundFileEntry right_sibling = - parse_entry(in, entry.right_sibling_id); - visit_descendants(in, right_sibling, current_level, max_level, dir, - callback); - } -} - void CompoundFileReader::read_stream(std::istream &in, const SectorOffset §or_offset, char *buffer, std::uint64_t length) const { diff --git a/src/odr/internal/cfb/cfb_impl.hpp b/src/odr/internal/cfb/cfb_impl.hpp index 9ce8b17c3..826f38b15 100644 --- a/src/odr/internal/cfb/cfb_impl.hpp +++ b/src/odr/internal/cfb/cfb_impl.hpp @@ -3,7 +3,6 @@ #include #include -#include #include namespace odr::internal::cfb::impl { @@ -68,10 +67,6 @@ CompoundFileEntry parse_entry(std::istream &in); class CompoundFileReader final { public: - using EnumFilesCallback = - std::function; - static constexpr auto MAGIC = "\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1"; explicit CompoundFileReader(std::istream &in, std::uint64_t file_size); @@ -100,10 +95,6 @@ class CompoundFileReader final { void read_file(std::istream &in, const CompoundFileEntry &entry, std::uint64_t offset, char *buffer, std::uint64_t len) const; - void visit_descendants(std::istream &in, const CompoundFileEntry &entry, - int max_level, - const EnumFilesCallback &callback) const; - private: struct SectorOffset final { Sector sector; @@ -112,12 +103,6 @@ class CompoundFileReader final { static constexpr Sector MaxSector = 0xFFFFFFFA; - // Enum entries with same level, including 'entry' itself - void visit_descendants(std::istream &in, const CompoundFileEntry &entry, - std::int32_t current_level, std::int32_t max_level, - const std::u16string &dir, - const EnumFilesCallback &callback) const; - void read_stream(std::istream &in, const SectorOffset §or_offset, char *buffer, std::uint64_t length) const; diff --git a/src/odr/internal/cfb/cfb_util.cpp b/src/odr/internal/cfb/cfb_util.cpp index f1ad9643b..00f6a2a87 100644 --- a/src/odr/internal/cfb/cfb_util.cpp +++ b/src/odr/internal/cfb/cfb_util.cpp @@ -1,5 +1,7 @@ #include +#include + #include #include @@ -172,6 +174,13 @@ std::optional Archive::Entry::child() const { m_path.join(RelPath(child.get_name()))); } +void Archive::Iterator::enter_(Entry entry) { + if (!m_visited.insert(entry.m_entry_id).second) { + throw CfbFileCorrupted(); + } + m_entry = std::move(entry); +} + void Archive::Iterator::dig_left_() { if (!m_entry.has_value()) { return; @@ -183,7 +192,7 @@ void Archive::Iterator::dig_left_() { break; } m_ancestors.push_back(*m_entry); - m_entry = left; + enter_(*left); } } @@ -194,7 +203,7 @@ void Archive::Iterator::next_() { if (const std::optional child = m_entry->child(); child.has_value()) { m_directories.push_back(*m_entry); - m_entry = child; + enter_(*child); dig_left_(); return; } @@ -208,7 +217,7 @@ void Archive::Iterator::next_flat_() { } if (const std::optional right = m_entry->right(); right.has_value()) { - m_entry = right; + enter_(*right); dig_left_(); return; } diff --git a/src/odr/internal/cfb/cfb_util.hpp b/src/odr/internal/cfb/cfb_util.hpp index 6e45d4246..ab9b78a3d 100644 --- a/src/odr/internal/cfb/cfb_util.hpp +++ b/src/odr/internal/cfb/cfb_util.hpp @@ -6,9 +6,13 @@ #include #include +#include #include #include +#include +#include #include +#include namespace odr::internal::cfb::impl { class CompoundFileReader; @@ -102,12 +106,17 @@ class Archive final : public std::enable_shared_from_this { std::optional m_entry; std::vector m_ancestors; std::vector m_directories; + std::set m_visited; Iterator() = default; - explicit Iterator(const Entry &root_entry) : m_entry{root_entry} { + explicit Iterator(const Entry &root_entry) { + enter_(root_entry); dig_left_(); } + /// Move onto a not-yet-visited entry; a repeat means a cyclic + /// child/sibling link ([MS-CFB] 2.6.4) that would never terminate. + void enter_(Entry entry); void dig_left_(); void next_(); void next_flat_(); diff --git a/src/odr/internal/crypto/crypto_util.cpp b/src/odr/internal/crypto/crypto_util.cpp index 013da24a8..1588a1c70 100644 --- a/src/odr/internal/crypto/crypto_util.cpp +++ b/src/odr/internal/crypto/crypto_util.cpp @@ -188,14 +188,20 @@ std::string util::decrypt_aes_gcm(const std::string &key, const std::string &iv, const std::string &input) { // follows https://www.w3.org/TR/xmlenc-core1/#sec-AES-GCM - if (std::strncmp(iv.data(), input.data(), iv.size()) != 0) { + const std::size_t iv_size = iv.size(); + constexpr std::size_t mac_size = 16; + + // The input is IV || ciphertext || tag; anything shorter would wrap + // `cipher_size`. `memcmp`, not `strncmp` — both operands are binary. + if (input.size() < iv_size + mac_size) { + throw std::runtime_error("GCM input too short"); + } + if (std::memcmp(iv.data(), input.data(), iv_size) != 0) { throw std::runtime_error("IV mismatch"); } std::string result(input.size(), '\0'); - const std::size_t iv_size = iv.size(); - constexpr std::size_t mac_size = 16; const std::size_t cipher_size = input.size() - iv_size - mac_size; auto *message = reinterpret_cast(result.data()); const auto *mac = diff --git a/src/odr/internal/crypto/crypto_util.hpp b/src/odr/internal/crypto/crypto_util.hpp index 1d1bd52e6..20b335989 100644 --- a/src/odr/internal/crypto/crypto_util.hpp +++ b/src/odr/internal/crypto/crypto_util.hpp @@ -38,6 +38,9 @@ std::string decrypt_aes_cbc(const std::string &key, const std::string &iv, /// size). Needed by the PDF R 6 hardened-hash algorithm (ISO 32000-2 2.B). std::string encrypt_aes_cbc(const std::string &key, const std::string &iv, const std::string &input); +/// AES-GCM per XML Encryption 1.1 §5.2.4: @p input is `iv || ciphertext || +/// 16-byte tag` and must repeat @p iv. Throws if it does not, if @p input is +/// too short to hold both, or if the tag fails to verify. std::string decrypt_aes_gcm(const std::string &key, const std::string &iv, const std::string &input); std::string decrypt_triple_des(const std::string &key, const std::string &iv, diff --git a/src/odr/internal/font/cff_builder.hpp b/src/odr/internal/font/cff_builder.hpp index fc554bd52..50f8da694 100644 --- a/src/odr/internal/font/cff_builder.hpp +++ b/src/odr/internal/font/cff_builder.hpp @@ -21,17 +21,12 @@ struct BuilderGlyph { /// browser) needs: Header, Name INDEX, Top DICT (FontBBox + /// charset/CharStrings/Private offsets), String INDEX (every glyph name, SID /// 391+), an empty Global Subr INDEX, the CharStrings INDEX, a format-0 charset -/// and a Private DICT -/// (`defaultWidthX`/`nominalWidthX`). Glyph 0 is the implicit `.notdef`; the -/// caller orders @p glyphs so glyph 0 is `.notdef`. +/// and a Private DICT (`defaultWidthX`/`nominalWidthX`). The caller orders +/// @p glyphs so glyph 0 is the implicit `.notdef`. /// -/// This is the assembly target for the Type1 -> CFF path: the translated Type2 -/// charstrings go in here, the result feeds `CffFont` + `wrap_to_otf`. No -/// `FontMatrix` is emitted, so the font is 1000 -/// units/em (the Type1 default); a non-default matrix is a follow-up. -/// -/// Offsets in the Top DICT use the fixed-width 5-byte integer form so the -/// layout resolves in a single pass. +/// No `FontMatrix` is emitted, so the font is 1000 units/em (the Type1 +/// default); a non-default matrix is a follow-up. Top DICT offsets use the +/// fixed-width 5-byte integer form so the layout resolves in a single pass. [[nodiscard]] std::string build_cff(std::string_view name, const std::vector &glyphs, double default_width, double nominal_width, diff --git a/src/odr/internal/font/cff_font.cpp b/src/odr/internal/font/cff_font.cpp index e890df3ef..c66a2fa2d 100644 --- a/src/odr/internal/font/cff_font.cpp +++ b/src/odr/internal/font/cff_font.cpp @@ -4,10 +4,12 @@ #include #include +#include +#include #include #include -#include #include +#include #include #include #include @@ -33,6 +35,8 @@ enum Operator : std::uint16_t { op_font_bbox = 5, op_ros = 1230, op_charstring_type = 1206, + op_fd_array = 1236, + op_fd_select = 1237, }; /// Number-operand byte markers shared by DICT data and Type2 charstrings @@ -96,6 +100,12 @@ namespace bs = util::byte_string; /// stay exact within double range (CFF integers fit). using Dict = std::map>; +/// A DICT operand as an FWord. Clamping keeps an out-of-range operand out of +/// the undefined double -> int16 conversion. +[[nodiscard]] std::int16_t to_fword(const double value) { + return static_cast(std::clamp(value, -32768.0, 32767.0)); +} + /// Parse a CFF DICT occupying the byte range [begin, end) of @p d. [[nodiscard]] Dict parse_dict(const std::string_view d, const std::uint32_t begin, @@ -181,28 +191,28 @@ enum PredefinedCharset : std::uint32_t { /// Predefined Expert charset: glyph -> SID (Adobe TN #5176 Appendix C). The /// ISOAdobe charset is the identity (SID == GID) so it needs no table. -constexpr std::uint16_t expert_charset[] = { - 0, 1, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 13, 14, - 15, 99, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 27, 28, - 249, 250, 251, 252, 253, 254, 255, 256, 257, 258, 259, 260, 261, 262, - 263, 264, 265, 266, 109, 110, 267, 268, 269, 270, 271, 272, 273, 274, - 275, 276, 277, 278, 279, 280, 281, 282, 283, 284, 285, 286, 287, 288, - 289, 290, 291, 292, 293, 294, 295, 296, 297, 298, 299, 300, 301, 302, - 303, 304, 305, 306, 307, 308, 309, 310, 311, 312, 313, 314, 315, 316, - 317, 318, 158, 155, 163, 319, 320, 321, 322, 323, 324, 325, 326, 150, - 164, 169, 327, 328, 329, 330, 331, 332, 333, 334, 335, 336, 337, 338, - 339, 340, 341, 342, 343, 344, 345, 346, 347, 348, 349, 350, 351, 352, - 353, 354, 355, 356, 357, 358, 359, 360, 361, 362, 363, 364, 365, 366, - 367, 368, 369, 370, 371, 372, 373, 374, 375, 376, 377, 378}; +constexpr auto expert_charset = std::to_array( + {0, 1, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 13, 14, + 15, 99, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 27, 28, + 249, 250, 251, 252, 253, 254, 255, 256, 257, 258, 259, 260, 261, 262, + 263, 264, 265, 266, 109, 110, 267, 268, 269, 270, 271, 272, 273, 274, + 275, 276, 277, 278, 279, 280, 281, 282, 283, 284, 285, 286, 287, 288, + 289, 290, 291, 292, 293, 294, 295, 296, 297, 298, 299, 300, 301, 302, + 303, 304, 305, 306, 307, 308, 309, 310, 311, 312, 313, 314, 315, 316, + 317, 318, 158, 155, 163, 319, 320, 321, 322, 323, 324, 325, 326, 150, + 164, 169, 327, 328, 329, 330, 331, 332, 333, 334, 335, 336, 337, 338, + 339, 340, 341, 342, 343, 344, 345, 346, 347, 348, 349, 350, 351, 352, + 353, 354, 355, 356, 357, 358, 359, 360, 361, 362, 363, 364, 365, 366, + 367, 368, 369, 370, 371, 372, 373, 374, 375, 376, 377, 378}); /// Predefined ExpertSubset charset: glyph -> SID (Adobe TN #5176 Appendix C). -constexpr std::uint16_t expert_subset_charset[] = { - 0, 1, 231, 232, 235, 236, 237, 238, 13, 14, 15, 99, 239, 240, 241, - 242, 243, 244, 245, 246, 247, 248, 27, 28, 249, 250, 251, 253, 254, 255, - 256, 257, 258, 259, 260, 261, 262, 263, 264, 265, 266, 109, 110, 267, 268, - 269, 270, 272, 300, 301, 302, 305, 314, 315, 158, 155, 163, 320, 321, 322, - 323, 324, 325, 326, 150, 164, 169, 327, 328, 329, 330, 331, 332, 333, 334, - 335, 336, 337, 338, 339, 340, 341, 342, 343, 344, 345, 346}; +constexpr auto expert_subset_charset = std::to_array( + {0, 1, 231, 232, 235, 236, 237, 238, 13, 14, 15, 99, 239, 240, 241, + 242, 243, 244, 245, 246, 247, 248, 27, 28, 249, 250, 251, 253, 254, 255, + 256, 257, 258, 259, 260, 261, 262, 263, 264, 265, 266, 109, 110, 267, 268, + 269, 270, 272, 300, 301, 302, 305, 314, 315, 158, 155, 163, 320, 321, 322, + 323, 324, 325, 326, 150, 164, 169, 327, 328, 329, 330, 331, 332, 333, 334, + 335, 336, 337, 338, 339, 340, 341, 342, 343, 344, 345, 346}); } // namespace @@ -237,6 +247,11 @@ std::vector CffFont::read_index(const std::uint32_t offset, for (std::uint16_t i = 1; i <= count; ++i) { const std::uint32_t next = read_be(d, offset_array + i * off_size, off_size); + // The offset array is non-decreasing (Adobe TN #5176 §5); otherwise the + // member length would wrap. + if (next < prev) { + throw std::runtime_error("cff: non-monotonic INDEX offsets"); + } members.push_back({data_base + prev, next - prev}); prev = next; } @@ -295,10 +310,8 @@ void CffFont::parse_top_dict(const Range top_dict) { if (const auto it = dict.find(op_font_bbox); it != dict.end() && it->second.size() == 4) { - m_bbox = {static_cast(it->second[0]), - static_cast(it->second[1]), - static_cast(it->second[2]), - static_cast(it->second[3])}; + m_bbox = {to_fword(it->second[0]), to_fword(it->second[1]), + to_fword(it->second[2]), to_fword(it->second[3])}; } if (const auto it = dict.find(op_char_strings); it != dict.end()) { @@ -311,7 +324,15 @@ void CffFont::parse_top_dict(const Range top_dict) { it != dict.end() && it->second.size() == 2) { const auto size = static_cast(it->second[0]); const auto offset = static_cast(it->second[1]); - parse_private_dict({offset, size}); + m_widths = parse_private_dict({offset, size}); + } + + // A CID-keyed font keeps its Private DICTs per FD, not in the Top DICT. + if (const auto it = dict.find(op_fd_array); it != dict.end()) { + parse_fd_array(static_cast(it->second.at(0))); + } + if (const auto it = dict.find(op_fd_select); it != dict.end()) { + parse_fd_select(static_cast(it->second.at(0))); } // charset: an offset past the predefined ids (0/1/2) is a custom charset; @@ -339,33 +360,87 @@ void CffFont::load_predefined_charset(const std::uint32_t id) { } return; } - const std::uint16_t *table = nullptr; - std::size_t size = 0; - if (id == predefined_charset_expert) { - table = expert_charset; - size = std::size(expert_charset); - } else { // predefined_charset_expert_subset - table = expert_subset_charset; - size = std::size(expert_subset_charset); - } - for (std::uint16_t gid = 1; gid < glyphs && gid < size; ++gid) { + const std::span table = + id == predefined_charset_expert + ? std::span(expert_charset) + : std::span(expert_subset_charset); + for (std::uint16_t gid = 1; gid < glyphs && gid < table.size(); ++gid) { m_charset[gid] = table[gid]; } } -void CffFont::parse_private_dict(const Range private_dict) { +CffFont::Widths CffFont::parse_private_dict(const Range private_dict) const { + Widths widths; if (private_dict.length == 0) { - return; + return widths; } const std::string_view d{m_data}; const Dict dict = parse_dict(d, private_dict.offset, private_dict.offset + private_dict.length); if (const auto it = dict.find(op_default_width_x); it != dict.end()) { - m_default_width = it->second.at(0); + widths.default_width = it->second.at(0); } if (const auto it = dict.find(op_nominal_width_x); it != dict.end()) { - m_nominal_width = it->second.at(0); + widths.nominal_width = it->second.at(0); } + return widths; +} + +void CffFont::parse_fd_array(const std::uint32_t offset) { + const std::string_view d{m_data}; + std::uint32_t end = 0; + for (const Range font_dict : read_index(offset, end)) { + const Dict dict = + parse_dict(d, font_dict.offset, font_dict.offset + font_dict.length); + Widths widths; + if (const auto it = dict.find(op_private); + it != dict.end() && it->second.size() == 2) { + widths = parse_private_dict({static_cast(it->second[1]), + static_cast(it->second[0])}); + } + m_fd_widths.push_back(widths); + } +} + +void CffFont::parse_fd_select(const std::uint32_t offset) { + const std::string_view d{m_data}; + const std::uint16_t glyphs = glyph_count(); + const std::uint8_t format = u8(d, offset); + + if (format == 0) { + m_fd_select.reserve(glyphs); + for (std::uint16_t gid = 0; gid < glyphs; ++gid) { + m_fd_select.push_back(u8(d, offset + 1 + gid)); + } + return; + } + if (format != 3) { + throw std::runtime_error("cff: unknown FDSelect format"); + } + + // format 3: ranges of [first, next first) sharing one FD, then a sentinel + const auto ranges = static_cast(read_be(d, offset + 1, 2)); + m_fd_select.assign(glyphs, 0); + for (std::uint16_t i = 0; i < ranges; ++i) { + const std::uint32_t entry = offset + 3 + 3 * i; + const auto first = static_cast(read_be(d, entry, 2)); + const std::uint8_t fd = u8(d, entry + 2); + const auto next = static_cast(read_be(d, entry + 3, 2)); + for (std::uint32_t gid = first; gid < next && gid < glyphs; ++gid) { + m_fd_select[gid] = fd; + } + } +} + +const CffFont::Widths & +CffFont::widths_for_glyph(const std::uint16_t glyph) const { + if (!m_fd_widths.empty()) { + const std::size_t fd = glyph < m_fd_select.size() ? m_fd_select[glyph] : 0; + if (fd < m_fd_widths.size()) { + return m_fd_widths[fd]; + } + } + return m_widths; } void CffFont::parse_charset(const std::uint32_t offset) { @@ -510,11 +585,13 @@ bool CffFont::symbolic() const noexcept { FontBBox CffFont::bounding_box() const noexcept { return m_bbox; } std::uint16_t CffFont::advance_width(const std::uint16_t glyph) const { - if (const std::optional width = charstring_width(glyph); - width.has_value()) { - return static_cast(m_nominal_width + *width); - } - return static_cast(m_default_width); + const Widths &widths = widths_for_glyph(glyph); + const std::optional width = charstring_width(glyph); + const double advance = + width.has_value() ? widths.nominal_width + *width : widths.default_width; + // An advance is a uFWord; clamping keeps a hostile Private DICT out of the + // undefined double -> uint16 conversion. + return static_cast(std::clamp(advance, 0.0, 65535.0)); } std::uint16_t CffFont::glyph_for_code_point(const char32_t code_point) const { diff --git a/src/odr/internal/font/cff_font.hpp b/src/odr/internal/font/cff_font.hpp index 53249f07f..609a8e100 100644 --- a/src/odr/internal/font/cff_font.hpp +++ b/src/odr/internal/font/cff_font.hpp @@ -49,8 +49,7 @@ class CffFont final : public abstract::Font { [[nodiscard]] bool is_cid_keyed() const noexcept; /// The glyph's PostScript name (non-CID fonts), empty when unresolved (a - /// CID-keyed font, an out-of-range glyph, or a standard-string SID until the - /// standard-strings table lands — see the .cpp TODO). + /// CID-keyed font, an out-of-range glyph, or an unknown SID). [[nodiscard]] std::string glyph_name(std::uint16_t glyph) const; /// charset glyph -> CID (CID-keyed fonts), `0` when out of range or not @@ -69,9 +68,24 @@ class CffFont final : public abstract::Font { std::uint32_t length{}; }; + /// The two Private DICT entries a charstring's width is resolved against + /// (Adobe TN #5177 "width"). + struct Widths { + double default_width{}; + double nominal_width{}; + }; + void parse(); void parse_top_dict(Range top_dict); - void parse_private_dict(Range private_dict); + [[nodiscard]] Widths parse_private_dict(Range private_dict) const; + /// Parse `/FDArray`'s per-FD Private DICTs and `/FDSelect` (CID-keyed fonts; + /// Adobe TN #5176 §19). Without these a CID font resolves every width against + /// a `nominalWidthX` of 0. + void parse_fd_array(std::uint32_t offset); + void parse_fd_select(std::uint32_t offset); + /// The Private DICT widths governing @p glyph — its FD's for a CID-keyed + /// font, the Top DICT's otherwise. + [[nodiscard]] const Widths &widths_for_glyph(std::uint16_t glyph) const; void parse_charset(std::uint32_t offset); /// Materialize a predefined charset (id 0 ISOAdobe / 1 Expert / 2 /// ExpertSubset) into `m_charset`, used when `/charset` is a predefined id or @@ -100,8 +114,9 @@ class CffFont final : public abstract::Font { std::vector m_strings; // String INDEX members (SID 391+) std::vector m_charset; // glyph -> SID (or CID, CID-keyed) - double m_default_width{}; - double m_nominal_width{}; + Widths m_widths; // Top DICT Private DICT + std::vector m_fd_widths; // per FDArray entry, CID-keyed only + std::vector m_fd_select; // glyph -> FDArray index }; } // namespace odr::internal::font::cff diff --git a/src/odr/internal/font/cff_transform.cpp b/src/odr/internal/font/cff_transform.cpp index 34c456531..13a4dc16f 100644 --- a/src/odr/internal/font/cff_transform.cpp +++ b/src/odr/internal/font/cff_transform.cpp @@ -99,24 +99,9 @@ std::string cff::wrap_to_otf(const CffFont &font, const std::map &extra) { const std::uint16_t glyphs = font.glyph_count(); - // The uniform PUA re-encode: pua_code_point(glyph) -> glyph over every glyph. - // Glyphs past the 6400-slot BMP PUA overflow into Supplementary PUA-A, and - // serialize_cmap emits a format-12 subtable to cover them. - std::map pua; - for (std::uint16_t glyph = 0; glyph < glyphs; ++glyph) { - pua[pua_code_point(glyph)] = glyph; - } - // Real-Unicode entries: caller guarantees BMP, non-PUA keys, so these never - // collide with the PUA range filled above. A glyph id the font does not have - // is dropped: `glyph_for_code` can fall back to "code as GID" (ISO 32000-1 - // 9.6.6.4) and yield an out-of-range index, and a single cmap reference past - // `numGlyphs` makes the OTS sanitizer reject the *entire* font (so every - // glyph would render as a tofu box, not just the unmappable code). - for (const auto &[code, glyph] : extra) { - if (glyph < glyphs) { - pua[code] = glyph; - } - } + // Glyphs past the 6400-slot BMP PUA overflow into Supplementary PUA-A, which + // serialize_cmap covers with a format-12 subtable. + const std::map pua = pua_cmap(glyphs, extra); std::uint16_t advance_width_max = 0; for (std::uint16_t glyph = 0; glyph < glyphs; ++glyph) { diff --git a/src/odr/internal/font/cff_transform.hpp b/src/odr/internal/font/cff_transform.hpp index 2c85593a7..838e5d04a 100644 --- a/src/odr/internal/font/cff_transform.hpp +++ b/src/odr/internal/font/cff_transform.hpp @@ -14,21 +14,11 @@ class CffFont; /// sanitizer) require, so this synthesizes the skeleton — `head` / `hhea` / /// `maxp` (v0.5) / `hmtx` / `name` / `post` / `OS/2` — from the /// `abstract::Font` facts and embeds the original CFF verbatim as the `CFF ` -/// table (pass-through, no outline interpretation). The `cmap` is the **uniform -/// PUA re-encode**: `pua_code_point(glyph) -> glyph` over every -/// glyph, so the font renders every glyph — including charset-unreachable ones -/// — when loaded via `@font-face`, matching the PUA code points the PDF HTML -/// layer emits. +/// table (pass-through, no outline interpretation). /// -/// @p extra adds real-Unicode -> glyph entries alongside the PUA range, 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). Keys must be in the BMP and outside the PUA (`U+E000..U+F8FF`); -/// the caller guarantees this. The PUA range is always kept as a fallback. -/// -/// Reuses the `sfnt_transform` serializers (`build_sfnt`, `serialize_cmap`, -/// `serialize_post`, `serialize_os2`). Throws `std::runtime_error` if the glyph -/// count exceeds the BMP PUA capacity (6400). +/// The `cmap` is `pua_cmap(glyph_count, extra)`, so the font renders every +/// glyph — including charset-unreachable ones — at the PUA code points the PDF +/// HTML layer emits. [[nodiscard]] std::string wrap_to_otf(const CffFont &font, const std::map &extra = {}); diff --git a/src/odr/internal/font/sfnt_font.cpp b/src/odr/internal/font/sfnt_font.cpp index da0d820a2..bf598e066 100644 --- a/src/odr/internal/font/sfnt_font.cpp +++ b/src/odr/internal/font/sfnt_font.cpp @@ -18,9 +18,7 @@ namespace bs = util::byte_string; namespace { -// SFNT enumerations (OpenType spec). Values are the on-disk codes; casting a -// raw `u16` to one and switching/comparing keeps the magic numbers in one -// place. +// The enumerators below are the on-disk codes (OpenType spec). /// `cmap`/`name` platform IDs. enum class PlatformId : std::uint16_t { @@ -203,8 +201,7 @@ void SfntFont::read_directory(const std::string_view sfnt) { : FontFormat::truetype; const std::uint16_t num_tables = bs::read_u16_be(sfnt.substr(4)); - // The offset table is 12 bytes (sfntVersion, numTables, then the three search - // hints); each of the `num_tables` directory entries is 16 bytes: tag(4), + // Past the 12 byte offset table, each directory entry is 16 bytes: tag(4), // checkSum(4), offset(4), length(4). for (std::uint16_t i = 0; i < num_tables; ++i) { const std::size_t entry = 12 + static_cast(i) * 16; @@ -309,9 +306,6 @@ void SfntFont::read_cmap_subtable(const std::string_view s) { m_cmap[code] = glyph; }; - // Every subtable format has a fixed-layout header, so each field is read at - // its known offset (matching the rest of this file). Format 4's arrays are - // variable-length, but each one's offset is a fixed function of segCount. const auto read_u16_vector = [](const std::string_view v, const std::size_t count) { std::vector out; @@ -330,11 +324,10 @@ void SfntFont::read_cmap_subtable(const std::string_view s) { } break; } - case CmapFormat::segment_mapping: { // segment mapping to delta values - // format(0), length(2), language(4), segCountX2(6), then searchRange(8), - // entrySelector(10), rangeShift(12). The four parallel segs-sized arrays - // follow: endCode(14), reservedPad, startCode, idDelta, idRangeOffset, each - // starting at a fixed offset once segCount is known. + case CmapFormat::segment_mapping: { + // format(0), length(2), language(4), segCountX2(6), 3 search hints(8..12), + // then the parallel segs-sized arrays endCode(14), reservedPad, startCode, + // idDelta, idRangeOffset. const std::uint16_t length = bs::read_u16_be(s.substr(2)); const std::size_t segs = bs::read_u16_be(s.substr(6)) / 2U; const std::vector end_codes = @@ -345,9 +338,8 @@ void SfntFont::read_cmap_subtable(const std::string_view s) { read_u16_vector(s.substr(16 + 4 * segs), segs); const std::vector id_range_offsets = read_u16_vector(s.substr(16 + 6 * segs), segs); - // Whatever remains of the subtable is the glyphIdArray that non-zero - // idRangeOffsets index into; preload it so the inner loop is a plain - // lookup. The header up to this point is 16 + 8*segs bytes. + // What remains past the 16 + 8*segs byte header is the glyphIdArray that + // non-zero idRangeOffsets index into. const std::size_t header = 16 + 8 * segs; if (length < header) { throw std::runtime_error("sfnt: cmap format 4 subtable too short"); @@ -505,22 +497,16 @@ std::string SfntFont::write() const { } tables.emplace_back("cmap", serialize_cmap(m_cmap)); - // A `post` table is required by OTS; PDF-embedded TrueType fonts often omit - // it. Synthesize a minimal one so the browser accepts the `@font-face`. + // OTS rejects a font missing `post` / `name` / `OS/2`, and PDF-embedded + // TrueType routinely omits all three; synthesize the missing ones so the + // browser accepts the `@font-face`. build_sfnt sorts the directory, so the + // insertion order here does not matter. if (!m_tables.contains("post")) { tables.emplace_back("post", serialize_post()); } - - // `name` is likewise required by OTS and likewise often omitted from - // TrueType subsets. Synthesize a minimal one (falls back to "ODR Font" when - // the font carries no name at all). if (!m_tables.contains("name")) { tables.emplace_back("name", serialize_name(m_name)); } - - // `OS/2` is likewise required by OTS and likewise often omitted. Synthesize - // it from the cmap bounds and bounding box (build_sfnt sorts the directory, - // so the insertion order here does not matter). if (!m_tables.contains("OS/2")) { std::uint16_t first_char = 0; std::uint16_t last_char = 0; diff --git a/src/odr/internal/font/sfnt_transform.cpp b/src/odr/internal/font/sfnt_transform.cpp index d6cae88e0..1b192bc3a 100644 --- a/src/odr/internal/font/sfnt_transform.cpp +++ b/src/odr/internal/font/sfnt_transform.cpp @@ -150,12 +150,11 @@ font::build_sfnt(const std::uint32_t sfnt_version, return out; } -/// Format-12 `cmap` subtable (segmented coverage): sequential map groups over -/// the full Unicode range, each `[startCharCode, endCharCode]` mapping to -/// `startGlyphID + (code - startCharCode)`. Used when the map reaches beyond -/// the BMP (glyphs overflowing into Supplementary PUA-A), which format 4 cannot -/// express. Wrapped in a (Windows, Unicode full repertoire) encoding record. -static std::string +namespace { + +/// Format-12 `cmap` subtable (segmented coverage), in a (Windows, Unicode full +/// repertoire) encoding record — what format 4 cannot express. +std::string serialize_cmap_format12(const std::map &map) { struct Group { std::uint32_t start_code; @@ -196,6 +195,8 @@ serialize_cmap_format12(const std::map &map) { return cmap; } +} // 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. @@ -375,29 +376,32 @@ std::string font::serialize_os2(const std::uint16_t units_per_em, return os2; } -void font::reencode_to_pua(sfnt::SfntFont &font, - const std::map &extra) { +std::map +font::pua_cmap(const std::uint16_t glyph_count, + const std::map &extra) { // A uint16 glyph id always fits: `pua_code_point` maps the BMP PUA first // (6400 slots) then overflows into Supplementary PUA-A, whose combined // `pua_capacity` (71934) exceeds any 16-bit glyph count. static_assert(std::numeric_limits::max() < pua_capacity); std::map map; - for (std::uint16_t glyph = 0; glyph < font.glyph_count(); ++glyph) { + for (std::uint16_t glyph = 0; glyph < glyph_count; ++glyph) { map[pua_code_point(glyph)] = glyph; } - // Real-Unicode entries: caller guarantees BMP, non-PUA keys, so these never - // collide with the PUA range filled above. A glyph id the font does not have - // is dropped: `glyph_for_code` can fall back to "code as GID" (ISO 32000-1 - // 9.6.6.4) and yield an out-of-range index, and a single cmap reference past - // `numGlyphs` makes the OTS sanitizer reject the *entire* font (so every - // glyph would render as a tofu box, not just the unmappable code). + // The caller guarantees BMP, non-PUA keys, so these never collide with the + // range filled above. An out-of-range glyph is dropped: `glyph_for_code` can + // fall back to "code as GID" (ISO 32000-1 9.6.6.4). for (const auto &[code, glyph] : extra) { - if (glyph < font.glyph_count()) { + if (glyph < glyph_count) { map[code] = glyph; } } - font.set_cmap(std::move(map)); + return map; +} + +void font::reencode_to_pua(sfnt::SfntFont &font, + const std::map &extra) { + font.set_cmap(pua_cmap(font.glyph_count(), extra)); } } // namespace odr::internal diff --git a/src/odr/internal/font/sfnt_transform.hpp b/src/odr/internal/font/sfnt_transform.hpp index 31a0a9b1d..31b84d5f1 100644 --- a/src/odr/internal/font/sfnt_transform.hpp +++ b/src/odr/internal/font/sfnt_transform.hpp @@ -12,68 +12,52 @@ namespace sfnt { class SfntFont; } -/// The deterministic Private Use Area code point that the uniform -/// re-encode assigns to glyph @p glyph: -/// `U+E000 + glyph` in the BMP PUA. Every consumer (the specimen page, the PDF -/// `@font-face` emission) derives the displayed code point from this — no -/// per-font table needed. +/// 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. [[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. +[[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`, and return the assembled bytes. -/// @p tables need not be sorted; a `head` table is patched in place with the -/// final adjustment. -/// -/// The whole-file checksum is additive over the 4-byte-aligned table layout, so -/// it equals `checksum(header+directory) + Σ checksum(table)` — computed -/// analytically and the adjustment patched into `head` before the bytes are -/// concatenated. +/// checksums and `head.checkSumAdjustment`. @p tables need not be sorted; a +/// `head` table is patched in place with the final adjustment. [[nodiscard]] std::string build_sfnt(std::uint32_t sfnt_version, std::vector> tables); -/// Serialize a code point -> glyph map into a `cmap` table. -/// -/// LIMITATION: emits a single Windows (3,1) format-4 subtable, so only BMP code -/// points (<= U+FFFF) are supported; a map containing a code point beyond the -/// BMP (which would require a format-12 subtable) throws `std::runtime_error`. -/// Within the BMP there is no further restriction: the map is split into -/// maximal arithmetic runs (consecutive code points mapping to consecutive -/// glyphs, `idRangeOffset = 0`), and a run of length one is trivially -/// arithmetic, so the `glyphIdArray` path is never needed. This covers the -/// uniform PUA re-encode (one run) and ordinary remaps; format-12 / multi-plane -/// coverage is a follow-up. +/// 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`. [[nodiscard]] std::string serialize_cmap(const std::map &map); -/// Serialize a minimal `name` table: nameIDs 1/2/4/6 (family / subfamily / -/// full / PostScript), Windows platform (3,1), UTF-16BE. -/// -/// OTS (the font sanitizer in Chrome/Firefox) requires `name` and rejects the -/// whole font when it is absent — like `post` and `OS/2`, PDF-embedded fonts -/// routinely omit it (a bare CFF has none; TrueType subsets often drop it). -/// An empty @p font_name falls back to "ODR Font". +// 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`). + +/// nameIDs 1/2/4/6 (family / subfamily / full / PostScript), Windows (3,1), +/// UTF-16BE. An empty @p font_name falls back to "ODR Font". [[nodiscard]] std::string serialize_name(const std::string &font_name); -/// Serialize a minimal version-3.0 `post` table (header only, no glyph names). -/// -/// OTS (the font sanitizer in Chrome/Firefox) lists `post` among the tables an -/// SFNT must carry and rejects the whole font when it is absent — the browser -/// then drops the `@font-face` and renders tofu. PDF-embedded TrueType fonts -/// routinely omit `post` (the viewer needs no glyph names), so a font copied -/// through verbatim would be rejected. Format 3.0 declares "no glyph names", -/// which is all a re-encoded display font needs. +/// Version-3.0 `post`: the header alone, i.e. "no glyph names". [[nodiscard]] std::string serialize_post(); -/// Serialize a minimal version-4 `OS/2` table. -/// -/// OTS (the font sanitizer in Chrome/Firefox) also requires `OS/2` and rejects -/// the whole font when it is absent — like `post`, PDF-embedded TrueType fonts -/// routinely omit it (the viewer takes its metrics elsewhere), so a font copied -/// through verbatim is rejected. The synthesized table carries neutral weight -/// and width (regular, medium) with the vertical metrics and character-range -/// bounds derived from the arguments; OTS reconciles the remaining fields (e.g. -/// the `fsSelection` style bits against `head`). @p units_per_em scales the +/// 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`. @@ -88,15 +72,10 @@ serialize_cmap(const std::map &map); /// 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, 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). Keys must be in the BMP and outside the PUA (`U+E000..U+F8FF`) so -/// they never shadow a glyph's own PUA code point; the caller guarantees this. -/// The PUA range is always kept as a fallback. -/// -/// Throws `std::runtime_error` if the glyph count exceeds the BMP PUA capacity -/// (6400); multi-plane PUA spill-over is a follow-up. +/// @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. void reencode_to_pua(sfnt::SfntFont &font, const std::map &extra = {}); diff --git a/src/odr/internal/font/type1_charstring.cpp b/src/odr/internal/font/type1_charstring.cpp index 0ccdccb75..12f084b17 100644 --- a/src/odr/internal/font/type1_charstring.cpp +++ b/src/odr/internal/font/type1_charstring.cpp @@ -2,14 +2,27 @@ #include #include +#include #include +#include #include +#include #include namespace odr::internal::font::type1 { namespace { +/// Charstring byte at @p i; an operand cut short by the end of the charstring +/// is malformed input, not something to read past. +[[nodiscard]] std::uint8_t byte_at(const std::string_view cs, + const std::size_t i) { + if (i >= cs.size()) { + throw std::runtime_error("type1: truncated charstring"); + } + return static_cast(cs[i]); +} + // Type1 charstring operators (single byte; 12 = escape to a two-byte op). enum T1 : std::int32_t { t1_hstem = 1, @@ -127,18 +140,18 @@ class Translator { p += 1; } else if (b <= 250) { value = (static_cast(b) - 247) * 256 + - static_cast(cs[p + 1]) + 108; + byte_at(cs, p + 1) + 108; p += 2; } else if (b <= 254) { value = -(static_cast(b) - 251) * 256 - - static_cast(cs[p + 1]) - 108; + byte_at(cs, p + 1) - 108; p += 2; } else { // 255: Type1 32-bit integer value = static_cast( - (static_cast(cs[p + 1]) << 24) | - (static_cast(cs[p + 2]) << 16) | - (static_cast(cs[p + 3]) << 8) | - static_cast(cs[p + 4])); + static_cast(byte_at(cs, p + 1)) << 24 | + static_cast(byte_at(cs, p + 2)) << 16 | + static_cast(byte_at(cs, p + 3)) << 8 | + byte_at(cs, p + 4)); p += 5; } m_stack.push_back(value); @@ -147,7 +160,7 @@ class Translator { std::int32_t op = b; ++p; if (b == 12) { - op = 1200 + static_cast(cs[p]); + op = 1200 + byte_at(cs, p); ++p; } handle(op, depth); diff --git a/src/odr/internal/font/type1_charstring.hpp b/src/odr/internal/font/type1_charstring.hpp index 86c333ac4..700881dd3 100644 --- a/src/odr/internal/font/type1_charstring.hpp +++ b/src/odr/internal/font/type1_charstring.hpp @@ -24,7 +24,8 @@ struct Type2Charstring { /// charstring, so the caller emits it against the CFF `nominalWidthX`. /// /// Best-effort and display-oriented: hints are dropped (they affect rendering -/// quality, not glyph shape), and unknown operators are skipped. +/// quality, not glyph shape), and unknown operators are skipped. Throws +/// `std::runtime_error` on a charstring that ends mid-operand. [[nodiscard]] Type2Charstring to_type2(std::string_view type1, const std::vector &subrs); diff --git a/src/odr/internal/font/type1_font.cpp b/src/odr/internal/font/type1_font.cpp index 2e4075b0b..8b02b6320 100644 --- a/src/odr/internal/font/type1_font.cpp +++ b/src/odr/internal/font/type1_font.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -56,6 +57,12 @@ namespace { } } +/// A `/FontBBox` number as an FWord. Clamping keeps an out-of-range number out +/// of the undefined double -> int16 conversion. +[[nodiscard]] std::int16_t to_fword(const double value) { + return static_cast(std::clamp(value, -32768.0, 32767.0)); +} + /// Parse the numbers inside the next `[...]` or `{...}` after @p key in @p s. [[nodiscard]] std::vector parse_number_array(const std::string_view s, const std::string_view key) { @@ -180,9 +187,8 @@ void Type1Font::parse_clear(const std::string_view clear) { } if (const std::vector bbox = parse_number_array(clear, "/FontBBox"); bbox.size() == 4) { - m_font_bbox = { - static_cast(bbox[0]), static_cast(bbox[1]), - static_cast(bbox[2]), static_cast(bbox[3])}; + m_font_bbox = {to_fword(bbox[0]), to_fword(bbox[1]), to_fword(bbox[2]), + to_fword(bbox[3])}; } // /Encoding: `StandardEncoding def`, or a custom array built with @@ -212,30 +218,36 @@ void Type1Font::parse_clear(const std::string_view clear) { } void Type1Font::parse_private(const std::string_view decrypted) { - std::int32_t len_iv = 4; + std::size_t len_iv = 4; if (const std::size_t k = decrypted.find("/lenIV"); k != std::string_view::npos) { std::size_t p = k + 6; std::int32_t value = 0; if (parse_int(read_token(decrypted, p), value)) { - len_iv = value; + if (value < 0) { + throw std::runtime_error("type1: negative /lenIV"); + } + len_iv = static_cast(value); } } - m_len_iv = len_iv; + + // /CharStrings starts where /Subrs ends (Subrs precede it). + const std::size_t cs = decrypted.find("/CharStrings"); // /Subrs: entries `dup RD NP`. if (const std::size_t k = decrypted.find("/Subrs"); k != std::string_view::npos) { std::size_t p = k; while ((p = decrypted.find("dup ", p)) != std::string_view::npos) { - // Stop when /CharStrings starts (Subrs precede it). - const std::size_t cs = decrypted.find("/CharStrings"); if (cs != std::string_view::npos && p > cs) { break; } std::size_t q = p + 4; std::int32_t index = 0; - if (!parse_int(read_token(decrypted, q), index) || index < 0) { + // Every subr needs at least one byte of input, so an index at or past the + // input size cannot name one — and must not size the vector. + if (!parse_int(read_token(decrypted, q), index) || index < 0 || + static_cast(index) >= decrypted.size()) { p += 4; continue; } @@ -254,7 +266,6 @@ void Type1Font::parse_private(const std::string_view decrypted) { } // /CharStrings: entries `/ RD ND`. - const std::size_t cs = decrypted.find("/CharStrings"); if (cs == std::string_view::npos) { return; } diff --git a/src/odr/internal/font/type1_font.hpp b/src/odr/internal/font/type1_font.hpp index 68f3a492b..6abac2de0 100644 --- a/src/odr/internal/font/type1_font.hpp +++ b/src/odr/internal/font/type1_font.hpp @@ -22,12 +22,10 @@ struct Glyph { /// /// A Type1 program has three sections: a clear-text header (font dictionary up /// to `eexec`), an `eexec`-encrypted private portion (`/Subrs`, -/// `/CharStrings`), and a zero-padded trailer. This reads the header for -/// `/FontMatrix`, -/// `/FontBBox`, `/Encoding` and `/FontName`, decrypts the `eexec` section -/// (`type1_crypt`) and extracts every glyph's decrypted charstring plus the -/// `/Subrs`. It does **not** yet interpret the charstrings — that is the -/// Type1 -> Type2 translation that follows, feeding 3.4's CFF -> OTF path. +/// `/CharStrings`), and a zero-padded trailer. This reads `/FontMatrix`, +/// `/FontBBox`, `/Encoding` and `/FontName` from the header, decrypts the +/// `eexec` section (`type1_crypt`) and keeps every glyph's decrypted +/// charstring plus the `/Subrs`; interpreting them is `type1_charstring`. /// /// Throws `std::runtime_error` when the program has no `eexec` section or no /// `/CharStrings`. @@ -78,7 +76,6 @@ class Type1Font { bool m_standard_encoding{true}; std::vector m_glyphs; std::vector m_subrs; - std::int32_t m_len_iv{4}; }; } // namespace odr::internal::font::type1 diff --git a/src/odr/internal/font/type1_transform.hpp b/src/odr/internal/font/type1_transform.hpp index 4a45a81a5..fbef4a989 100644 --- a/src/odr/internal/font/type1_transform.hpp +++ b/src/odr/internal/font/type1_transform.hpp @@ -10,11 +10,9 @@ class Type1Font; /// glyph's charstring to Type2 (`to_type2`, flattening the font's `/Subrs`) and /// assemble via the CFF builder, with `.notdef` placed at glyph 0. /// -/// Returns the CFF bytes (not a `cff::CffFont`): a `CffFont` is the -/// parse-and-keep-the-bytes reader, so producing one means parsing this output -/// back — the caller does that (`CffFont{to_cff(font)}`), then `wrap_to_otf` -/// wraps it for the browser, so an embedded Type1 font reuses the entire 3.4 -/// CFF path. Mirrors `cff::wrap_to_otf`, which likewise emits bytes. +/// Returns the CFF bytes, not a `cff::CffFont`: the caller parses them back +/// (`CffFont{to_cff(font)}`) and hands the result to `wrap_to_otf`, so an +/// embedded Type1 font reuses the whole CFF path. [[nodiscard]] std::string to_cff(const Type1Font &font); } // namespace odr::internal::font::type1 diff --git a/src/odr/internal/svm/svm_format.cpp b/src/odr/internal/svm/svm_format.cpp index f9bdc8856..cd198f3d4 100644 --- a/src/odr/internal/svm/svm_format.cpp +++ b/src/odr/internal/svm/svm_format.cpp @@ -4,6 +4,8 @@ #include +#include +#include #include namespace odr::internal { @@ -107,15 +109,15 @@ svm::read_poly_polygon(std::istream &in) { svm::Header svm::read_header(std::istream &in) { Header result; - char magic[6]; - in.read(magic, sizeof(magic)); - if (std::strncmp("VCLMTF", magic, sizeof(magic)) != 0) { + std::array magic{}; + in.read(magic.data(), static_cast(magic.size())); + if (!in || std::memcmp("VCLMTF", magic.data(), magic.size()) != 0) { throw NoSvmFile(); } result.vl = read_version_length(in); - const std::size_t start = in.tellg(); + const std::int64_t start = in.tellg(); read_primitive(in, result.compression_mode); result.map_mode = read_map_mode(in); result.size = read_int_pair(in); @@ -125,8 +127,10 @@ svm::Header svm::read_header(std::istream &in) { read_primitive(in, result.render_graphic_replacements); } - if (const std::size_t left = - result.vl.length - (static_cast(in.tellg()) - start); + // Only skip forward: reading past the declared length would otherwise wrap + // the difference and swallow the rest of the stream. + if (const std::int64_t left = + result.vl.length - (static_cast(in.tellg()) - start); left > 0) { // TODO log header skipping bytes in.ignore(static_cast(left)); diff --git a/src/odr/internal/svm/svm_to_svg.cpp b/src/odr/internal/svm/svm_to_svg.cpp index e2eba563a..b59a1bc3c 100644 --- a/src/odr/internal/svm/svm_to_svg.cpp +++ b/src/odr/internal/svm/svm_to_svg.cpp @@ -208,11 +208,9 @@ void translate_action(const ActionHeader &action_header, std::istream &in, read_stretch_text_action(in, action_header.vl, context.encoding); write_text(out, action.point, action.text, context); } break; - case META_TEXTRECT_ACTION: { - TextRectangleAction action; - // action.read(in, action_header.vl, context.encoding); - // TODO - } break; + case META_TEXTRECT_ACTION: + // TODO read_text_rectangle_action; the caller skips the body meanwhile + break; case META_NULL_ACTION: case META_PUSH_ACTION: case META_POP_ACTION: diff --git a/src/odr/internal/text/text_util.cpp b/src/odr/internal/text/text_util.cpp index 5a88163a0..68c4499fd 100644 --- a/src/odr/internal/text/text_util.cpp +++ b/src/odr/internal/text/text_util.cpp @@ -2,6 +2,7 @@ #include +#include #include #include @@ -9,18 +10,16 @@ namespace odr::internal { std::string text::guess_charset(std::istream &in) { - static constexpr auto BUFFER_SIZE = 4096; - const auto ud = uchardet_new(); - char buffer[BUFFER_SIZE]; + std::array buffer{}; while (true) { - in.read(buffer, BUFFER_SIZE); + in.read(buffer.data(), static_cast(buffer.size())); const auto read = in.gcount(); if (read == 0) { break; } - uchardet_handle_data(ud, buffer, read); + uchardet_handle_data(ud, buffer.data(), read); } uchardet_data_end(ud); diff --git a/src/odr/internal/zip/zip_archive.cpp b/src/odr/internal/zip/zip_archive.cpp index 104a330db..fbdf6cc2d 100644 --- a/src/odr/internal/zip/zip_archive.cpp +++ b/src/odr/internal/zip/zip_archive.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -83,7 +84,8 @@ void ZipArchive::save(std::ostream &out) const { const auto o = static_cast(opaque); o->write(static_cast(buffer), static_cast(size)); - return size; + // A short write has to surface, or the archive is silently truncated. + return o->good() ? size : std::size_t{0}; }; state = mz_zip_writer_init(&archive, 0); if (!state) { @@ -131,13 +133,8 @@ ZipArchive::Iterator ZipArchive::begin() const { ZipArchive::Iterator ZipArchive::end() const { return std::cend(m_entries); } ZipArchive::Iterator ZipArchive::find(const RelPath &path) const { - for (auto it = begin(); it != end(); ++it) { - if (it->path() == path) { - return it; - } - } - - return end(); + return std::ranges::find_if( + *this, [&path](const Entry &entry) { return entry.path() == path; }); } ZipArchive::Iterator diff --git a/src/odr/internal/zip/zip_util.cpp b/src/odr/internal/zip/zip_util.cpp index 18b475631..428d7bb85 100644 --- a/src/odr/internal/zip/zip_util.cpp +++ b/src/odr/internal/zip/zip_util.cpp @@ -5,6 +5,7 @@ #include #include +#include namespace odr::internal::zip::util { @@ -38,6 +39,11 @@ class ReaderBuffer final : public std::streambuf { std::min(m_remaining, m_buffer.size()); const std::uint32_t result = mz_zip_reader_extract_iter_read(m_iter, m_buffer.data(), amount); + // miniz reports a failed inflate as a short read; leaving the get area + // empty while claiming success would spin `underflow` forever. + if (result == 0) { + return traits_type::eof(); + } m_remaining -= result; setg(m_buffer.data(), m_buffer.data(), m_buffer.data() + result); @@ -125,10 +131,10 @@ bool Archive::Entry::is_directory() const { RelPath Archive::Entry::path() const { std::lock_guard lock(m_archive->mutex()); - char filename[MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE]; - mz_zip_reader_get_filename(m_archive->zip(), m_index, filename, - MZ_ZIP_MAX_ARCHIVE_FILENAME_SIZE); - return RelPath(filename); + std::array filename{}; + mz_zip_reader_get_filename(m_archive->zip(), m_index, filename.data(), + static_cast(filename.size())); + return RelPath(filename.data()); } Method Archive::Entry::method() const { @@ -197,9 +203,13 @@ void util::open_from_file(mz_zip_archive &archive, const abstract::File &file, archive.m_pRead = [](void *opaque, const std::uint64_t offset, void *buffer, const std::size_t size) { const auto in = static_cast(opaque); + // Reporting `size` regardless would hand miniz whatever was already in the + // buffer; a short read has to surface as one. Clear first so an earlier + // read past the end does not poison every later seek. + in->clear(); in->seekg(static_cast(offset)); in->read(static_cast(buffer), static_cast(size)); - return size; + return static_cast(in->gcount()); }; const bool state = mz_zip_reader_init( &archive, file.size(), MZ_ZIP_FLAG_DO_NOT_SORT_CENTRAL_DIRECTORY); From ec3039fceb5b37a9f49cb1235379e6643c9bc908 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Fri, 7 Aug 2026 23:29:32 +0200 Subject: [PATCH 02/13] fix(html): escape document-derived text in attributes and style values Link href, bookmark id, image src, font name, font shadow, the four cell border strings and archive entry paths were written into attributes raw, so a quote in document content broke out of the attribute. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01D8GkXFfKmN7KaCoeYcAayS --- src/odr/internal/html/document.cpp | 77 +-- src/odr/internal/html/document_element.cpp | 103 +-- src/odr/internal/html/document_style.cpp | 24 +- src/odr/internal/html/filesystem.cpp | 42 +- src/odr/internal/html/image_file.cpp | 6 +- src/odr/internal/html/pdf_file.cpp | 697 ++++++++------------- src/odr/internal/html/text_file.cpp | 10 +- 7 files changed, 375 insertions(+), 584 deletions(-) diff --git a/src/odr/internal/html/document.cpp b/src/odr/internal/html/document.cpp index d9ff9df81..40554e6df 100644 --- a/src/odr/internal/html/document.cpp +++ b/src/odr/internal/html/document.cpp @@ -23,14 +23,19 @@ namespace odr::internal::html { namespace { +/// Whether the document renders as fixed-size pages on a backdrop rather than +/// reflowing to the viewport. +bool is_paged_content(const Document &document, const HtmlConfig &config) { + return (document.document_type() == DocumentType::text && + config.text_document_margin) || + document.document_type() == DocumentType::presentation || + document.document_type() == DocumentType::drawing; +} + void front(const Document &document, const WritingState &state) { HtmlWriter &out = state.out(); - const bool paged_content = - (document.document_type() == DocumentType::text && - state.config().text_document_margin) || - document.document_type() == DocumentType::presentation || - document.document_type() == DocumentType::drawing; + const bool paged_content = is_paged_content(document, state.config()); out.write_begin(); out.write_header_begin(); @@ -78,13 +83,7 @@ void front(const Document &document, const WritingState &state) { void back(const Document &document, const WritingState &state) { HtmlWriter &out = state.out(); - const bool paged_content = - (document.document_type() == DocumentType::text && - state.config().text_document_margin) || - document.document_type() == DocumentType::presentation || - document.document_type() == DocumentType::drawing; - - if (paged_content) { + if (is_paged_content(document, state.config())) { out.write_element_end("div"); } @@ -323,56 +322,30 @@ class TextHtmlFragment final : public HtmlFragmentBase { } }; -class SlideHtmlFragment final : public HtmlFragmentBase { +/// A fragment rendering one top-level element handle (slide, sheet, page) +/// through its `translate_*` function. +template +class ElementHtmlFragment final : public HtmlFragmentBase { public: - explicit SlideHtmlFragment(std::string name, const std::size_t index, - std::string path, Document document, - const Slide &slide) + explicit ElementHtmlFragment(std::string name, const std::size_t index, + std::string path, Document document, + const Handle &element) : HtmlFragmentBase(std::move(name), index, std::move(path), std::move(document)), - m_slide{slide} {} + m_element{element} {} void write_fragment(HtmlWriter &, WritingState &state) const override { - translate_slide(m_slide, state); + Translate(m_element, state); } private: - Slide m_slide; + Handle m_element; }; -class SheetHtmlFragment final : public HtmlFragmentBase { -public: - explicit SheetHtmlFragment(std::string name, const std::size_t index, - std::string path, Document document, - const Sheet &sheet) - : HtmlFragmentBase(std::move(name), index, std::move(path), - std::move(document)), - m_sheet{sheet} {} - - void write_fragment(HtmlWriter &, WritingState &state) const override { - translate_sheet(m_sheet, state); - } - -private: - Sheet m_sheet; -}; - -class PageHtmlFragment final : public HtmlFragmentBase { -public: - explicit PageHtmlFragment(std::string name, const std::size_t index, - std::string path, Document document, - const Page &page) - : HtmlFragmentBase(std::move(name), index, std::move(path), - std::move(document)), - m_page{page} {} - - void write_fragment(HtmlWriter &, WritingState &state) const override { - translate_page(m_page, state); - } - -private: - Page m_page; -}; +using SlideHtmlFragment = ElementHtmlFragment; +using SheetHtmlFragment = ElementHtmlFragment; +using PageHtmlFragment = ElementHtmlFragment; } // namespace } // namespace odr::internal::html diff --git a/src/odr/internal/html/document_element.cpp b/src/odr/internal/html/document_element.cpp index 3376f346e..1630a9b9b 100644 --- a/src/odr/internal/html/document_element.cpp +++ b/src/odr/internal/html/document_element.cpp @@ -23,40 +23,58 @@ void html::translate_children(const ElementRange &range, void html::translate_element(const Element &element, const WritingState &state) { - if (element.type() == ElementType::text) { + switch (element.type()) { + case ElementType::text: translate_text(element, state); - } else if (element.type() == ElementType::line_break) { + break; + case ElementType::line_break: translate_line_break(element, state); - } else if (element.type() == ElementType::paragraph) { + break; + case ElementType::paragraph: translate_paragraph(element, state); - } else if (element.type() == ElementType::span) { + break; + case ElementType::span: translate_span(element, state); - } else if (element.type() == ElementType::link) { + break; + case ElementType::link: translate_link(element, state); - } else if (element.type() == ElementType::bookmark) { + break; + case ElementType::bookmark: translate_bookmark(element, state); - } else if (element.type() == ElementType::list) { + break; + case ElementType::list: translate_list(element, state); - } else if (element.type() == ElementType::list_item) { + break; + case ElementType::list_item: translate_list_item(element, state); - } else if (element.type() == ElementType::table) { + break; + case ElementType::table: translate_table(element, state); - } else if (element.type() == ElementType::frame) { + break; + case ElementType::frame: translate_frame(element, state); - } else if (element.type() == ElementType::image) { + break; + case ElementType::image: translate_image(element, state); - } else if (element.type() == ElementType::rect) { + break; + case ElementType::rect: translate_rect(element, state); - } else if (element.type() == ElementType::line) { + break; + case ElementType::line: translate_line(element, state); - } else if (element.type() == ElementType::circle) { + break; + case ElementType::circle: translate_circle(element, state); - } else if (element.type() == ElementType::custom_shape) { + break; + case ElementType::custom_shape: translate_custom_shape(element, state); - } else if (element.type() == ElementType::group) { + break; + case ElementType::group: translate_children(element.children(), state); - } else { + break; + default: // TODO log + break; } } @@ -196,36 +214,34 @@ void html::translate_sheet(const Sheet &sheet, const WritingState &state) { state.out().write_element_end("table"); } -void html::translate_slide(const Slide &slide, const WritingState &state) { +namespace { + +/// A slide or a drawing page: the master page's content under the page's own, +/// inside one outer page box. There is no inner (margin) box — unlike a text +/// document, both anchor their children absolutely at page coordinates. +template +void translate_page_like(const PageLike &page, + const html::WritingState &state) { state.out().write_element_begin( - "div", HtmlElementOptions() - .set_class("odr-page-outer") - .set_style(translate_outer_page_style(slide.page_layout()))); - // state.out().write_element_begin( - // "div", HtmlElementOptions().set_class("odr-page-inner").set_style( - // translate_inner_page_style(slide.page_layout()))); + "div", + html::HtmlElementOptions() + .set_class("odr-page-outer") + .set_style(html::translate_outer_page_style(page.page_layout()))); - translate_master_page(slide.master_page(), state); - translate_children(slide.children(), state); + html::translate_master_page(page.master_page(), state); + html::translate_children(page.children(), state); - // state.out().write_element_end("div"); state.out().write_element_end("div"); } -void html::translate_page(const Page &page, const WritingState &state) { - state.out().write_element_begin( - "div", HtmlElementOptions() - .set_class("odr-page-outer") - .set_style(translate_outer_page_style(page.page_layout()))); - // state.out().write_element_begin( - // "div", HtmlElementOptions().set_class("odr-page-inner").set_style( - // translate_inner_page_style(page.page_layout()))); +} // namespace - translate_master_page(page.master_page(), state); - translate_children(page.children(), state); +void html::translate_slide(const Slide &slide, const WritingState &state) { + translate_page_like(slide, state); +} - // state.out().write_element_end("div"); - state.out().write_element_end("div"); +void html::translate_page(const Page &page, const WritingState &state) { + translate_page_like(page, state); } void html::translate_master_page(const MasterPage &masterPage, @@ -307,7 +323,7 @@ void html::translate_link(const Element &element, const WritingState &state) { state.out().write_element_begin( "a", HtmlElementOptions().set_inline(true).set_attributes( - HtmlAttributesVector{{"href", link.href()}})); + HtmlAttributesVector{{"href", escape_attribute(link.href())}})); translate_children(link.children(), state); state.out().write_element_end("a"); } @@ -317,8 +333,9 @@ void html::translate_bookmark(const Element &element, const Bookmark bookmark = element.as_bookmark(); state.out().write_element_begin( - "a", HtmlElementOptions().set_inline(true).set_attributes( - HtmlAttributesVector{{"id", bookmark.name()}})); + "a", + HtmlElementOptions().set_inline(true).set_attributes( + HtmlAttributesVector{{"id", escape_attribute(bookmark.name())}})); state.out().write_element_end("a"); } @@ -425,7 +442,7 @@ void html::translate_image(const Element &element, const WritingState &state) { .set_attributes([&](const HtmlAttributeWriterCallback &clb) { clb("alt", "Error: image not found or unsupported"); if (resource_location.has_value()) { - clb("src", resource_location.value()); + clb("src", escape_attribute(resource_location.value())); } else { clb("src", [&](std::ostream &o) { // reached only for internal images, which have a file diff --git a/src/odr/internal/html/document_style.cpp b/src/odr/internal/html/document_style.cpp index fb87a4218..4883ae084 100644 --- a/src/odr/internal/html/document_style.cpp +++ b/src/odr/internal/html/document_style.cpp @@ -142,7 +142,9 @@ std::string html::translate_text_style(const TextStyle &text_style) { std::string result; if (const std::optional font_name = text_style.font_name; font_name.has_value()) { - result.append("font-family:").append(*font_name).append(";"); + result.append("font-family:") + .append(escape_attribute(std::string(*font_name))) + .append(";"); } if (const std::optional font_size = text_style.font_size; font_size.has_value()) { @@ -168,7 +170,9 @@ std::string html::translate_text_style(const TextStyle &text_style) { } if (const std::optional font_shadow = text_style.font_shadow; font_shadow.has_value()) { - result.append("text-shadow:").append(*font_shadow).append(";"); + result.append("text-shadow:") + .append(escape_attribute(*font_shadow)) + .append(";"); } if (const std::optional font_color = text_style.font_color; font_color.has_value()) { @@ -329,21 +333,29 @@ html::translate_table_cell_style(const TableCellStyle &table_cell_style) { if (const std::optional border_right = table_cell_style.border.right; border_right.has_value()) { - result.append("border-right:").append(*border_right).append(";"); + result.append("border-right:") + .append(escape_attribute(*border_right)) + .append(";"); } if (const std::optional border_top = table_cell_style.border.top; border_top.has_value()) { - result.append("border-top:").append(*border_top).append(";"); + result.append("border-top:") + .append(escape_attribute(*border_top)) + .append(";"); } if (const std::optional border_left = table_cell_style.border.left; border_left.has_value()) { - result.append("border-left:").append(*border_left).append(";"); + result.append("border-left:") + .append(escape_attribute(*border_left)) + .append(";"); } if (const std::optional border_bottom = table_cell_style.border.bottom; border_bottom.has_value()) { - result.append("border-bottom:").append(*border_bottom).append(";"); + result.append("border-bottom:") + .append(escape_attribute(*border_bottom)) + .append(";"); } if (const std::optional text_rotation = table_cell_style.text_rotation; diff --git a/src/odr/internal/html/filesystem.cpp b/src/odr/internal/html/filesystem.cpp index 24f75447e..ea2bb719d 100644 --- a/src/odr/internal/html/filesystem.cpp +++ b/src/odr/internal/html/filesystem.cpp @@ -28,11 +28,7 @@ class HtmlServiceImpl final : public HtmlService { [[nodiscard]] const HtmlViews &list_views() const override { return m_views; } [[nodiscard]] bool exists(const std::string &path) const override { - if (path == "files.html") { - return true; - } - - return false; + return path == "files.html"; } [[nodiscard]] std::string mimetype(const std::string &path) const override { @@ -81,46 +77,38 @@ class HtmlServiceImpl final : public HtmlService { out.write_body_begin(); + const auto span = [&out](const HtmlWritable &content) { + out.write_element_begin("span"); + out.write_raw(content); + out.write_element_end("span"); + }; + for (; !file_walker.end(); file_walker.next()) { - Path file_path(file_walker.path()); + const Path file_path(file_walker.path()); const bool is_file = file_walker.is_file(); out.write_element_begin("p"); - out.write_element_begin("span"); - out.write_raw(file_path.string()); - out.write_element_end("span"); - - out.write_element_begin("span"); - out.write_raw(" "); - out.write_element_end("span"); - - out.write_element_begin("span"); - out.write_raw(file_walker.is_file() ? "file" : "directory"); - out.write_element_end("span"); + span(escape_text(file_path.string())); + span(" "); + span(is_file ? "file" : "directory"); if (is_file) { - out.write_element_begin("span"); - out.write_raw(" "); - out.write_element_end("span"); + span(" "); File file = m_filesystem.open(file_path.string()); - out.write_element_begin("span"); - out.write_raw(std::to_string(file.size())); - out.write_element_end("span"); + span(std::to_string(file.size())); if (const std::unique_ptr stream = file.stream(); stream != nullptr) { - out.write_element_begin("span"); - out.write_raw(" "); - out.write_element_end("span"); + span(" "); out.write_element_begin( "a", HtmlElementOptions().set_attributes(HtmlAttributesVector{ {"href", file_to_url(*stream, "application/octet-stream")}, - {"download", file_path.basename()}})); + {"download", escape_attribute(file_path.basename())}})); out.write_raw("download"); out.write_element_end("a"); } diff --git a/src/odr/internal/html/image_file.cpp b/src/odr/internal/html/image_file.cpp index 433a3ab65..7a8da9b34 100644 --- a/src/odr/internal/html/image_file.cpp +++ b/src/odr/internal/html/image_file.cpp @@ -65,11 +65,7 @@ class HtmlServiceImpl final : public HtmlService { [[nodiscard]] const HtmlViews &list_views() const override { return m_views; } [[nodiscard]] bool exists(const std::string &path) const override { - if (path == "image.html") { - return true; - } - - return false; + return path == "image.html"; } [[nodiscard]] std::string mimetype(const std::string &path) const override { diff --git a/src/odr/internal/html/pdf_file.cpp b/src/odr/internal/html/pdf_file.cpp index a2a5f062f..f099bb2d5 100644 --- a/src/odr/internal/html/pdf_file.cpp +++ b/src/odr/internal/html/pdf_file.cpp @@ -43,17 +43,15 @@ namespace odr::internal::html { namespace { -/// Round to 0.01 user-space units; sub-precision beyond that is invisible and -/// the extra digits add up across a page full of path data. +/// Round to 0.01 units; finer precision is invisible and the extra digits add +/// up across a page full of path data. double round2(const double v) { return std::round(v * 100.0) / 100.0; } constexpr double pt_to_in = 1.0 / 72.0; -/// Serialize a transform as an SVG `matrix(...)`. Only the translation (e, f) -/// is rounded — it lives in page-box units where 1/100 px is plenty; the linear -/// part (a..d) keeps full precision so small scale/skew factors aren't -/// quantized to zero. Used for `transform`, `gradientTransform` and -/// `patternTransform`. +/// A transform as an SVG `matrix(...)`. Only the translation is rounded; the +/// linear part keeps full precision so small scale/skew factors aren't +/// quantized to zero. std::string svg_matrix(const util::math::Transform2D &m) { std::ostringstream f; f << "matrix(" << m.a << ',' << m.b << ',' << m.c << ',' << m.d << ',' @@ -73,14 +71,12 @@ struct LinkOut { ///< `target="_self"`) }; -/// Maps a link's 0-based target page index to the href navigating to it: a -/// "#pN" anchor in the combined document, a page-view file name in a -/// standalone page. Returns "" to drop the link (target page not rendered). +/// A link's 0-based target page index to the href navigating to it. Returns "" +/// to drop the link (target page not rendered). using PageHref = std::function; -/// Resolves a link annotation's destination to a page: a `page-object -> -/// 0-based index` map plus the catalog's named-destination table (`/Dests` -/// dictionary and the `/Names /Dests` name tree, ISO 32000-1 12.3.2.3). +/// Resolves a link annotation's destination to a page index, via the catalog's +/// named-destination tables (ISO 32000-1 12.3.2.3). struct LinkResolver { pdf::DocumentParser &parser; std::map page_index; @@ -180,25 +176,22 @@ LinkResolver build_link_resolver(pdf::DocumentParser &parser, return resolver; } -/// Whether a `/URI` action target is safe to emit as an `href`. A PDF is -/// untrusted input, so active schemes (`javascript:`, `data:`, `vbscript:`, …) -/// must not become a clickable link that executes in the generated document. -/// We allow only the common navigable schemes plus scheme-less (relative) -/// references. Embedded ASCII whitespace/control bytes are ignored when reading -/// the scheme, matching browsers that strip them before dispatch (so -/// `java\tscript:` cannot slip through). +/// Whether a `/URI` action target is safe to emit as an `href`: only the +/// navigable schemes plus scheme-less (relative) references, so `javascript:` +/// and friends cannot become a clickable link. Embedded whitespace/control +/// bytes are ignored while reading the scheme, as browsers strip them before +/// dispatch (`java\tscript:` must not slip through). bool is_safe_uri(std::string_view uri) { std::string scheme; for (const char ch : uri) { const auto c = static_cast(ch); if (ch == ':') { - for (char &s : scheme) { - s = static_cast(std::tolower(static_cast(s))); - } - static constexpr std::string_view allowed[] = {"http", "https", "mailto", - "ftp", "ftps", "tel"}; - return std::find(std::begin(allowed), std::end(allowed), scheme) != - std::end(allowed); + std::ranges::transform(scheme, scheme.begin(), [](const char s) { + return static_cast(std::tolower(static_cast(s))); + }); + static constexpr std::array allowed = { + "http", "https", "mailto", "ftp", "ftps", "tel"}; + return std::ranges::find(allowed, scheme) != allowed.end(); } if (ch == '/' || ch == '?' || ch == '#') { return true; // path/query/fragment reached first -> relative reference @@ -215,10 +208,8 @@ bool is_safe_uri(std::string_view uri) { return true; // no ':' -> relative reference } -/// Resolve a page's `/Link` annotations (ISO 32000-1 12.5.6.5) to positioned -/// overlays: a `/URI` action becomes an external link, a `/GoTo` action or a -/// direct `/Dest` an internal link via `page_href`. `to_box` maps PDF user -/// space to the page box (points, y-down). +/// A page's `/Link` annotations (ISO 32000-1 12.5.6.5) as positioned overlays. +/// `to_box` maps PDF user space to the page box (points, y-down). std::vector collect_page_links(const pdf::Page &page, const util::math::Transform2D &to_box, LinkResolver &resolver, @@ -294,8 +285,7 @@ void write_page_links(HtmlWriter &out, const std::vector &links) { for (const LinkOut &link : links) { std::ostringstream a; // Internal `#pN` links must override the document's `` so they scroll within the rendered PDF instead of - // opening a new copy. + // target="_blank">` or they open a new copy instead of scrolling. a << " &rgb) { return std::move(s).str(); } -/// Map a PDF blend-mode name (`/ExtGState` `/BM`, ISO 32000-1 11.3.5) to its -/// CSS `mix-blend-mode` keyword. CSS derives its blend modes from PDF, so the -/// separable and non-separable modes map 1:1 (camelCase -> kebab-case). Returns -/// "" for `Normal` and for any unrecognized name (rendered normal), so a caller -/// can skip the property entirely. +/// A PDF blend-mode name (`/BM`, ISO 32000-1 11.3.5) as its CSS +/// `mix-blend-mode` keyword — CSS took its blend modes from PDF, so they map +/// 1:1. "" for `Normal` and for anything unrecognized, so callers can skip the +/// property entirely. std::string blend_mode_to_css(const std::string &blend_mode) { static const std::unordered_map map = { {"Multiply", "multiply"}, {"Screen", "screen"}, @@ -376,9 +363,8 @@ std::string blend_mode_to_css(const std::string &blend_mode) { } /// The CSS declaration a non-embedded font renders through: its substitute -/// `font-family` stack plus the weight/style implied by the `/BaseFont` name -/// and `/FontDescriptor` flags. Interned as an `ff` atomic class on the -/// fallback (`font == 0`) runs of either text mode. +/// family stack plus the weight/style implied by `/BaseFont` and the +/// `/FontDescriptor` flags. std::string font_substitute_declaration(const pdf::FontSubstitute &substitute) { std::string declaration = "font-family:" + substitute.css_family; if (substitute.bold) { @@ -390,10 +376,8 @@ std::string font_substitute_declaration(const pdf::FontSubstitute &substitute) { return declaration; } -/// The `local(...)` sources of a CSS `font-family` stack, dropping the generic -/// keywords an `@font-face src` cannot name. Returns e.g. -/// "local('Times New Roman'),local(Times)" for "'Times New Roman',Times,serif", -/// or "" when the stack names no concrete font (generic-only). +/// The `local(...)` sources of a `font-family` stack, dropping the generic +/// keywords an `@font-face src` cannot name. "" when the stack is generic-only. std::string local_font_sources(const std::string_view css_family) { static constexpr std::array generics = { "serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui"}; @@ -410,8 +394,7 @@ std::string local_font_sources(const std::string_view css_family) { while (!name.empty() && name.back() == ' ') { name.remove_suffix(1); } - const bool generic = - std::find(generics.begin(), generics.end(), name) != generics.end(); + const bool generic = std::ranges::find(generics, name) != generics.end(); if (!name.empty() && !generic) { if (!src.empty()) { src += ','; @@ -428,13 +411,10 @@ std::string local_font_sources(const std::string_view css_family) { return src; } -/// Registers one `@font-face` per (substitute family, style, ascent) that -/// overrides the face's ascent/descent so a glyph's baseline lands exactly at -/// the `top` `add_position_classes` derives from `ascent_em` — independent of -/// the metrics of whichever local font actually resolves. Without the override -/// the browser positions the baseline using the resolved font's own ascent, -/// which for a large non-embedded run (e.g. a 120pt Times title) drops it well -/// below the intended baseline. +/// One `@font-face` per (substitute family, style, ascent) overriding the +/// face's ascent/descent, so a glyph's baseline lands at the `top` +/// `add_position_classes` derived from `ascent_em` rather than wherever +/// whichever local font resolves would put it. class SubstituteFontFaces { public: /// The `font-family:...` (plus weight/style) declaration for `substitute`, @@ -446,10 +426,9 @@ class SubstituteFontFaces { if (src.empty()) { return font_substitute_declaration(substitute); } - // ascent-override + descent-override sum to one em, so `line-height:1` - // leaves no leading and the baseline sits at exactly `ascent_em` of the em - // box. `ascent_em` is clamped to [0.5, 1.2]; the `max` keeps descent - // non-negative for the rare ascent > 1 (a slight baseline approximation). + // The two overrides sum to one em, so `line-height:1` leaves no leading and + // the baseline sits at exactly `ascent_em`. `max` keeps descent + // non-negative for the rare clamped ascent > 1. const double ascent = ascent_em; const double descent = std::max(0.0, 1.0 - ascent_em); std::ostringstream key; @@ -489,9 +468,8 @@ class SubstituteFontFaces { std::vector m_faces; }; -/// Build an SVG `d` attribute from a path's subpaths, each point mapped through -/// `to_box` (PDF user space -> the page box, y-down). Lines become `L`, cubic -/// Béziers `C`, and an explicitly closed subpath ends with `Z`. +/// An SVG `d` attribute for a path's subpaths, each point mapped through +/// `to_box` (PDF user space -> the page box, y-down). std::string svg_path_d(const std::vector &subpaths, const util::math::Transform2D &to_box) { std::ostringstream d; @@ -522,13 +500,9 @@ std::string svg_path_d(const std::vector &subpaths, return std::move(d).str(); } -/// Serialize a painted path to an SVG `` fragment in the page -/// viewBox, or "" when it paints nothing. Fill honours the even-odd rule; -/// stroke carries width (CTM-scaled in user space), caps, joins, miter limit -/// and the dash pattern. A zero stroke width renders as a thin hairline. -/// `clip_id`, when non-empty, references a `` installed via -/// `clip-path`. `fill_url_id`, when non-empty, fills the path with that paint -/// server (a shading gradient or a tiling ``) instead of `fill_color`. +/// A painted path as an SVG `` fragment in the page viewBox, or "" +/// when it paints nothing. `clip_id` and `fill_url_id`, when non-empty, name a +/// `` and a paint server (gradient or tiling pattern) to reference. std::string svg_path_fragment(const pdf::PathElement &path, const util::math::Transform2D &to_box, const std::string &clip_id, @@ -563,8 +537,7 @@ std::string svg_path_fragment(const pdf::PathElement &path, if (path.stroke_alpha < 1) { f << " stroke-opacity=\"" << round2(path.stroke_alpha) << '"'; } - // A 0 width is "device-thinnest" in PDF; SVG would draw nothing, so floor - // it to a sub-point hairline. + // A 0 width is "device-thinnest" in PDF; SVG would draw nothing. const double width = path.line_width > 0 ? path.line_width : 0.5; f << " stroke-width=\"" << round2(width) << '"'; if (path.line_cap == 1) { @@ -603,16 +576,12 @@ std::string svg_path_fragment(const pdf::PathElement &path, return std::move(f).str(); } -/// Serialize an image XObject to an SVG `` fragment in the page viewBox, -/// or "" when it carries no pass-through bytes. The image fills the unit square -/// in user space (ISO 32000-1 8.10.5); the transform maps that square — through -/// a vertical flip (the image's first row is its top, SVG draws y-down) and the -/// CTM — into the page box. `clip_id`, when non-empty, installs a clip via a -/// wrapping ``. The clip geometry is in the page viewBox -/// (`userSpaceOnUse`), but the `` carries its own `transform`, so a -/// `clip-path` placed *on the image* would be resolved in the image's -/// post-transform unit-square space and clip the whole image away. The `` -/// carries no transform, so the clip is read in the viewBox where it lives. +/// An image XObject as an SVG `` fragment in the page viewBox, or "" +/// when it carries no pass-through bytes. The image fills the unit square in +/// user space (ISO 32000-1 8.10.5), flipped vertically because its first row is +/// its top. `clip_id` is installed on a wrapping ``, not on the ``: +/// the clip geometry is `userSpaceOnUse` in the viewBox, and on the image it +/// would resolve in the image's post-transform unit-square space instead. std::string svg_image_fragment(const pdf::ImageElement &image, const util::math::Transform2D &to_box, const std::string &clip_id) { @@ -643,11 +612,9 @@ std::string svg_image_fragment(const pdf::ImageElement &image, return std::move(f).str(); } -/// Shared bookkeeping for the per-page `` registries below (clips, -/// gradients, tiling patterns): a signature->id cache that deduplicates -/// repeated definitions, a per-page monotonic id counter, and the accumulated -/// `` markup (emitted once into the page's hidden ``). Ids are -/// namespaced per page as `_`. +/// Shared bookkeeping for the per-page `` registries below: a +/// signature->id cache deduplicating repeated definitions plus the accumulated +/// `` markup. Ids are namespaced per page as `_`. class DefsRegistry { public: explicit DefsRegistry(const std::uint32_t page) : m_page{page} {} @@ -655,9 +622,8 @@ class DefsRegistry { [[nodiscard]] std::string defs() const { return m_defs.str(); } protected: - /// The id for `signature`, minting `_` the first time it is - /// seen. `inserted` is true only on that first sight — when the caller still - /// needs to emit the definition into `m_defs`. + /// The id for `signature`. `inserted` is true only on first sight, when the + /// caller still has to emit the definition into `m_defs`. struct Entry { std::string id; bool inserted; @@ -679,12 +645,10 @@ class DefsRegistry { std::unordered_map m_id_by_signature; }; -/// Registers a page's clip regions as nested `` defs, deduplicating -/// shared prefixes. PDF's current clip is the *intersection* of an ordered list -/// of regions; SVG expresses intersection by chaining `clip-path` from one -/// `` to the next, so region i's clipPath references region i-1's and -/// the painted element references the last. Ids are namespaced per page -/// (`c_`). +/// A page's clip regions as nested `` defs (`c_`). PDF's +/// current clip is the *intersection* of an ordered region list; SVG expresses +/// intersection by chaining `clip-path`, so region i references region i-1 and +/// the painted element references the last. class ClipRegistry : public DefsRegistry { public: using DefsRegistry::DefsRegistry; @@ -718,18 +682,13 @@ class ClipRegistry : public DefsRegistry { } }; -/// Registers a page's shadings (axial/radial) as ``/ -/// `` defs, deduplicating by shading and placement. The -/// shading's pre-sampled colour stops become ``s; `gradientTransform` -/// (shading space -> page box) places the gradient in the page's user space, so -/// referencing elements use `gradientUnits="userSpaceOnUse"`. Ids are -/// namespaced per page (`g_`). +/// A page's axial/radial shadings as ``/`` defs +/// (`g_`), placed by `gradientTransform` in `userSpaceOnUse`. /// -/// DEFERRED (out of scope for this stage): PDF `/Extend` is approximated by -/// SVG's default `pad` spread (the end stops extend outward), so a non-extended -/// shading is over-painted beyond its interval instead of being masked to it; -/// `Shading::background` and `Shading::bbox` are likewise not yet honoured. -/// Honouring them needs the fill clipped to the gradient band/annulus. +/// DEFERRED: `/Extend` is approximated by SVG's default `pad` spread, so a +/// non-extended shading over-paints beyond its interval; `Shading::background` +/// and `Shading::bbox` are not honoured. Both need the fill clipped to the +/// gradient band/annulus. class GradientRegistry : public DefsRegistry { public: using DefsRegistry::DefsRegistry; @@ -773,10 +732,9 @@ class GradientRegistry : public DefsRegistry { } }; -/// Serialize an `sh` shading flood to an SVG `` covering the page box, -/// filled with `gradient_id` and bounded by `clip_id` (the clip in force at -/// `sh` time). Returns "" when the shading produced no gradient. The rect spans -/// the whole page; the clip (and the gradient's own extent) bound the paint. +/// An `sh` shading flood as an SVG `` spanning the page box, filled with +/// `gradient_id`; `clip_id` (and the gradient's own extent) bound the paint. +/// "" when the shading produced no gradient. std::string svg_shading_fragment(const std::string &gradient_id, const std::string &clip_id, const double width, const double height, const double alpha, @@ -800,17 +758,13 @@ std::string svg_shading_fragment(const std::string &gradient_id, return std::move(f).str(); } -/// Registers a page's tiling patterns (`/PatternType 1`) as SVG `` -/// defs. The pattern's content stream is run as a mini page (`extract_page`) -/// into tile fragments laid out in pattern space; the `` repeats them -/// every `/XStep`/`/YStep`, and `patternTransform` (pattern space -> page box) -/// places the lattice. An uncoloured pattern (`/PaintType 2`) ignores its -/// content's own colours and paints in the path's fill colour, so the cache key -/// folds that colour in. Each cell is clipped to its `/BBox` so marks outside -/// the cell (or in the gap when a step exceeds the BBox) don't leak into the -/// tile. Ids are namespaced per page (`pat_`). Only paths and images -/// inside the tile are rendered (nested text/shadings/patterns are skipped — -/// rare). Returns "" for an unrepresentable pattern. +/// A page's tiling patterns (`/PatternType 1`) as SVG `` defs +/// (`pat_`). The content stream is run as a mini page into tile +/// fragments in pattern space, repeated every `/XStep`/`/YStep` and placed by +/// `patternTransform`. An uncoloured pattern (`/PaintType 2`) paints in the +/// path's fill colour, so the cache key folds that colour in. Only paths and +/// images are rendered (nested text/shadings/patterns are skipped — rare). +/// "" for an unrepresentable pattern. class PatternRegistry : public DefsRegistry { public: using DefsRegistry::DefsRegistry; @@ -859,8 +813,7 @@ class PatternRegistry : public DefsRegistry { << "\" height=\"" << round2(std::abs(pattern.y_step)) << "\" patternTransform=\"" << svg_matrix(m) << "\">"; // Clip each cell to its `/BBox` (ISO 32000-1 8.7.3.1). An overlapping - // lattice (a step smaller than the BBox) can't be expressed as a single SVG - // `` and is not reproduced. + // lattice (step < BBox) has no single-`` equivalent and is lost. const double bbox_w = pattern.bbox[2] - pattern.bbox[0]; const double bbox_h = pattern.bbox[3] - pattern.bbox[1]; if (bbox_w > 0 && bbox_h > 0) { @@ -879,13 +832,11 @@ class PatternRegistry : public DefsRegistry { class MaskRegistry; -/// Serialize one graphic page element (a painted path, a shading flood, an -/// image, or a nested transparency group) to an SVG fragment in the page -/// viewBox, registering any clip, gradient, pattern or soft mask it needs. -/// Returns "" for a text element or one that paints nothing. A soft mask on the -/// element wraps its fragment in a masked ``; a `GroupElement` renders its -/// children then wraps them in one `` carrying the group's opacity, blend -/// and mask (so the group composites as a unit before those apply). +/// One graphic page element as an SVG fragment in the page viewBox, +/// registering any clip, gradient, pattern or soft mask it needs. "" for a text +/// element or one that paints nothing. A `GroupElement`'s children are wrapped +/// in a single `` so the group composites as a unit before its +/// opacity/blend/mask apply. std::string render_graphic_fragment(const pdf::PageElement &element, const util::math::Transform2D &to_box, double width, double height, @@ -894,14 +845,10 @@ std::string render_graphic_fragment(const pdf::PageElement &element, PatternRegistry &patterns, MaskRegistry &masks, const Logger &logger); -/// Registers a page's soft masks (`/SMask`, ISO 32000-1 11.6.5.2) as `` -/// defs. The extractor has rendered each mask's transparency group into a list -/// of graphic elements (in user space); those are serialized into the mask body -/// with the page's own clip/gradient/pattern registries, so their ids stay -/// unique within the page. Coverage comes from luminance by default -/// (`/Luminosity` -> the SVG mask default) or from alpha (`/Alpha` -> -/// `mask-type="alpha"`); a non-black `/BC` backdrop floods behind the group. -/// Ids are namespaced per page (`m_`). +/// A page's soft masks (`/SMask`, ISO 32000-1 11.6.5.2) as `` defs +/// (`m_`). The extractor has already rendered each mask's transparency +/// group to graphic elements; those are serialized through the page's own +/// clip/gradient/pattern registries so their ids stay unique within the page. class MaskRegistry : public DefsRegistry { public: using DefsRegistry::DefsRegistry; @@ -911,9 +858,8 @@ class MaskRegistry : public DefsRegistry { const double width, const double height, ClipRegistry &clips, GradientRegistry &gradients, PatternRegistry &patterns, const Logger &logger) { - // A fresh `SoftMask` is built for every `gs`, but many are identical (the - // same drop-shadow reused across a run of glyphs, say). Dedupe on the - // rendered body + type + backdrop so those collapse to a single def. + // A fresh `SoftMask` is built for every `gs`, but many are identical (one + // drop-shadow across a run of glyphs); dedupe on the rendered body. std::ostringstream body; for (const pdf::PageElement &element : mask.group) { body << render_graphic_fragment(element, to_box, width, height, clips, @@ -936,9 +882,7 @@ class MaskRegistry : public DefsRegistry { m_defs << " mask-type=\"alpha\""; } m_defs << '>'; - // A non-black `/BC` backdrop floods the mask region behind the group; the - // default (black) needs none — SVG's mask background is already luminance - // 0. + // Black — SVG's mask background already — needs no backdrop rect. if (mask.backdrop.has_value() && ((*mask.backdrop)[0] + (*mask.backdrop)[1] + (*mask.backdrop)[2] > 0)) { m_defs << "`, but text is painted as positioned markup, not SVG, so it cannot ride -/// inside that ``. The extractor faithfully nests such text in the group; to -/// avoid dropping it from both the visual and selection layers, each interior -/// `TextElement` is hoisted to the top level — where the ordinary text pipeline -/// renders it and `extract_text`-style top-level scans pick it up. The only -/// thing forgone is the group effect on the text itself (see pdf/AGENTS.md -/// gaps); the group's graphics are untouched and still composited as a unit. -/// Nesting is flattened recursively; hoisted text is emitted at the group's -/// position (ahead of the composited graphics), and a group left with no -/// graphics is dropped. +/// Hoists text out of transparency groups (recursively) to the top level. A +/// group's effects ride an SVG ``, but text is positioned markup, not SVG, +/// so it cannot sit inside that `` — without the hoist it would be dropped +/// from both the visual and the selection layer. Forgone: the group effect on +/// the text itself (see pdf/AGENTS.md gaps). The group's graphics are untouched +/// and still composite as a unit; a group left with none is dropped. std::vector lift_group_text(std::vector elements) { std::vector result; @@ -1097,14 +1035,11 @@ lift_group_text(std::vector elements) { return result; } -/// Deduplicates CSS declarations into atomic, single-property classes. PDF text -/// emits one absolutely-positioned line block per detected line, and the same -/// font sizes, offsets and spacings recur across the (potentially millions of) -/// elements. Writing each declaration inline bloats the document. Instead, -/// every distinct declaration is registered once here, named `` in -/// first-seen order (e.g. `f1`, `f2` for font sizes, `t1` for a top offset), -/// emitted once in , and referenced by class on each element. This is -/// representation-only: the computed style of every element is unchanged. +/// Deduplicates CSS declarations into atomic, single-property classes named +/// `` in first-seen order, emitted once in ``. The same font +/// sizes, offsets and spacings recur across up to millions of positioned +/// elements, and inline declarations bloat the document. Representation-only: +/// no element's computed style changes. class AtomicStyles { public: /// `prefix` selects the property family; `declaration` is a full CSS @@ -1121,9 +1056,8 @@ class AtomicStyles { return it->second; } - /// Writes one rule per line (`.f1{font-size:9.96pt}`) so regeneration diffs - /// stay legible. Each rule is preceded by a newline; the caller has already - /// written the constant rules on the opening `