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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
7 changes: 7 additions & 0 deletions src/odr/internal/abstract/font.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>

namespace odr::internal::abstract {

Expand Down Expand Up @@ -44,6 +45,12 @@ class Font {
/// Recovers Unicode for a font without usable `/ToUnicode` or `/Encoding`.
[[nodiscard]] virtual std::optional<char32_t>
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
15 changes: 14 additions & 1 deletion src/odr/internal/crypto/crypto_util.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<const byte *>(input.data()), input.size());
inflator.MessageEnd();
return result;
Expand Down
1 change: 1 addition & 0 deletions src/odr/internal/crypto/crypto_util.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
37 changes: 37 additions & 0 deletions src/odr/internal/font/cff_font.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::uint16_t>
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<std::uint16_t>(sid);
}
}
return std::nullopt;
}

std::optional<std::int32_t>
CffFont::charstring_width(const std::uint16_t glyph) const {
if (glyph >= m_charstrings.size()) {
Expand Down Expand Up @@ -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<std::uint16_t> 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<std::uint16_t>(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;
Expand Down
5 changes: 5 additions & 0 deletions src/odr/internal/font/cff_font.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ class CffFont final : public abstract::Font {
glyph_for_code_point(char32_t code_point) const override;
[[nodiscard]] std::optional<char32_t>
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 ---

Expand Down Expand Up @@ -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<std::uint16_t>
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<std::int32_t>
Expand Down
68 changes: 55 additions & 13 deletions src/odr/internal/html/pdf_file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 `&nbsp;` 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.
Expand All @@ -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));
Expand Down Expand Up @@ -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;
Expand All @@ -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)};
}
Expand Down Expand Up @@ -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<char32_t, std::uint16_t> &extra_unicode,
const std::array<bool, 2> &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<char32_t, std::uint16_t> extra_unicode,
const std::array<bool, 2> &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::sfnt::SfntFont>(
font.embedded_font)) {
Expand Down Expand Up @@ -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);
}
Expand Down
10 changes: 10 additions & 0 deletions src/odr/internal/pdf/pdf_document.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::uint8_t>(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.
Expand Down
6 changes: 3 additions & 3 deletions test/data.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -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")
16 changes: 16 additions & 0 deletions test/src/internal/font/cff_font.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions test/src/internal/pdf/pdf_font.cpp
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
#include <odr/internal/abstract/font.hpp>
#include <odr/internal/font/cff_builder.hpp>
#include <odr/internal/font/cff_font.hpp>
#include <odr/internal/font/sfnt_font.hpp>
#include <odr/internal/pdf/pdf_afm.hpp>
#include <odr/internal/pdf/pdf_document_element.hpp>
Expand Down Expand Up @@ -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::CffFont>(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;
Expand Down
Loading