From 1cf5765f0c5ff119798c3f9402ad8e32ac8f0611 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Fri, 14 Aug 2026 18:48:27 +0200 Subject: [PATCH 1/2] fix(pdf): read a comment as white space, and half a pair as one mark Four new pdfs in the private corpus rendered as nothing at all. Three ended in `Invalid UTF-16`: a code the `ToUnicode` cmap does not map falls back to its own numeric value as a utf-16 unit, and a value in the surrogate range is not a code point, so converting the run threw and took the whole document with it. `u16string_to_string` now spends a replacement mark on a surrogate that completes no pair, wherever the half pair came from - the same throw was reachable from every engine that decodes utf-16. The fourth carries `%` in its content stream. A comment runs from `%` to the end of the line and stands wherever white space may (7.2.4), but the parser read the `<` as the start of a hex string and threw on the first letter that is not a hex digit. Objects and content-stream operators now skip comments. The file structure reads its `%PDF-` and `%%EOF` markers as entries of its own, which is why this is a separate call and not `skip_whitespace` itself. The four pdfs and an odt template join the corpus with their reference output; no existing reference file changes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01J8PCMZUVxuGstmmgQzEdif --- CHANGELOG.md | 4 +++ .../pdf/pdf_graphics_operator_parser.cpp | 6 ++-- src/odr/internal/pdf/pdf_object_parser.cpp | 31 +++++++++++++++---- src/odr/internal/pdf/pdf_object_parser.hpp | 4 +++ src/odr/internal/util/string_util.cpp | 28 ++++++++++++++++- src/odr/internal/util/string_util.hpp | 3 ++ test/data.cmake | 8 ++--- test/src/internal/pdf/pdf_object_parser.cpp | 12 +++++++ test/src/internal/pdf/pdf_page_extractor.cpp | 12 +++++++ test/src/internal/util/string_util_test.cpp | 14 +++++++++ 10 files changed, 109 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 258ba9462..65bf4e89a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,10 @@ The release run heads these entries with the version and opens a fresh - A frame that names a side instead of an offset sits on that side, so a centred image in an odt is centred. The side it names is `GraphicStyle`'s new `horizontal_position`, carried by the python, java and objc bindings. +- A pdf that comments its content stream renders: `%` to the end of the line is + white space wherever it stands, not the start of a token. +- Text that decodes to half a surrogate pair costs that character a replacement + mark instead of the whole document. ## v6.5.0 - 2026-08-10 diff --git a/src/odr/internal/pdf/pdf_graphics_operator_parser.cpp b/src/odr/internal/pdf/pdf_graphics_operator_parser.cpp index c9662976c..d49205b2e 100644 --- a/src/odr/internal/pdf/pdf_graphics_operator_parser.cpp +++ b/src/odr/internal/pdf/pdf_graphics_operator_parser.cpp @@ -224,6 +224,8 @@ std::string GraphicsOperatorParser::read_operator_name() { GraphicsOperator GraphicsOperatorParser::read_operator() { GraphicsOperator result; + m_parser.skip_whitespace_and_comments(); + std::string operator_name; while (true) { if (m_parser.peek_number()) { @@ -252,7 +254,7 @@ GraphicsOperator GraphicsOperatorParser::read_operator() { break; } } - m_parser.skip_whitespace(); + m_parser.skip_whitespace_and_comments(); } result.type = operator_name_to_type(operator_name); @@ -273,7 +275,7 @@ GraphicsOperator GraphicsOperatorParser::read_operator() { result.arguments.emplace_back(StandardString(std::move(data))); } - m_parser.skip_whitespace(); + m_parser.skip_whitespace_and_comments(); return result; } diff --git a/src/odr/internal/pdf/pdf_object_parser.cpp b/src/odr/internal/pdf/pdf_object_parser.cpp index fc7435665..fc3085e79 100644 --- a/src/odr/internal/pdf/pdf_object_parser.cpp +++ b/src/odr/internal/pdf/pdf_object_parser.cpp @@ -132,6 +132,25 @@ void ObjectParser::skip_whitespace() { } } +void ObjectParser::skip_whitespace_and_comments() { + while (true) { + skip_whitespace(); + if (geti() != '%') { + return; + } + while (true) { + const int_type c = sb().sbumpc(); + if (c == eof) { + in().setstate(std::ios::eofbit); + return; + } + if (c == '\n' || c == '\r') { + break; + } + } + } +} + void ObjectParser::skip_line() { read_line(); } std::string ObjectParser::read_line(const bool inclusive) { @@ -467,7 +486,7 @@ Array ObjectParser::read_array() { if (bumpc() != '[') { throw std::runtime_error("unexpected character"); } - skip_whitespace(); + skip_whitespace_and_comments(); while (true) { if (const char_type c = getc(); c == ']') { @@ -475,14 +494,14 @@ Array ObjectParser::read_array() { return Array(std::move(result)); } result.emplace_back(read_object()); - skip_whitespace(); + skip_whitespace_and_comments(); // Array elements may be bare adjacent integers, so a reference can only be // recognised once the `R` token actually appears: it retroactively folds // the two preceding integers (`n g`) into an `n g R` reference. if (const char_type c = getc(); c == 'R' && result.size() >= 2) { bumpc(); - skip_whitespace(); + skip_whitespace_and_comments(); const UnsignedInteger gen = result.back().as_integer(); result.pop_back(); @@ -517,7 +536,7 @@ Dictionary ObjectParser::read_dictionary() { if (bumpc() != '<') { throw std::runtime_error("unexpected character"); } - skip_whitespace(); + skip_whitespace_and_comments(); while (true) { if (const char_type c = getc(); c == '>') { @@ -527,9 +546,9 @@ Dictionary ObjectParser::read_dictionary() { } Name name = read_name(); - skip_whitespace(); + skip_whitespace_and_comments(); Object value = read_object(); - skip_whitespace(); + skip_whitespace_and_comments(); promote_indirect_reference(value); result.emplace(std::move(name.string), std::move(value)); diff --git a/src/odr/internal/pdf/pdf_object_parser.hpp b/src/odr/internal/pdf/pdf_object_parser.hpp index 19b1aa43c..becc153ef 100644 --- a/src/odr/internal/pdf/pdf_object_parser.hpp +++ b/src/odr/internal/pdf/pdf_object_parser.hpp @@ -44,6 +44,10 @@ class ObjectParser { static bool is_whitespace(char c); [[nodiscard]] bool peek_whitespace(); void skip_whitespace(); + /// White space plus the comments (`%` to the end of the line, 7.2.4) it may + /// be interleaved with. Not for the file structure, which reads its `%PDF-` + /// and `%%EOF` markers as entries of their own. + void skip_whitespace_and_comments(); void skip_line(); std::string read_line(bool inclusive = false); /// Advance the cursor just past the next occurrence of `marker`. Returns true diff --git a/src/odr/internal/util/string_util.cpp b/src/odr/internal/util/string_util.cpp index 7279f15b2..8d1109cf4 100644 --- a/src/odr/internal/util/string_util.cpp +++ b/src/odr/internal/util/string_util.cpp @@ -176,7 +176,33 @@ std::size_t string::utf8_length(const std::string &string) { } std::string string::u16string_to_string(const std::u16string &string) { - return utf8::utf16to8(string); + static constexpr char32_t replacement = 0xfffd; + + std::string result; + result.reserve(string.size()); + + for (std::size_t i = 0; i < string.size(); ++i) { + const char32_t unit = string[i]; + + if (unit < 0xd800 || unit > 0xdfff) { + utf8::append(unit, result); + continue; + } + // a low surrogate first, or a high one with nothing to pair with + if (unit > 0xdbff || i + 1 >= string.size()) { + utf8::append(replacement, result); + continue; + } + const char32_t second = string[i + 1]; + if (second < 0xdc00 || second > 0xdfff) { + utf8::append(replacement, result); + continue; + } + utf8::append(0x10000 + ((unit - 0xd800) << 10) + (second - 0xdc00), result); + ++i; + } + + return result; } std::u16string string::string_to_u16string(const std::string_view string) { diff --git a/src/odr/internal/util/string_util.hpp b/src/odr/internal/util/string_util.hpp index de62c30aa..14c6c930a 100644 --- a/src/odr/internal/util/string_util.hpp +++ b/src/odr/internal/util/string_util.hpp @@ -66,6 +66,9 @@ std::string to_string(double d, int precision); std::size_t utf8_length(const std::string &string); +/// A surrogate that completes no pair is not a code point; it is converted to +/// U+FFFD rather than rejected, so a file's bad text run costs its own +/// characters and not the whole document. std::string u16string_to_string(const std::u16string &string); std::u16string string_to_u16string(std::string_view string); /// @p length is a byte count, not a number of code units. diff --git a/test/data.cmake b/test/data.cmake index f73297899..93e0d8e68 100644 --- a/test/data.cmake +++ b/test/data.cmake @@ -7,19 +7,19 @@ odr_test_data( PATH "input/odr-public" URL "https://github.com/opendocument-app/OpenDocument.test.git" - REVISION "d7c093dffc987e8e5cf093ea7412371ce9d7363d") + REVISION "77ed98d72075bbd375be328b7232f40568a32563") odr_test_data( PATH "input/odr-private" URL "https://github.com/opendocument-app/OpenDocument.test-private.git" - REVISION "753b7e5c313c23c0524cb9ed678ac294508f3e21") + REVISION "d92bbbc453e6dbc0187ec2bdf0560a6e48d5643b") odr_test_data( PATH "reference-output/odr-public" URL "https://github.com/opendocument-app/OpenDocument.test.output.git" - REVISION "1964c34c2a6bd84d8178d9586ba59a04f172b7f2") + REVISION "6d1ed797b3833b99b09ce359f2689c5a3af105c1") odr_test_data( PATH "reference-output/odr-private" URL "https://github.com/opendocument-app/OpenDocument.test-private.output.git" - REVISION "5e5f035f8a6e6c86ad5d978887607e8b9167c019") + REVISION "90841441c293ec665632b570bdfe63842926859c") diff --git a/test/src/internal/pdf/pdf_object_parser.cpp b/test/src/internal/pdf/pdf_object_parser.cpp index dcbd9bd56..3f8de80ec 100644 --- a/test/src/internal/pdf/pdf_object_parser.cpp +++ b/test/src/internal/pdf/pdf_object_parser.cpp @@ -192,6 +192,18 @@ TEST(PdfObjectParser, read_object_reference_in_containers) { } } +// 7.2.4: a comment runs from `%` to the end of the line and stands wherever +// white space may, so `%` inside an object is not the start of a token. +TEST(PdfObjectParser, comment_inside_containers) { + const auto [object, rest] = + read_object("<< % a key follows\n/A [1 % and a value\n2] /B 3 >>"); + ASSERT_TRUE(object.is_dictionary()); + const Dictionary &dictionary = object.as_dictionary(); + ASSERT_TRUE(dictionary["A"].is_array()); + EXPECT_EQ(dictionary["A"].as_array().size(), 2u); + EXPECT_EQ(dictionary["B"].as_integer(), 3); +} + // 7.3.4.2: a literal string is the bytes between balanced parentheses. TEST(PdfObjectParser, standard_string_basic) { EXPECT_EQ(read_standard_string("(Hello)"), "Hello"); diff --git a/test/src/internal/pdf/pdf_page_extractor.cpp b/test/src/internal/pdf/pdf_page_extractor.cpp index f947c58df..33ff184b1 100644 --- a/test/src/internal/pdf/pdf_page_extractor.cpp +++ b/test/src/internal/pdf/pdf_page_extractor.cpp @@ -71,6 +71,18 @@ TEST(PdfPageExtractor, crlf_delimited_operators) { EXPECT_EQ(texts[0].codes, "Hi"); } +// A comment stands where white space may (7.2.4), so a `%` line between +// operators is a comment and not the start of a hex string that swallows the +// rest of the page. +TEST(PdfPageExtractor, comment_between_operators) { + const auto texts = run("%\nq\n" + "%\n" + "BT /F1 12 Tf 1 0 0 1 100 700 Tm (Hi) Tj ET\nQ\n"); + ASSERT_EQ(texts.size(), 1); + EXPECT_DOUBLE_EQ(texts[0].transform.e, 100); + EXPECT_EQ(texts[0].codes, "Hi"); +} + // `Tm` sets the text matrix outright, scaling and all. TEST(PdfPageExtractor, tm_scaling) { const auto texts = run("BT /F1 10 Tf 2 0 0 2 50 60 Tm (X) Tj ET"); diff --git a/test/src/internal/util/string_util_test.cpp b/test/src/internal/util/string_util_test.cpp index e4989551f..3ac2ea268 100644 --- a/test/src/internal/util/string_util_test.cpp +++ b/test/src/internal/util/string_util_test.cpp @@ -172,3 +172,17 @@ TEST(string_util, find_ignore_case) { // `from` past the end is not an out-of-range read. EXPECT_EQ(find_ignore_case("abc", "a", 99), std::string_view::npos); } + +TEST(string_util, u16string_to_string) { + EXPECT_EQ(u16string_to_string(u"abc"), "abc"); + EXPECT_EQ(u16string_to_string(u"\U0001f600"), "\xf0\x9f\x98\x80"); + + // A surrogate completing no pair is not a code point: it costs its own + // character rather than the whole string. + EXPECT_EQ(u16string_to_string(std::u16string{u'a', 0xdc62, u'b'}), + "a\xef\xbf\xbd" + "b"); + EXPECT_EQ(u16string_to_string(std::u16string{0xd83d}), "\xef\xbf\xbd"); + EXPECT_EQ(u16string_to_string(std::u16string{0xd83d, u'a'}), "\xef\xbf\xbd" + "a"); +} From ccc1b81fa1628352cc767b1b674ee630953eb2dd Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Fri, 14 Aug 2026 18:58:44 +0200 Subject: [PATCH 2/2] fix(pdf): let a comment stand wherever the spec says it may Three gaps the first pass left, all from review: `%` is a delimiter (7.2.2), so `Tj%note` is an operator and a comment rather than one bareword that swallows the operation; the separators inside an indirect reference may carry one, so `5 0%note\nR` resolves; and the gaps around an indirect object's body may too, so a commented catalog or page still parses. The entry-level skips stay blind, which is what keeps `%PDF-` and `%%EOF` readable as entries of their own. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01J8PCMZUVxuGstmmgQzEdif --- src/odr/internal/pdf/pdf_file_parser.cpp | 17 +++++++------- .../pdf/pdf_graphics_operator_parser.cpp | 5 ++-- src/odr/internal/pdf/pdf_object_parser.cpp | 8 +++---- src/odr/internal/pdf/pdf_object_parser.hpp | 4 ++-- src/odr/internal/util/string_util.hpp | 4 +--- test/src/internal/pdf/pdf_file_parser.cpp | 12 ++++++++++ test/src/internal/pdf/pdf_object_parser.cpp | 23 +++++++++++++------ test/src/internal/pdf/pdf_page_extractor.cpp | 9 ++++++++ 8 files changed, 56 insertions(+), 26 deletions(-) diff --git a/src/odr/internal/pdf/pdf_file_parser.cpp b/src/odr/internal/pdf/pdf_file_parser.cpp index 2767a7658..715c5cc05 100644 --- a/src/odr/internal/pdf/pdf_file_parser.cpp +++ b/src/odr/internal/pdf/pdf_file_parser.cpp @@ -20,16 +20,17 @@ IndirectObject FileParser::read_indirect_object() { IndirectObject result; result.reference.id = m_parser.read_unsigned_integer(); - m_parser.skip_whitespace(); + m_parser.skip_whitespace_and_comments(); result.reference.gen = m_parser.read_unsigned_integer(); - m_parser.skip_whitespace(); + m_parser.skip_whitespace_and_comments(); // `obj` is not necessarily followed by a newline; some producers keep the // object on the same line m_parser.expect_characters("obj"); - m_parser.skip_whitespace(); + // A comment is white space in the gaps around the body (7.2.4). + m_parser.skip_whitespace_and_comments(); result.object = m_parser.read_object(); - m_parser.skip_whitespace(); + m_parser.skip_whitespace_and_comments(); // an indirect object whose body is itself a reference (`5 0 obj 12 0 R // endobj`): the body is followed by `endobj`/`stream`, so a digit here can // only be the generation of an `n g R` @@ -57,7 +58,7 @@ IndirectObject FileParser::read_indirect_object() { Trailer FileParser::read_trailer() { m_parser.expect_characters("trailer"); - m_parser.skip_whitespace(); + m_parser.skip_whitespace_and_comments(); Trailer result; @@ -188,9 +189,9 @@ ObjectStream FileParser::read_object_stream(const std::uint32_t n, offsets.reserve(n); { for (std::uint32_t i = 0; i < n; ++i) { - parser().skip_whitespace(); + parser().skip_whitespace_and_comments(); const std::uint64_t id = parser().read_unsigned_integer(); - parser().skip_whitespace(); + parser().skip_whitespace_and_comments(); const std::uint64_t offset = parser().read_unsigned_integer(); offsets.emplace_back(id, first + static_cast(offset)); } @@ -202,7 +203,7 @@ ObjectStream FileParser::read_object_stream(const std::uint32_t n, for (const auto &[id, position] : offsets) { in().clear(); in().seekg(position); - parser().skip_whitespace(); + parser().skip_whitespace_and_comments(); members.push_back({id, parser().read_object()}); } diff --git a/src/odr/internal/pdf/pdf_graphics_operator_parser.cpp b/src/odr/internal/pdf/pdf_graphics_operator_parser.cpp index d49205b2e..1fab7f076 100644 --- a/src/odr/internal/pdf/pdf_graphics_operator_parser.cpp +++ b/src/odr/internal/pdf/pdf_graphics_operator_parser.cpp @@ -210,9 +210,10 @@ std::string GraphicsOperatorParser::read_operator_name() { return result; } // Any white-space (7.2.2, incl. `\r` in CRLF streams) or the start of a - // following token ends the bareword. + // following token ends the bareword. `%` is a delimiter too (7.2.2), so a + // comment may follow an operator with nothing in between. if (ObjectParser::is_whitespace(static_cast(c)) || c == '/' || - c == '<' || c == '[') { + c == '<' || c == '[' || c == '%') { return result; } diff --git a/src/odr/internal/pdf/pdf_object_parser.cpp b/src/odr/internal/pdf/pdf_object_parser.cpp index fc3085e79..638b8d9d2 100644 --- a/src/odr/internal/pdf/pdf_object_parser.cpp +++ b/src/odr/internal/pdf/pdf_object_parser.cpp @@ -595,19 +595,19 @@ void ObjectParser::promote_indirect_reference(Object &value) { } const auto id = static_cast(value.as_integer()); const UnsignedInteger gen = read_unsigned_integer(); - skip_whitespace(); + skip_whitespace_and_comments(); if (bumpc() != 'R') { throw std::runtime_error("expected 'R' to complete indirect reference"); } - skip_whitespace(); + skip_whitespace_and_comments(); value = Object(ObjectReference{id, gen}); } ObjectReference ObjectParser::read_object_reference() { UnsignedInteger id = read_unsigned_integer(); - skip_whitespace(); + skip_whitespace_and_comments(); UnsignedInteger gen = read_unsigned_integer(); - skip_whitespace(); + skip_whitespace_and_comments(); if (bumpc() != 'R') { throw std::runtime_error("unexpected character"); diff --git a/src/odr/internal/pdf/pdf_object_parser.hpp b/src/odr/internal/pdf/pdf_object_parser.hpp index becc153ef..a31264136 100644 --- a/src/odr/internal/pdf/pdf_object_parser.hpp +++ b/src/odr/internal/pdf/pdf_object_parser.hpp @@ -45,8 +45,8 @@ class ObjectParser { [[nodiscard]] bool peek_whitespace(); void skip_whitespace(); /// White space plus the comments (`%` to the end of the line, 7.2.4) it may - /// be interleaved with. Not for the file structure, which reads its `%PDF-` - /// and `%%EOF` markers as entries of their own. + /// be interleaved with. Not between entries: `%PDF-` and `%%EOF` are read as + /// entries of their own. void skip_whitespace_and_comments(); void skip_line(); std::string read_line(bool inclusive = false); diff --git a/src/odr/internal/util/string_util.hpp b/src/odr/internal/util/string_util.hpp index 14c6c930a..b9889dc8e 100644 --- a/src/odr/internal/util/string_util.hpp +++ b/src/odr/internal/util/string_util.hpp @@ -66,9 +66,7 @@ std::string to_string(double d, int precision); std::size_t utf8_length(const std::string &string); -/// A surrogate that completes no pair is not a code point; it is converted to -/// U+FFFD rather than rejected, so a file's bad text run costs its own -/// characters and not the whole document. +/// A surrogate completing no pair becomes U+FFFD rather than throwing. std::string u16string_to_string(const std::u16string &string); std::u16string string_to_u16string(std::string_view string); /// @p length is a byte count, not a number of code units. diff --git a/test/src/internal/pdf/pdf_file_parser.cpp b/test/src/internal/pdf/pdf_file_parser.cpp index d4d95fb02..0266dc890 100644 --- a/test/src/internal/pdf/pdf_file_parser.cpp +++ b/test/src/internal/pdf/pdf_file_parser.cpp @@ -132,6 +132,18 @@ TEST(IndirectObject, reference_body) { EXPECT_EQ(object.object.as_reference().gen, 0u); } +// A comment is white space in the gaps around an object's body (7.2.4). +TEST(IndirectObject, comment_around_body) { + std::istringstream in("5 0 obj\n% the catalog\n<< /Type /Catalog >>\n" + "% and done\nendobj"); + FileParser parser(in); + + const IndirectObject object = parser.read_indirect_object(); + EXPECT_EQ(object.reference.id, 5u); + ASSERT_TRUE(object.object.is_dictionary()); + EXPECT_EQ(object.object.as_dictionary()["Type"].as_name(), "Catalog"); +} + // read_stream with a known /Length reads exactly that many bytes, then the // `endstream` and `endobj` keywords. TEST(ReadStream, known_length) { diff --git a/test/src/internal/pdf/pdf_object_parser.cpp b/test/src/internal/pdf/pdf_object_parser.cpp index 3f8de80ec..9db6d2d1f 100644 --- a/test/src/internal/pdf/pdf_object_parser.cpp +++ b/test/src/internal/pdf/pdf_object_parser.cpp @@ -195,13 +195,22 @@ TEST(PdfObjectParser, read_object_reference_in_containers) { // 7.2.4: a comment runs from `%` to the end of the line and stands wherever // white space may, so `%` inside an object is not the start of a token. TEST(PdfObjectParser, comment_inside_containers) { - const auto [object, rest] = - read_object("<< % a key follows\n/A [1 % and a value\n2] /B 3 >>"); - ASSERT_TRUE(object.is_dictionary()); - const Dictionary &dictionary = object.as_dictionary(); - ASSERT_TRUE(dictionary["A"].is_array()); - EXPECT_EQ(dictionary["A"].as_array().size(), 2u); - EXPECT_EQ(dictionary["B"].as_integer(), 3); + { + const auto [object, rest] = + read_object("<< % a key follows\n/A [1 % and a value\n2] /B 3 >>"); + ASSERT_TRUE(object.is_dictionary()); + const Dictionary &dictionary = object.as_dictionary(); + ASSERT_TRUE(dictionary["A"].is_array()); + EXPECT_EQ(dictionary["A"].as_array().size(), 2u); + EXPECT_EQ(dictionary["B"].as_integer(), 3); + } + { + // `%` is a delimiter (7.2.2), so a comment may sit between the generation + // and the `R` that completes a reference — with no white space at all + const auto [object, rest] = read_object("<< /A 5 0% a comment\nR >>"); + ASSERT_TRUE(object.is_dictionary()); + EXPECT_EQ(object.as_dictionary()["A"].as_reference().id, 5u); + } } // 7.3.4.2: a literal string is the bytes between balanced parentheses. diff --git a/test/src/internal/pdf/pdf_page_extractor.cpp b/test/src/internal/pdf/pdf_page_extractor.cpp index 33ff184b1..b063edaf3 100644 --- a/test/src/internal/pdf/pdf_page_extractor.cpp +++ b/test/src/internal/pdf/pdf_page_extractor.cpp @@ -83,6 +83,15 @@ TEST(PdfPageExtractor, comment_between_operators) { EXPECT_EQ(texts[0].codes, "Hi"); } +// `%` delimits (7.2.2), so a comment needs no white space before it. +TEST(PdfPageExtractor, comment_directly_after_operator) { + const auto texts = + run("BT /F1 12 Tf 1 0 0 1 100 700 Tm (Hi) Tj% right after\nET"); + ASSERT_EQ(texts.size(), 1); + EXPECT_DOUBLE_EQ(texts[0].transform.e, 100); + EXPECT_EQ(texts[0].codes, "Hi"); +} + // `Tm` sets the text matrix outright, scaling and all. TEST(PdfPageExtractor, tm_scaling) { const auto texts = run("BT /F1 10 Tf 2 0 0 2 50 60 Tm (X) Tj ET");