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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ The release run heads these entries with the version and opens a fresh

## Unreleased

- A pdf that nests parentheses inside a string opens, and keeps its document
metadata β€” `cairo` and `pdfTeX` write their `/Producer` that way.

## v6.9.0 - 2026-08-18

- A filled pdf form shows what was filled in, and a marked-up one its markup:
Expand Down
14 changes: 12 additions & 2 deletions src/odr/internal/pdf/pdf_object_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#include <odr/internal/util/stream_util.hpp>

#include <cmath>
#include <cstdint>
#include <optional>
#include <sstream>
#include <stdexcept>
Expand Down Expand Up @@ -393,6 +394,10 @@ std::variant<StandardString, HexString> ObjectParser::read_string() {
char_type c = bumpc();

if (c == '(') {
// 7.3.4.2: a balanced pair of parentheses inside a literal string needs no
// escaping, so only the `)` closing the outermost pair ends the string.
std::uint32_t depth = 1;

while (true) {
c = bumpc();

Expand Down Expand Up @@ -436,8 +441,13 @@ std::variant<StandardString, HexString> ObjectParser::read_string() {
}
continue;
}
if (c == ')') {
return StandardString(std::move(string));
if (c == '(') {
++depth;
} else if (c == ')') {
--depth;
if (depth == 0) {
return StandardString(std::move(string));
}
}

string += c;
Expand Down
30 changes: 29 additions & 1 deletion src/odr/internal/util/stream_util.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,42 @@ namespace odr::internal::util::stream {
namespace {

/// Read-only stream buffer over an existing `string_view`, which must outlive
/// it. No seeking.
/// it.
class ViewStreamBuf : public std::streambuf {
public:
explicit ViewStreamBuf(std::string_view view) {
// the get area is never written through
auto *begin = const_cast<char *>(view.data());
setg(begin, begin, begin + view.size());
}

protected:
pos_type seekoff(const off_type off, const std::ios_base::seekdir dir,
const std::ios_base::openmode which) override {
if ((which & std::ios_base::in) == 0) {
return pos_type(off_type(-1));
}

off_type position = off;
if (dir == std::ios_base::cur) {
position += gptr() - eback();
} else if (dir == std::ios_base::end) {
position += egptr() - eback();
} else if (dir != std::ios_base::beg) {
return pos_type(off_type(-1));
}

if (position < 0 || position > egptr() - eback()) {
return pos_type(off_type(-1));
}
setg(eback(), eback() + position, egptr());
return position;
}

pos_type seekpos(const pos_type pos,
const std::ios_base::openmode which) override {
return seekoff(pos, std::ios_base::beg, which);
}
};

} // namespace
Expand Down
1 change: 1 addition & 0 deletions test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ add_executable(odr_test

"src/internal/util/map_util_test.cpp"
"src/internal/util/number_util_test.cpp"
"src/internal/util/stream_util_test.cpp"
"src/internal/util/string_util_test.cpp"
"src/internal/util/xml_util_test.cpp"

Expand Down
6 changes: 3 additions & 3 deletions test/data.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
odr_test_data(
PATH "input/odr-public"
URL "https://github.com/opendocument-app/OpenDocument.test.git"
REVISION "1ad8965bfc65529a715d5b0e39743292e879329e")
REVISION "3a5eaa1e559e420ee620d890937e0b11a64a294b")

odr_test_data(
PATH "input/odr-private"
Expand All @@ -17,9 +17,9 @@ odr_test_data(
odr_test_data(
PATH "reference-output/odr-public"
URL "https://github.com/opendocument-app/OpenDocument.test.output.git"
REVISION "c667f1d619e31909ec81effe9148c79283291c01")
REVISION "c7258ff998e22aaedf514ef2c1475dc3c22d0a96")

odr_test_data(
PATH "reference-output/odr-private"
URL "https://github.com/opendocument-app/OpenDocument.test-private.output.git"
REVISION "fabb5651c6406570b735cf6acd1213a14f5b390c")
REVISION "08b933720c39706541d2ba74f62e248d3d2a8b21")
12 changes: 12 additions & 0 deletions test/src/internal/pdf/pdf_object_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,18 @@ TEST(PdfObjectParser, standard_string_literal_escapes) {
EXPECT_EQ(read_standard_string("(a\\qb)"), "aqb");
}

// 7.3.4.2: a balanced pair of parentheses needs no escaping, so only the `)`
// closing the outermost pair ends the string. `cairo` writes its `/Producer`
// this way.
TEST(PdfObjectParser, standard_string_balanced_parentheses) {
EXPECT_EQ(read_standard_string("(a (b) c)"), "a (b) c");
EXPECT_EQ(read_standard_string("(a (b (c)) d)"), "a (b (c)) d");
EXPECT_EQ(read_standard_string("(cairo 1.18.4 (https://cairographics.org))"),
"cairo 1.18.4 (https://cairographics.org)");
// an escaped parenthesis does not open or close a pair
EXPECT_EQ(read_standard_string("(a \\(b (c) d)"), "a (b (c) d");
}

// 7.3.4.2: a `\ddd` octal escape (1-3 digits) is the byte of that value.
TEST(PdfObjectParser, standard_string_octal_escape) {
EXPECT_EQ(read_standard_string("(\\101)"), "A");
Expand Down
42 changes: 42 additions & 0 deletions test/src/internal/util/stream_util_test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#include <odr/internal/util/stream_util.hpp>

#include <ios>
#include <string>
#include <string_view>

#include <gtest/gtest.h>

using namespace odr::internal::util;

// A `ViewStream` is seekable: pdf object streams address their members by
// absolute position rather than reading them in order.
TEST(ViewStream, seek) {
const std::string_view view("0123456789");
stream::ViewStream in(view);

in.seekg(4);
EXPECT_EQ(in.tellg(), 4);
EXPECT_EQ(stream::read(in, 3), "456");

in.seekg(-2, std::ios::cur);
EXPECT_EQ(stream::read(in, 2), "56");

in.seekg(-1, std::ios::end);
EXPECT_EQ(stream::read(in, 1), "9");

in.seekg(0);
EXPECT_EQ(stream::read(in), "0123456789");
}

// An out-of-range seek fails the stream instead of moving the cursor.
TEST(ViewStream, seek_out_of_range) {
const std::string_view view("0123456789");
stream::ViewStream in(view);

in.seekg(11);
EXPECT_TRUE(in.fail());

in.clear();
in.seekg(-1, std::ios::beg);
EXPECT_TRUE(in.fail());
}
Loading