From 08ccedb7e359b777d13e6b2316e8563663610cab Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 6 Sep 2026 17:27:38 +0200 Subject: [PATCH 1/2] feat(pdf): annotate a pdf from json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PdfFile::annotate` takes the payload our browser-side annotator will produce — highlight, underline, strike-out, squiggly and ink, in pdf user space — and writes the annotated file. It hangs off the file rather than off `Document`, which pdf does not have, and addresses annotations by page index and geometry rather than by element id. A `version` that is not 1 is refused rather than half-understood, as is an unknown type, a page that is not there, a quad that is not eight coordinates, and a missing required member. Annotations landing on one page share a single page rewrite. `FileTypeCapabilities` gains `annotate`, and the conformance test holds it to the same rule as the rest: a type nothing can open cannot claim it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018e3PEzyU2oAFSzsEoWsSmz --- CHANGELOG.md | 6 + src/odr/file.cpp | 5 + src/odr/file.hpp | 16 ++- src/odr/internal/abstract/file.hpp | 4 + src/odr/internal/file_type_table.cpp | 3 +- src/odr/internal/pdf/pdf_file.cpp | 134 +++++++++++++++++++ src/odr/internal/pdf/pdf_file.hpp | 3 + test/CMakeLists.txt | 1 + test/src/odr_test.cpp | 1 + test/src/pdf_annotate_test.cpp | 185 +++++++++++++++++++++++++++ 10 files changed, 356 insertions(+), 2 deletions(-) create mode 100644 test/src/pdf_annotate_test.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 3debe76f5..da7bce48f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ 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 is copied and only the annotations are appended. Every other + viewer reads them as ordinary pdf annotations. `FileTypeCapabilities` gains an + `annotate` flag for it. + - **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..66943b81c 100644 --- a/src/odr/internal/pdf/pdf_file.cpp +++ b/src/odr/internal/pdf/pdf_file.cpp @@ -1,5 +1,17 @@ #include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include + #include #include @@ -175,4 +187,126 @@ 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; +} + +} // namespace + +void PdfFile::annotate(const std::string_view annotations, std::ostream &out, + const Logger &logger) const { + nlohmann::json json; + try { + json = nlohmann::json::parse(annotations); + } catch (const nlohmann::json::exception &e) { + throw std::invalid_argument(std::string("annotations are not json: ") + + e.what()); + } + + // 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"); + } + + DocumentParser parser = create_parser(logger); + 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[pages[index]].push_back( + type == "ink" + ? write_ink(writer, read_ink(value)) + : write_text_markup(writer, read_text_markup(value, type))); + } + + for (const auto &[page, references] : by_page) { + append_page_annotations(writer, *page, references); + } + + writer.write(out); +} + } // 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..d67c56635 --- /dev/null +++ b/test/src/pdf_annotate_test.cpp @@ -0,0 +1,185 @@ +#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; + 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); +} + +// 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_ANY_THROW(open_fixture("odr-public/pdf/Casio_WVA-M650-7AJF.pdf") + .annotate(std::string(one_highlight), out)); +} From 16a450914b70c6f81303ec1e9651235ef77a9bd6 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 6 Sep 2026 17:45:52 +0200 Subject: [PATCH 2/2] fix(pdf): refuse a mistyped annotation member like any other MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PdfFile::annotate` promises `std::invalid_argument` for a malformed payload, but nlohmann reports a member of the wrong type — `"page": "0"`, a colour of strings, a string `version` — as a `json::type_error`, which escaped the public entry point unconverted. One catch now covers the whole read, so the parse error and the type error arrive the same way, and three cases pin it. Keys the per-page bundle by page index rather than by `Page *`, so the page rewrites run in page order rather than in allocation order, and puts the new includes in the order the file already had. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0147n68S7LNAv9KynLaGgXN4 --- CHANGELOG.md | 5 +-- src/odr/internal/pdf/pdf_file.cpp | 62 ++++++++++++++++--------------- test/src/pdf_annotate_test.cpp | 17 ++++++++- 3 files changed, 50 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da7bce48f..e816214e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,9 +18,8 @@ The release run heads these entries with the version and opens a fresh - `PdfFile::annotate` writes markup annotations — highlight, underline, strike-out, squiggly and freehand ink — into a pdf as an incremental update, - so the source is copied and only the annotations are appended. Every other - viewer reads them as ordinary pdf annotations. `FileTypeCapabilities` gains an - `annotate` flag for it. + 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. diff --git a/src/odr/internal/pdf/pdf_file.cpp b/src/odr/internal/pdf/pdf_file.cpp index 66943b81c..7ae2e0327 100644 --- a/src/odr/internal/pdf/pdf_file.cpp +++ b/src/odr/internal/pdf/pdf_file.cpp @@ -1,30 +1,28 @@ #include -#include -#include -#include -#include - -#include -#include -#include -#include - -#include - #include #include #include +#include +#include +#include #include #include #include #include +#include +#include #include +#include #include #include +#include #include +#include + +#include namespace odr::internal::pdf { @@ -261,30 +259,20 @@ Ink read_ink(const nlohmann::json &value) { return result; } -} // namespace - -void PdfFile::annotate(const std::string_view annotations, std::ostream &out, - const Logger &logger) const { - nlohmann::json json; - try { - json = nlohmann::json::parse(annotations); - } catch (const nlohmann::json::exception &e) { - throw std::invalid_argument(std::string("annotations are not json: ") + - e.what()); - } - +/// @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"); } - DocumentParser parser = create_parser(logger); 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; + std::map> by_page; for (const nlohmann::json &value : json.value("annotations", nlohmann::json::array())) { @@ -296,17 +284,33 @@ void PdfFile::annotate(const std::string_view annotations, std::ostream &out, } const auto type = at(value, "type").get(); - by_page[pages[index]].push_back( + 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 &[page, references] : by_page) { - append_page_annotations(writer, *page, references); + 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/test/src/pdf_annotate_test.cpp b/test/src/pdf_annotate_test.cpp index d67c56635..10f5d669e 100644 --- a/test/src/pdf_annotate_test.cpp +++ b/test/src/pdf_annotate_test.cpp @@ -101,6 +101,7 @@ TEST(PdfAnnotate, writes_every_type) { 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()); @@ -174,12 +175,24 @@ TEST(PdfAnnotate, malformed_payloads_are_refused) { [{"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_ANY_THROW(open_fixture("odr-public/pdf/Casio_WVA-M650-7AJF.pdf") - .annotate(std::string(one_highlight), out)); + EXPECT_THROW(open_fixture("odr-public/pdf/Casio_WVA-M650-7AJF.pdf") + .annotate(std::string(one_highlight), out), + std::runtime_error); }