diff --git a/CHANGELOG.md b/CHANGELOG.md index 3debe76f5..e816214e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- `PdfFile::annotate` writes markup annotations — highlight, underline, + strike-out, squiggly and freehand ink — into a pdf as an incremental update, + so the source bytes are left as they are and any viewer reads them as + ordinary pdf annotations. `FileTypeCapabilities` gains an `annotate` flag. + - **Breaking**: `html::edit` becomes `Document::edit`, in every binding — java's `Html.edit(document, diff)` becomes `document.edit(diff)`, and so on. `Text::set_content` is unchanged. diff --git a/src/odr/file.cpp b/src/odr/file.cpp index c14d2d4ba..70a3f3650 100644 --- a/src/odr/file.cpp +++ b/src/odr/file.cpp @@ -354,6 +354,11 @@ PdfFile PdfFile::decrypt(const std::string &password) const { return DecodedFile::decrypt(password).as_pdf_file(); } +void PdfFile::annotate(const std::string_view annotations, std::ostream &out, + const Logger &logger) const { + m_impl->annotate(annotations, out, logger); +} + std::shared_ptr PdfFile::impl() const { return m_impl; } diff --git a/src/odr/file.hpp b/src/odr/file.hpp index 07601b511..a166ff866 100644 --- a/src/odr/file.hpp +++ b/src/odr/file.hpp @@ -203,7 +203,8 @@ struct FileTypeCapabilities final { bool color_scheme{}; ///< the view honors @ref HtmlConfig::color_scheme bool edit{}; ///< @ref Document::is_editable can be `true` bool save{}; ///< @ref Document::save is supported - bool encrypt{}; ///< @ref Document::save with a password is supported + bool encrypt{}; ///< @ref Document::save with a password is supported + bool annotate{}; ///< @ref PdfFile::annotate is supported }; /// Collection of encryption states. @@ -528,6 +529,19 @@ class PdfFile final : public DecodedFile { [[nodiscard]] PdfFile decrypt(const std::string &password) const; + /// @brief Applies markup @p annotations, writing the annotated pdf to + /// @p out. + /// + /// The wire format our browser-side annotator produces: highlight, underline, + /// strike-out, squiggly and freehand ink, placed in pdf user space. The + /// source is copied and the annotations appended, so nothing else about the + /// file changes. + /// @throws std::invalid_argument if @p annotations is malformed. + /// @throws std::runtime_error if the file cannot take them — its + /// cross-reference table was recovered, or it is encrypted. + void annotate(std::string_view annotations, std::ostream &out, + const Logger &logger = Logger::null()) const; + [[nodiscard]] std::shared_ptr impl() const; private: diff --git a/src/odr/internal/abstract/file.hpp b/src/odr/internal/abstract/file.hpp index 2bd4167aa..cd0262962 100644 --- a/src/odr/internal/abstract/file.hpp +++ b/src/odr/internal/abstract/file.hpp @@ -138,6 +138,10 @@ class PdfFile : public DecodedFile { [[nodiscard]] std::string_view mimetype() const noexcept final { return "application/pdf"; } + + /// Apply `annotations` and write the result to `out`. + virtual void annotate(std::string_view annotations, std::ostream &out, + const Logger &logger) const = 0; }; class FontFile : public DecodedFile { diff --git a/src/odr/internal/file_type_table.cpp b/src/odr/internal/file_type_table.cpp index aeed25f19..437273e0d 100644 --- a/src/odr/internal/file_type_table.cpp +++ b/src/odr/internal/file_type_table.cpp @@ -445,7 +445,8 @@ constexpr std::array table{ {.detect_by_content = true, .open = true, .decrypt = true, - .translate_html = true}}, + .translate_html = true, + .annotate = true}}, Row{FileType::text_file, "txt"sv, diff --git a/src/odr/internal/pdf/pdf_file.cpp b/src/odr/internal/pdf/pdf_file.cpp index 1d6e07946..7ae2e0327 100644 --- a/src/odr/internal/pdf/pdf_file.cpp +++ b/src/odr/internal/pdf/pdf_file.cpp @@ -4,15 +4,25 @@ #include #include +#include +#include +#include #include #include #include #include +#include +#include #include +#include #include #include +#include #include +#include + +#include namespace odr::internal::pdf { @@ -175,4 +185,132 @@ DocumentParser PdfFile::create_parser(const Logger &logger) const { return DocumentParser(m_file->stream(), m_decryptor, logger); } +namespace { + +/// A required member of `value`, refused rather than defaulted. +const nlohmann::json &at(const nlohmann::json &value, const char *key) { + const auto it = value.find(key); + if (it == value.end()) { + throw std::invalid_argument(std::string("annotation is missing /") + key); + } + return *it; +} + +std::array read_color(const nlohmann::json &value) { + if (!value.is_array() || value.size() != 3) { + throw std::invalid_argument("color is not three components"); + } + return {value[0].get(), value[1].get(), + value[2].get()}; +} + +AnnotationCommon read_common(const nlohmann::json &value) { + AnnotationCommon result; + result.color = read_color(at(value, "color")); + result.opacity = value.value("opacity", 1.0); + result.author = value.value("author", std::string()); + result.contents = value.value("contents", std::string()); + return result; +} + +TextMarkupKind read_markup_kind(const std::string &type) { + if (type == "highlight") { + return TextMarkupKind::highlight; + } + if (type == "underline") { + return TextMarkupKind::underline; + } + if (type == "strikeOut") { + return TextMarkupKind::strike_out; + } + if (type == "squiggly") { + return TextMarkupKind::squiggly; + } + throw std::invalid_argument("unknown annotation type " + type); +} + +TextMarkup read_text_markup(const nlohmann::json &value, + const std::string &type) { + TextMarkup result; + result.kind = read_markup_kind(type); + for (const nlohmann::json &quad : at(value, "quads")) { + if (!quad.is_array() || quad.size() != 8) { + throw std::invalid_argument("quad is not eight coordinates"); + } + Quad &out = result.quads.emplace_back(); + for (std::size_t i = 0; i < out.size(); ++i) { + out[i] = quad[i].get(); + } + } + result.common = read_common(value); + return result; +} + +Ink read_ink(const nlohmann::json &value) { + Ink result; + for (const nlohmann::json &stroke : at(value, "strokes")) { + if (!stroke.is_array() || stroke.empty() || stroke.size() % 2 != 0) { + throw std::invalid_argument("stroke is not a sequence of x y pairs"); + } + result.strokes.push_back(stroke.get>()); + } + result.width = value.value("width", 1.0); + result.common = read_common(value); + return result; +} + +/// @throws std::invalid_argument for a payload this build cannot write. +void write_annotations(DocumentParser &parser, const nlohmann::json &json, + std::ostream &out) { + // the guard against a payload from a frontend this build does not know + if (json.value("version", 0) != 1) { + throw std::invalid_argument("unsupported annotation format version"); + } + + const std::unique_ptr document = parser.parse_document(); + const std::vector pages = document->collect_pages(); + + IncrementalWriter writer(parser); + // one page rewrite per page, however many annotations land on it + std::map> by_page; + + for (const nlohmann::json &value : + json.value("annotations", nlohmann::json::array())) { + const auto index = at(value, "page").get(); + if (index >= pages.size()) { + throw std::invalid_argument("annotation names page " + + std::to_string(index) + + ", which is not there"); + } + const auto type = at(value, "type").get(); + + by_page[index].push_back( + type == "ink" + ? write_ink(writer, read_ink(value)) + : write_text_markup(writer, read_text_markup(value, type))); + } + + for (const auto &[index, references] : by_page) { + append_page_annotations(writer, *pages[index], references); + } + + writer.write(out); +} + +} // namespace + +void PdfFile::annotate(const std::string_view annotations, std::ostream &out, + const Logger &logger) const { + try { + const nlohmann::json json = nlohmann::json::parse(annotations); + DocumentParser parser = create_parser(logger); + write_annotations(parser, json, out); + } catch (const nlohmann::json::exception &e) { + // nlohmann reports a member of the wrong type in a hierarchy of its own, + // and that is a malformed payload like any other + throw std::invalid_argument(std::string("annotations are malformed: ") + + e.what()); + } +} + } // namespace odr::internal::pdf diff --git a/src/odr/internal/pdf/pdf_file.hpp b/src/odr/internal/pdf/pdf_file.hpp index 6c36fe4d8..982f8b5c0 100644 --- a/src/odr/internal/pdf/pdf_file.hpp +++ b/src/odr/internal/pdf/pdf_file.hpp @@ -28,6 +28,9 @@ class PdfFile final : public abstract::PdfFile { [[nodiscard]] bool is_decodable() const noexcept override; + void annotate(std::string_view annotations, std::ostream &out, + const Logger &logger) const override; + [[nodiscard]] DocumentParser create_parser(const Logger &logger = Logger::null()) const; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 8d142089d..f57807b87 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -35,6 +35,7 @@ add_executable(odr_test "src/html_test.cpp" "src/logger_test.cpp" "src/odr_test.cpp" + "src/pdf_annotate_test.cpp" "src/quantity_test.cpp" "src/table_position_test.cpp" diff --git a/test/src/odr_test.cpp b/test/src/odr_test.cpp index 4f9e48a57..c7d68b6b2 100644 --- a/test/src/odr_test.cpp +++ b/test/src/odr_test.cpp @@ -220,6 +220,7 @@ TEST(FileTypeTable, capabilities_build_on_each_other) { EXPECT_FALSE(capabilities.translate_html) << file_type_to_string(type); EXPECT_FALSE(capabilities.edit) << file_type_to_string(type); EXPECT_FALSE(capabilities.save) << file_type_to_string(type); + EXPECT_FALSE(capabilities.annotate) << file_type_to_string(type); } if (!capabilities.save) { EXPECT_FALSE(capabilities.encrypt) << file_type_to_string(type); diff --git a/test/src/pdf_annotate_test.cpp b/test/src/pdf_annotate_test.cpp new file mode 100644 index 000000000..10f5d669e --- /dev/null +++ b/test/src/pdf_annotate_test.cpp @@ -0,0 +1,198 @@ +#include +#include +#include + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +#include + +using namespace odr; +using namespace odr::test; +using odr::internal::pdf::DocumentParser; +using odr::internal::pdf::Page; +using PdfDocument = odr::internal::pdf::Document; + +namespace { + +PdfFile open_fixture(const std::string &short_path) { + return open(File(TestData::test_file_path(short_path)), DecodeOptions{}, + Logger::null()) + .as_pdf_file(); +} + +std::string annotate(const std::string &json) { + std::ostringstream out; + open_fixture("odr-public/pdf/style-various-1.pdf").annotate(json, out); + return std::move(out).str(); +} + +std::vector pages_of(DocumentParser &parser, + std::unique_ptr &document) { + document = parser.parse_document(); + return document->collect_pages(); +} + +constexpr std::string_view one_highlight = R"json({ + "version": 1, + "annotations": [ + { + "page": 0, + "type": "highlight", + "quads": [[72, 700, 300, 700, 72, 688, 300, 688]], + "color": [1, 0.9, 0.2] + } + ] +})json"; + +} // namespace + +TEST(PdfAnnotate, capability_is_declared) { + EXPECT_TRUE( + capabilities_by_file_type(FileType::portable_document_format).annotate); +} + +TEST(PdfAnnotate, writes_a_highlight) { + const std::string result = annotate(std::string(one_highlight)); + + DocumentParser parser(std::make_unique(result)); + std::unique_ptr document; + const std::vector pages = pages_of(parser, document); + ASSERT_EQ(pages.size(), 2); + + // the fixture's own link annotations plus ours + const auto &annotations = pages[0]->annotations; + ASSERT_FALSE(annotations.empty()); + const auto &dictionary = annotations.back()->object.as_dictionary(); + EXPECT_EQ(dictionary.get("Subtype").as_string(), "Highlight"); + EXPECT_NE(annotations.back()->appearance, nullptr); +} + +TEST(PdfAnnotate, writes_every_type) { + const std::string result = annotate(R"json({ + "version": 1, + "annotations": [ + {"page": 0, "type": "highlight", + "quads": [[72, 700, 300, 700, 72, 688, 300, 688]], "color": [1, 1, 0]}, + {"page": 0, "type": "underline", + "quads": [[72, 660, 300, 660, 72, 648, 300, 648]], "color": [0, 0, 1]}, + {"page": 0, "type": "strikeOut", + "quads": [[72, 630, 300, 630, 72, 618, 300, 618]], "color": [1, 0, 0]}, + {"page": 1, "type": "squiggly", + "quads": [[72, 600, 300, 600, 72, 588, 300, 588]], "color": [0, 1, 0]}, + {"page": 1, "type": "ink", "strokes": [[100, 500, 130, 540, 160, 490]], + "width": 2, "color": [0, 0, 0]} + ] + })json"); + + DocumentParser parser(std::make_unique(result)); + std::unique_ptr document; + const std::vector pages = pages_of(parser, document); + ASSERT_EQ(pages.size(), 2); + + const auto subtypes = [](const Page &page) { + std::vector result; + result.reserve(page.annotations.size()); + for (const auto *annotation : page.annotations) { + result.push_back( + annotation->object.as_dictionary().get("Subtype").as_string()); + } + return result; + }; + + const std::vector first = subtypes(*pages[0]); + EXPECT_NE(std::ranges::find(first, "Highlight"), first.end()); + EXPECT_NE(std::ranges::find(first, "Underline"), first.end()); + EXPECT_NE(std::ranges::find(first, "StrikeOut"), first.end()); + + const std::vector second = subtypes(*pages[1]); + EXPECT_NE(std::ranges::find(second, "Squiggly"), second.end()); + EXPECT_NE(std::ranges::find(second, "Ink"), second.end()); +} + +TEST(PdfAnnotate, author_and_contents_reach_the_file) { + const std::string result = annotate(R"json({ + "version": 1, + "annotations": [ + {"page": 0, "type": "highlight", + "quads": [[72, 700, 300, 700, 72, 688, 300, 688]], + "color": [1, 1, 0], "opacity": 0.5, + "author": "a reviewer", "contents": "look (here)"} + ] + })json"); + + DocumentParser parser(std::make_unique(result)); + std::unique_ptr document; + const std::vector pages = pages_of(parser, document); + const auto &dictionary = pages[0]->annotations.back()->object.as_dictionary(); + EXPECT_EQ(dictionary.get("T").as_string(), "a reviewer"); + EXPECT_EQ(dictionary.get("Contents").as_string(), "look (here)"); + EXPECT_DOUBLE_EQ(dictionary.get("CA").as_real(), 0.5); +} + +// An empty payload is legal and writes an update that changes nothing. +TEST(PdfAnnotate, empty_payload_is_a_no_op_update) { + const std::string result = + annotate(R"json({"version": 1, "annotations": []})json"); + + DocumentParser parser(std::make_unique(result)); + std::unique_ptr document; + const std::vector pages = pages_of(parser, document); + EXPECT_EQ(pages.size(), 2); +} + +TEST(PdfAnnotate, malformed_payloads_are_refused) { + EXPECT_THROW(annotate("not json"), std::invalid_argument); + EXPECT_THROW(annotate(R"json({"annotations": []})json"), + std::invalid_argument); + EXPECT_THROW(annotate(R"json({"version": 2, "annotations": []})json"), + std::invalid_argument); + // a type we do not write + EXPECT_THROW(annotate(R"json({"version": 1, "annotations": + [{"page": 0, "type": "stamp", "color": [0, 0, 0]}]})json"), + std::invalid_argument); + // a page that is not there + EXPECT_THROW(annotate(R"json({"version": 1, "annotations": + [{"page": 99, "type": "highlight", "quads": [[0,0,0,0,0,0,0,0]], + "color": [0, 0, 0]}]})json"), + std::invalid_argument); + // a quad that is not eight coordinates + EXPECT_THROW(annotate(R"json({"version": 1, "annotations": + [{"page": 0, "type": "highlight", "quads": [[0, 0]], + "color": [0, 0, 0]}]})json"), + std::invalid_argument); + // a required member missing + EXPECT_THROW(annotate(R"json({"version": 1, "annotations": + [{"page": 0, "type": "highlight", + "quads": [[0,0,0,0,0,0,0,0]]}]})json"), + std::invalid_argument); + // a member of the wrong type, which nlohmann reports in its own hierarchy + EXPECT_THROW(annotate(R"json({"version": "1", "annotations": []})json"), + std::invalid_argument); + EXPECT_THROW(annotate(R"json({"version": 1, "annotations": + [{"page": "0", "type": "highlight", "quads": [[0,0,0,0,0,0,0,0]], + "color": [0, 0, 0]}]})json"), + std::invalid_argument); + EXPECT_THROW(annotate(R"json({"version": 1, "annotations": + [{"page": 0, "type": "highlight", "quads": [[0,0,0,0,0,0,0,0]], + "color": ["a", "b", "c"]}]})json"), + std::invalid_argument); +} + +// Decision 6: an encrypted file cannot take an incremental update, because the +// key its new objects would need is not retained. +TEST(PdfAnnotate, refuses_an_encrypted_file) { + std::ostringstream out; + EXPECT_THROW(open_fixture("odr-public/pdf/Casio_WVA-M650-7AJF.pdf") + .annotate(std::string(one_highlight), out), + std::runtime_error); +}