diff --git a/src/odr/internal/pdf/pdf_object.cpp b/src/odr/internal/pdf/pdf_object.cpp index 962827d7c..525850ee1 100644 --- a/src/odr/internal/pdf/pdf_object.cpp +++ b/src/odr/internal/pdf/pdf_object.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -12,9 +13,44 @@ namespace odr::internal::pdf { +namespace { + +/// A name's regular characters (ISO 32000-1 7.3.5): anything else is written +/// as `#` and two hex digits, `#` itself included. +bool name_char_is_regular(const unsigned char c) { + return c >= 0x21 && c <= 0x7e && c != '#' && c != '/' && c != '%' && + c != '(' && c != ')' && c != '<' && c != '>' && c != '[' && c != ']' && + c != '{' && c != '}'; +} + +} // namespace + void StandardString::to_stream(std::ostream &out) const { - // TODO escape - out << "(" << string << ")"; + // 7.3.4.2: only the reverse solidus and unbalanced parentheses need + // escaping, but balance is a property of the whole string, so escape every + // parenthesis rather than track it. A literal carriage return is read back + // as an end-of-line marker, i.e. as `\n`, so it has to be escaped too. + out << "("; + for (const char c : string) { + switch (c) { + case '\\': + out << "\\\\"; + break; + case '(': + out << "\\("; + break; + case ')': + out << "\\)"; + break; + case '\r': + out << "\\r"; + break; + default: + out << c; + break; + } + } + out << ")"; } std::string StandardString::to_string() const { @@ -33,7 +69,16 @@ std::string HexString::to_string() const { return ss.str(); } -void Name::to_stream(std::ostream &out) const { out << "/" << string; } +void Name::to_stream(std::ostream &out) const { + out << "/"; + for (const char c : string) { + if (name_char_is_regular(static_cast(c))) { + out << c; + } else { + out << fmt::format("#{:02X}", static_cast(c)); + } + } +} std::string Name::to_string() const { std::ostringstream ss; @@ -136,8 +181,8 @@ void Object::to_stream(std::ostream &out) const { } else if (is_integer()) { out << as_integer(); } else if (is_real()) { - // not `setprecision`, which would stick to the stream - out << fmt::format("{:.4g}", as_real()); + // 7.3.3 has no exponent form, and a stream would carry the host locale + out << util::number::to_string_significant(as_real(), 10); } else if (is_standard_string()) { as().to_stream(out); } else if (is_hex_string()) { @@ -164,12 +209,16 @@ std::string Object::to_string() const { void Array::to_stream(std::ostream &out) const { out << "["; + bool first = true; for (const auto &item : *this) { + if (!first) { + out << " "; + } + first = false; item.to_stream(out); - out << " "; } - out << " ]"; + out << "]"; } std::string Array::to_string() const { @@ -182,13 +231,13 @@ void Dictionary::to_stream(std::ostream &out) const { out << "<<"; for (const auto &[key, value] : *this) { - out << "/" << key; + Name(key).to_stream(out); out << " "; value.to_stream(out); out << " "; } - out << " >>"; + out << ">>"; } std::string Dictionary::to_string() const { diff --git a/src/odr/internal/util/math_util.hpp b/src/odr/internal/util/math_util.hpp index 5b5a36cf0..b39266a68 100644 --- a/src/odr/internal/util/math_util.hpp +++ b/src/odr/internal/util/math_util.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include namespace odr::internal::util::math { @@ -61,6 +62,23 @@ struct Transform2D { apply(const double x, const double y) const noexcept { return {a * x + c * y + e, b * x + d * y + f}; } + + /// The transform undoing this one, or `nullopt` where the linear part is + /// singular. + [[nodiscard]] constexpr std::optional inverse() const noexcept { + const double determinant = a * d - b * c; + if (determinant == 0) { + return std::nullopt; + } + return Transform2D{ + d / determinant, + -b / determinant, + -c / determinant, + a / determinant, + (c * f - d * e) / determinant, + (b * e - a * f) / determinant, + }; + } }; } // namespace odr::internal::util::math diff --git a/test/src/internal/pdf/pdf_object.cpp b/test/src/internal/pdf/pdf_object.cpp index 316987e23..f8552d0b0 100644 --- a/test/src/internal/pdf/pdf_object.cpp +++ b/test/src/internal/pdf/pdf_object.cpp @@ -166,3 +166,61 @@ TEST(PdfObject, to_string) { EXPECT_EQ(Object(Name{"Type"}).to_string(), "/Type"); EXPECT_EQ(Object(ObjectReference(12, 0)).to_string(), "12 0 R"); } + +// 7.3.3 knows no exponent form, and the host locale must not reach the output. +TEST(PdfObject, real_to_string_is_plain_decimal) { + EXPECT_EQ(Object(Real{1.5}).to_string(), "1.5"); + EXPECT_EQ(Object(Real{0.0}).to_string(), "0"); + EXPECT_EQ(Object(Real{-72.25}).to_string(), "-72.25"); + // `{:g}` would have written these as `1e-05` and `1.44e+04` + EXPECT_EQ(Object(Real{0.00001}).to_string(), "0.00001"); + EXPECT_EQ(Object(Real{14400.0}).to_string(), "14400"); + // and would have rounded this one to four significant digits + EXPECT_EQ(Object(Real{612.345}).to_string(), "612.345"); +} + +TEST(PdfObject, standard_string_escapes_delimiters) { + EXPECT_EQ(Object(StandardString{"plain"}).to_string(), "(plain)"); + EXPECT_EQ(Object(StandardString{"a(b)c"}).to_string(), R"((a\(b\)c))"); + EXPECT_EQ(Object(StandardString{R"(back\slash)"}).to_string(), + R"((back\\slash))"); + EXPECT_EQ(Object(StandardString{"cr\rlf"}).to_string(), R"((cr\rlf))"); + // a line feed stands for itself (7.3.4.2), so it is written raw + EXPECT_EQ(Object(StandardString{"a\nb"}).to_string(), "(a\nb)"); +} + +TEST(PdfObject, name_escapes_irregular_characters) { + EXPECT_EQ(Object(Name{"Type"}).to_string(), "/Type"); + EXPECT_EQ(Object(Name{"A;Name_With-Various***Chars?"}).to_string(), + "/A;Name_With-Various***Chars?"); + EXPECT_EQ(Object(Name{"Adobe Green"}).to_string(), "/Adobe#20Green"); + EXPECT_EQ(Object(Name{"paired()parentheses"}).to_string(), + "/paired#28#29parentheses"); + EXPECT_EQ(Object(Name{"The_Key_of_F#_Minor"}).to_string(), + "/The_Key_of_F#23_Minor"); + EXPECT_EQ(Object(Name{"1.2/3"}).to_string(), "/1.2#2F3"); +} + +TEST(PdfObject, container_to_string) { + Array array; + array.holder().emplace_back(Integer{1}); + array.holder().emplace_back(Real{2.5}); + array.holder().emplace_back(Name{"Three"}); + EXPECT_EQ(Object(std::move(array)).to_string(), "[1 2.5 /Three]"); + + EXPECT_EQ(Object(Array{}).to_string(), "[]"); + + Dictionary dictionary; + dictionary["Type"] = Object(Name{"Catalog"}); + EXPECT_EQ(Object(std::move(dictionary)).to_string(), "<>"); + + EXPECT_EQ(Object(Dictionary{}).to_string(), "<<>>"); +} + +// A key is a name and is escaped as one, or a space in it would split the +// dictionary in two on the way back in. +TEST(PdfObject, dictionary_key_is_escaped) { + Dictionary dictionary; + dictionary["Odd Key"] = Object(Integer{1}); + EXPECT_EQ(Object(std::move(dictionary)).to_string(), "<>"); +} diff --git a/test/src/internal/pdf/pdf_object_parser.cpp b/test/src/internal/pdf/pdf_object_parser.cpp index 3bac2db02..5a2480f3e 100644 --- a/test/src/internal/pdf/pdf_object_parser.cpp +++ b/test/src/internal/pdf/pdf_object_parser.cpp @@ -286,3 +286,38 @@ TEST(PdfObjectParser, hex_string_pads_odd_digit) { // whitespace before the terminator must not be mistaken for a digit EXPECT_EQ(read_hex_string("<41 >"), "A"); } + +// What `to_stream` writes, `read_object` reads back unchanged. +TEST(PdfObjectParser, serialization_round_trips) { + const auto round_trip = [](const Object &object) { + return read_object(object.to_string()).first; + }; + + EXPECT_EQ(round_trip(Object(StandardString{"a(b)c\\d\re"})).as_string(), + "a(b)c\\d\re"); + EXPECT_EQ(round_trip(Object(Name{"Odd #Name/With Spaces"})).as_string(), + "Odd #Name/With Spaces"); + EXPECT_DOUBLE_EQ(round_trip(Object(Real{0.00001})).as_real(), 0.00001); + EXPECT_DOUBLE_EQ(round_trip(Object(Real{-612.345})).as_real(), -612.345); + + Array array; + array.holder().emplace_back(Integer{1}); + array.holder().emplace_back(Real{2.5}); + array.holder().emplace_back(Name{"Three"}); + const Object read_array = round_trip(Object(std::move(array))); + ASSERT_TRUE(read_array.is_array()); + ASSERT_EQ(read_array.as_array().size(), 3u); + EXPECT_EQ(read_array.as_array()[0].as_integer(), 1); + EXPECT_DOUBLE_EQ(read_array.as_array()[1].as_real(), 2.5); + EXPECT_EQ(read_array.as_array()[2].as_string(), "Three"); + + Dictionary dictionary; + dictionary["Type"] = Object(Name{"Annot"}); + dictionary["Odd Key"] = Object(Integer{1}); + dictionary["Rect"] = Object(Real{72.5}); + const Object read_dictionary = round_trip(Object(std::move(dictionary))); + ASSERT_TRUE(read_dictionary.is_dictionary()); + EXPECT_EQ(read_dictionary.as_dictionary().get("Type").as_string(), "Annot"); + EXPECT_EQ(read_dictionary.as_dictionary().get("Odd Key").as_integer(), 1); + EXPECT_DOUBLE_EQ(read_dictionary.as_dictionary().get("Rect").as_real(), 72.5); +} diff --git a/test/src/internal/util/math_util_test.cpp b/test/src/internal/util/math_util_test.cpp index 8b02ecbc5..a0c9a7b74 100644 --- a/test/src/internal/util/math_util_test.cpp +++ b/test/src/internal/util/math_util_test.cpp @@ -38,6 +38,36 @@ TEST(Transform2D, compose_is_ordered) { EXPECT_DOUBLE_EQ(q[1], 10); } +// Applying a transform then its inverse returns the original point, whatever +// the transform is made of. +TEST(Transform2D, inverse_undoes_apply) { + const Transform2D m = Transform2D::translation(-30, -40) * + Transform2D::scaling_translation(1, -1, 0, 800) * + Transform2D{0, 1, -1, 0, 600, 0}; + + const auto inverse = m.inverse(); + ASSERT_TRUE(inverse.has_value()); + + const auto forward = m.apply(123, 456); + const auto back = inverse->apply(forward[0], forward[1]); + EXPECT_DOUBLE_EQ(back[0], 123); + EXPECT_DOUBLE_EQ(back[1], 456); +} + +TEST(Transform2D, inverse_of_identity_is_identity) { + const auto inverse = Transform2D().inverse(); + ASSERT_TRUE(inverse.has_value()); + const auto p = inverse->apply(3, 4); + EXPECT_DOUBLE_EQ(p[0], 3); + EXPECT_DOUBLE_EQ(p[1], 4); +} + +// A singular linear part collapses the plane, so nothing undoes it. +TEST(Transform2D, inverse_of_singular_is_nullopt) { + EXPECT_FALSE(Transform2D::scaling(0, 1).inverse().has_value()); + EXPECT_FALSE((Transform2D{1, 2, 2, 4, 5, 6}).inverse().has_value()); +} + // Composing then applying equals applying each factor in sequence. TEST(Transform2D, compose_matches_sequential_apply) { const Transform2D a{1, 2, 3, 4, 5, 6};