diff --git a/CHANGELOG.md b/CHANGELOG.md index ed7400dc6..7cc573ff3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- Justified pdf text is spaced as the file asks: word spacing (`Tw`) applies to + runs painted with an embedded font, which had rendered short. +- A pdf page is turned as its `/Rotate` says. +- A pdf whose subset font names its glyphs `gidNNNNN` reads correctly, and a + named glyph variant such as `hyphen.case` is the one painted: a simple font's + `/Encoding` names select through the font program's charset. +- A pdf whose Flate streams omit the ADLER32 trailer opens. + ## v6.8.0 - 2026-08-18 - A file can be read in the dark: `HtmlConfig::color_scheme` is `light`, `dark` diff --git a/src/odr/internal/abstract/font.hpp b/src/odr/internal/abstract/font.hpp index 7a766ec83..c69af1b2b 100644 --- a/src/odr/internal/abstract/font.hpp +++ b/src/odr/internal/abstract/font.hpp @@ -5,6 +5,7 @@ #include #include #include +#include namespace odr::internal::abstract { @@ -44,6 +45,12 @@ class Font { /// Recovers Unicode for a font without usable `/ToUnicode` or `/Encoding`. [[nodiscard]] virtual std::optional code_point_for_glyph(std::uint16_t glyph) const = 0; + + /// The glyph a PostScript glyph name selects, 0 when the font names none — + /// including every format that carries no glyph names (ISO 32000-1 9.6.6.2). + [[nodiscard]] virtual std::uint16_t glyph_for_name(std::string_view) const { + return 0; + } }; } // namespace odr::internal::abstract diff --git a/src/odr/internal/crypto/crypto_util.cpp b/src/odr/internal/crypto/crypto_util.cpp index 00bb4e61d..7f7c966d8 100644 --- a/src/odr/internal/crypto/crypto_util.cpp +++ b/src/odr/internal/crypto/crypto_util.cpp @@ -292,9 +292,22 @@ std::size_t util::padding(const std::string_view input) { return inflator.GetPadding(); } +namespace { +/// Drops the ADLER32 check (RFC 1950 2.2), which real producers truncate, and +/// clears the queue so trailing bytes stay out of the output. +class UncheckedZlibDecompressor final : public CryptoPP::ZlibDecompressor { +public: + explicit UncheckedZlibDecompressor(BufferedTransformation *attachment) + : ZlibDecompressor(attachment) {} + +protected: + void ProcessPoststreamTail() override { m_inQueue.Clear(); } +}; +} // namespace + std::string util::zlib_inflate(const std::string_view input) { std::string result; - CryptoPP::ZlibDecompressor inflator(new CryptoPP::StringSink(result)); + UncheckedZlibDecompressor inflator(new CryptoPP::StringSink(result)); inflator.Put(reinterpret_cast(input.data()), input.size()); inflator.MessageEnd(); return result; diff --git a/src/odr/internal/crypto/crypto_util.hpp b/src/odr/internal/crypto/crypto_util.hpp index b6b681f55..f8773050c 100644 --- a/src/odr/internal/crypto/crypto_util.hpp +++ b/src/odr/internal/crypto/crypto_util.hpp @@ -51,6 +51,7 @@ std::string decrypt_blowfish(std::string_view key, std::string_view iv, std::string inflate(std::string_view input); std::size_t padding(std::string_view input); +/// Inflates a zlib stream, ignoring its ADLER32 trailer. std::string zlib_inflate(std::string_view input); std::string zlib_deflate(std::string_view input); diff --git a/src/odr/internal/font/cff_font.cpp b/src/odr/internal/font/cff_font.cpp index c66a2fa2d..a5dfbeb3d 100644 --- a/src/odr/internal/font/cff_font.cpp +++ b/src/odr/internal/font/cff_font.cpp @@ -488,6 +488,26 @@ std::string CffFont::string_for_sid(const std::uint16_t sid) const { d.substr(m_strings[index].offset, m_strings[index].length)); } +std::optional +CffFont::sid_for_string(const std::string_view string) const { + for (std::uint16_t sid = 0; sid < cff_standard_strings_size; ++sid) { + if (cff_standard_strings[sid] == string) { + return sid; + } + } + const std::string_view d{m_data}; + for (std::size_t index = 0; index < m_strings.size(); ++index) { + const std::size_t sid = cff_standard_strings_size + index; + if (sid > 0xffff) { + break; + } + if (d.substr(m_strings[index].offset, m_strings[index].length) == string) { + return static_cast(sid); + } + } + return std::nullopt; +} + std::optional CffFont::charstring_width(const std::uint16_t glyph) const { if (glyph >= m_charstrings.size()) { @@ -634,6 +654,23 @@ std::string CffFont::glyph_name(const std::uint16_t glyph) const { return string_for_sid(m_charset[glyph]); } +std::uint16_t CffFont::glyph_for_name(const std::string_view name) const { + // The charset is glyph -> CID in a CID-keyed font, so it names nothing. + if (m_cid_keyed || name.empty()) { + return 0; + } + const std::optional sid = sid_for_string(name); + if (!sid.has_value()) { + return 0; + } + for (std::size_t glyph = 1; glyph < m_charset.size(); ++glyph) { + if (m_charset[glyph] == *sid) { + return static_cast(glyph); + } + } + return 0; +} + std::uint16_t CffFont::cid_for_glyph(const std::uint16_t glyph) const { if (!m_cid_keyed || glyph >= m_charset.size()) { return 0; diff --git a/src/odr/internal/font/cff_font.hpp b/src/odr/internal/font/cff_font.hpp index 609a8e100..b0d25c6e6 100644 --- a/src/odr/internal/font/cff_font.hpp +++ b/src/odr/internal/font/cff_font.hpp @@ -41,6 +41,8 @@ class CffFont final : public abstract::Font { glyph_for_code_point(char32_t code_point) const override; [[nodiscard]] std::optional code_point_for_glyph(std::uint16_t glyph) const override; + [[nodiscard]] std::uint16_t + glyph_for_name(std::string_view name) const override; // --- CFF-specific facts, for the OTF wrap and PDF wiring --- @@ -98,6 +100,9 @@ class CffFont final : public abstract::Font { std::uint32_t &end) const; /// Resolve a string SID to its text (standard strings or the String INDEX). [[nodiscard]] std::string string_for_sid(std::uint16_t sid) const; + /// The inverse, `nullopt` when the font never names @p string. + [[nodiscard]] std::optional + sid_for_string(std::string_view string) const; /// Extract the optional leading width from glyph @p glyph's Type2 charstring, /// in design units; `nullopt` when the charstring carries no explicit width. [[nodiscard]] std::optional diff --git a/src/odr/internal/html/pdf_file.cpp b/src/odr/internal/html/pdf_file.cpp index 5a97abfb9..ebf0c5ac1 100644 --- a/src/odr/internal/html/pdf_file.cpp +++ b/src/odr/internal/html/pdf_file.cpp @@ -1488,7 +1488,9 @@ class HtmlServiceImpl final : public HtmlService { std::string run_text; if (font != 0) { - run_text = escape_text(glyph_run_str(*text.font, text.codes)); + // Not `escape_text`: its ` ` is a different character, which + // `word-spacing` does not move. + run_text = escape_markup(glyph_run_str(*text.font, text.codes)); } else { // `margin-left` already spans the word break; rendering it too // shifts the glyphs by a space, once per run. @@ -1499,10 +1501,9 @@ class HtmlServiceImpl final : public HtmlService { cs_pt != 0) { add_class(run_classes, "s", pt_decl("letter-spacing", cs_pt)); } - // CSS `word-spacing` only affects real U+0020, so it is inert on PUA - // glyph runs — fallback path only. Composite fonts are skipped: PDF - // Tw applies to single-byte code 32 alone. - if (font == 0 && !(text.font != nullptr && text.font->composite)) { + // Tw applies to single-byte code 32 alone (9.3.3), which only a + // simple font's run keeps as a real U+0020 (`space_glyph`). + if (!(text.font != nullptr && text.font->composite)) { if (const double ws_pt = round2(text.word_spacing * scale); ws_pt != 0) { add_class(run_classes, "ws", pt_decl("word-spacing", ws_pt)); @@ -2342,11 +2343,17 @@ class HtmlServiceImpl final : public HtmlService { std::min(page_box[0].as_real(), page_box[2].as_real()); const double box_y0 = std::min(page_box[1].as_real(), page_box[3].as_real()); - const double width = + const double box_width = std::max(page_box[0].as_real(), page_box[2].as_real()) - box_x0; - const double height = + const double box_height = std::max(page_box[1].as_real(), page_box[3].as_real()) - box_y0; + // `/Rotate` turns the page clockwise as displayed (7.7.3.3), so a quarter + // turn swaps what the reader sees as its width and height. + const bool quarter_turn = page.rotate == 90 || page.rotate == 270; + const double width = quarter_turn ? box_height : box_width; + const double height = quarter_turn ? box_width : box_height; + std::string classes = "p"; { std::ostringstream w; @@ -2357,9 +2364,25 @@ class HtmlServiceImpl final : public HtmlService { add_class(classes, "y", std::move(h).str()); } + // Onto the displayed box: 90° sends the top-left corner to the top-right, + // 270° to the bottom-left. + const util::math::Transform2D rotation = [&]() -> util::math::Transform2D { + switch (page.rotate) { + case 90: + return {0, 1, -1, 0, box_height, 0}; + case 180: + return {-1, 0, 0, -1, box_width, box_height}; + case 270: + return {0, -1, 1, 0, 0, box_width}; + default: + return {}; + } + }(); + const util::math::Transform2D to_box = util::math::Transform2D::translation(-box_x0, -box_y0) * - util::math::Transform2D::scaling_translation(1, -1, 0, height); + util::math::Transform2D::scaling_translation(1, -1, 0, box_height) * + rotation; return {width, height, to_box, std::move(classes)}; } @@ -2554,11 +2577,14 @@ class HtmlServiceImpl final : public HtmlService { /// Re-encodes `font`'s embedded program, folding `extra_unicode`'s cmap /// entries in alongside the PUA range, and appends its `@font-face` plus the /// `.fvN`/`.fnN` rules `class_used` says are needed. - static void - write_font_face(const pdf::Font &font, const std::uint32_t index, - const std::map &extra_unicode, - const std::array &class_used, - std::string &font_faces, std::string &font_styles) { + static void write_font_face(const pdf::Font &font, const std::uint32_t index, + std::map extra_unicode, + const std::array &class_used, + std::string &font_faces, + std::string &font_styles) { + if (const std::uint16_t space = space_glyph(font); space != 0) { + extra_unicode.emplace(U' ', space); + } std::string reencoded; if (const auto sfnt = std::dynamic_pointer_cast( font.embedded_font)) { @@ -2612,10 +2638,26 @@ class HtmlServiceImpl final : public HtmlService { return std::clamp(em, 0.5, 1.0); } + /// The glyph a simple font paints for byte 32, 0 when it has none. That byte + /// keeps a real U+0020 for CSS `word-spacing` to move, and `write_font_face` + /// maps U+0020 onto this glyph so the painted shape is unchanged. + static std::uint16_t space_glyph(const pdf::Font &font) { + if (font.composite || font.embedded_font == nullptr) { + return 0; + } + const std::uint16_t glyph = font.glyph_for_code(' '); + return glyph < font.embedded_font->glyph_count() ? glyph : 0; + } + static std::string glyph_run_str(const pdf::Font &font, const std::string &codes) { + const std::uint16_t space = space_glyph(font); std::string s; for (const std::uint32_t code : font.codes(codes)) { + if (code == ' ' && space != 0) { + s += ' '; + continue; + } util::string::append_c32(font::pua_code_point(font.glyph_for_code(code)), s); } diff --git a/src/odr/internal/pdf/pdf_document.cpp b/src/odr/internal/pdf/pdf_document.cpp index 9bd8eadfa..ec8443906 100644 --- a/src/odr/internal/pdf/pdf_document.cpp +++ b/src/odr/internal/pdf/pdf_document.cpp @@ -128,6 +128,16 @@ std::uint16_t Font::glyph_for_code(const std::uint32_t code) const { } return code < cid_to_gid.size() ? cid_to_gid[code] : 0; } + // Simple Type1/CFF (ISO 32000-1 9.6.6.2): the name selects the glyph in the + // font program. Subset producers name glyphs `gidNNNNN`, which no glyph list + // translates, so the charset is the only link that reaches them. + if (encoding.has_value()) { + if (const std::uint16_t glyph = embedded_font->glyph_for_name( + encoding->glyph_name(static_cast(code))); + glyph != 0) { + return glyph; + } + } // Simple TrueType (ISO 32000-1 9.6.6.4), best effort: the embedded cmap keyed // on the byte code first (symbolic (3,0)/(1,0) fonts), then on the code's // Unicode (via the /Encoding glyph name), then the code as a GID. diff --git a/test/data.cmake b/test/data.cmake index 821b93038..7361b8b5a 100644 --- a/test/data.cmake +++ b/test/data.cmake @@ -12,14 +12,14 @@ odr_test_data( odr_test_data( PATH "input/odr-private" URL "https://github.com/opendocument-app/OpenDocument.test-private.git" - REVISION "d92bbbc453e6dbc0187ec2bdf0560a6e48d5643b") + REVISION "b1deaf20eb08054cf88fcc4cae33d0e90e185da3") odr_test_data( PATH "reference-output/odr-public" URL "https://github.com/opendocument-app/OpenDocument.test.output.git" - REVISION "cf8f1e19ae5d70f433114c5ca29716931c29ebfe") + REVISION "4c90050f653012ac27a1c274dfad95c05b33624f") odr_test_data( PATH "reference-output/odr-private" URL "https://github.com/opendocument-app/OpenDocument.test-private.output.git" - REVISION "a86e896770dbd5604df0c7e09c0bfc69dba83f2a") + REVISION "8c497eb840d40a1b625dac1ba5abd1089b3cce5d") diff --git a/test/src/internal/font/cff_font.cpp b/test/src/internal/font/cff_font.cpp index 948341894..d3d0c445c 100644 --- a/test/src/internal/font/cff_font.cpp +++ b/test/src/internal/font/cff_font.cpp @@ -359,6 +359,22 @@ TEST(CffFontTest, ResolvesStandardStringAndReverseMap) { EXPECT_EQ(font.glyph_for_code_point(U'A'), 1); } +TEST(CffFontTest, SelectsGlyphByName) { + // A custom name resolves through the String INDEX, a standard one through + // the generated table, and an absent one answers 0. + const CffFont custom{build_cff()}; + EXPECT_EQ(custom.glyph_for_name("myglyph"), 1); + EXPECT_EQ(custom.glyph_for_name("A"), 0); // SID 34, but no glyph carries it + EXPECT_EQ(custom.glyph_for_name(""), 0); + + const CffFont standard{build_cff(/*glyph1_sid=*/34)}; + EXPECT_EQ(standard.glyph_for_name("A"), 1); + + // A CID-keyed charset holds CIDs, so it names nothing. + const CffFont cid_keyed{build_cid_keyed_cff()}; + EXPECT_EQ(cid_keyed.glyph_for_name("A"), 0); +} + TEST(CffFontTest, MaterializesIsoAdobePredefinedCharset) { // `/charset 0` (and an omitted `/charset`) selects the ISOAdobe charset, the // identity SID == GID; without materializing it the glyphs would be diff --git a/test/src/internal/pdf/pdf_font.cpp b/test/src/internal/pdf/pdf_font.cpp index 6d9050015..22213f1b8 100644 --- a/test/src/internal/pdf/pdf_font.cpp +++ b/test/src/internal/pdf/pdf_font.cpp @@ -1,4 +1,6 @@ #include +#include +#include #include #include #include @@ -191,6 +193,27 @@ TEST(PdfFont, simple_font_glyph_for_code_via_cmap) { EXPECT_EQ(font.glyph_for_code('C'), 3); } +TEST(PdfFont, simple_font_glyph_for_code_via_font_charset) { + // A subset producer names its glyphs `gidNNNNN`: only the font program's + // own charset reaches them (ISO 32000-1 9.6.6.2). + const std::string endchar("\x0e", 1); + Font font; + font.embedded_font = + std::make_shared(font::cff::build_cff( + "Subset", + {{".notdef", endchar}, {"gid00046", endchar}, {"gid00133", endchar}}, + /*default_width=*/0, /*nominal_width=*/0, + odr::FontBBox{0, -200, 600, 800})); + + font.encoding.emplace(pdf::BaseEncoding::standard); + font.encoding->set_difference(30, "gid00133"); + + EXPECT_EQ(font.glyph_for_code(30), 2); + // `A` in StandardEncoding, which the font does not carry: the code-as-GID + // fallback stands. + EXPECT_EQ(font.glyph_for_code(65), 65); +} + TEST(PdfFont, no_font_yields_no_glyph) { Font font; font.composite = true;