diff --git a/CMakeLists.txt b/CMakeLists.txt index 579af93f1..7dd020d15 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -225,6 +225,7 @@ set(ODR_SOURCE_FILES "src/odr/internal/pdf/pdf_afm.cpp" "src/odr/internal/pdf/pdf_afm_data.cpp" + "src/odr/internal/pdf/pdf_annotation.cpp" "src/odr/internal/pdf/pdf_cid.cpp" "src/odr/internal/pdf/pdf_cid_data.cpp" "src/odr/internal/pdf/pdf_cmap.cpp" diff --git a/docs/design/pdf-annotation.md b/docs/design/pdf-annotation.md index a182bec57..8b0085185 100644 --- a/docs/design/pdf-annotation.md +++ b/docs/design/pdf-annotation.md @@ -3,8 +3,8 @@ Status: **underway.** This records the architecture for adding markup annotations — text highlight and freehand drawing first — to an existing PDF, the alternatives weighed, and the effort it costs. The format model is -validated against four viewers, and Phases 0 through 1 have landed: the -incremental writer works, the annotations it will carry are not written yet. +validated against four viewers, and Phases 0 through 3 have landed: the writer +appends, and the markup and ink annotations it carries are written. Scope is **markup only**: draw on top of a page, highlight/underline/strike text. Editing or removing the *existing* text of a PDF is explicitly out — that @@ -166,10 +166,8 @@ Notes on the shape: - **`quads` order is upper-left, upper-right, lower-left, lower-right.** The spec's stated order (12.5.6.10) is counterclockwise; every implementation writes the Z-order above, and `pdfAnnotate`'s documentation says as much - outright. Follow the implementations, and say so in a comment at the one place - that emits it. Still **unverified** — see *Validated against real viewers*: - an annotation carrying an `/AP` never has its `/QuadPoints` read, so a test - has to reach for a viewer that regenerates the appearance. + outright, as does Phase 2's appearance-less experiment. Follow the + implementations, and say so in a comment at the one place that emits it. - **`delete` only names an annotation we wrote**, identified by the `/NM` we minted. Deleting a foreign annotation is out of scope: we would have to prove nothing else references it. @@ -225,9 +223,8 @@ correctly (user-space y 700/688 arrived at page-box y 92/104). Three things the spike did **not** settle, and Phase 1 and 2 owe tests for each: - **QuadPoints ordering.** With an `/AP` present, the appearance is what every - one of those engines painted — the `/QuadPoints` were never consulted. The - ordering matters only to a viewer that regenerates the appearance, and to - text-selection semantics. The note below stands as a note. + one of those engines painted — the `/QuadPoints` were never consulted. + Settled in Phase 2 with an appearance-less annotation instead. - **A page dictionary inside an object stream**, and **appending to a file whose newest section is an xref stream.** The spike's fixture had neither; Phase 1's tests cover both. @@ -279,17 +276,22 @@ ghostscript, CoreGraphics and our own renderer all honour the new rotation A page dictionary living inside an object stream is rewritten uncompressed in the new section, the newer type-1 entry winning over the older type-2 one. -### Phase 2 — highlight (2 d, ~200 lines) +### Phases 2 and 3 — text markup and ink — **done** (#847) -Annotation dictionary + appearance builder + `/Annots` append. The test is the -round trip: write, re-open with `DocumentParser`, assert the appearance resolves -and the rendered page carries a `mix-blend-mode:multiply` rect at the expected -position. +`pdf/pdf_annotation.{hpp,cpp}`: `write_text_markup` covers `/Highlight`, +`/Underline`, `/StrikeOut` and `/Squiggly`; `write_ink` covers `/Ink`, its +strokes smoothed Catmull-Rom → cubic bezier. `append_page_annotations` puts +them on the page, rewriting the `/Annots` array itself where it is indirect. -### Phase 3 — ink (1–2 d, ~150 lines) +Only the highlight multiplies (11.6.4.1) — it is a wash over the text, where +the others are marks drawn on top of it. Opacity rides on the annotation's +`/CA` alone, which a viewer applies to the whole appearance; setting `ca` in +the appearance's own state as well would square it. -Stroke smoothing (Catmull-Rom → cubic bezier) into the appearance stream. -`/BS /W`, round caps/joins. +**`/QuadPoints` ordering is settled** against two appearance-less files that +force a viewer to synthesize one: ghostscript draws the Z-order as a clean +rectangle and 12.5.6.10's counterclockwise order as a twisted blob. +CoreGraphics synthesizes nothing at all, so it is no oracle here. ### Phase 4 — public API (1 d, ~130 lines) @@ -368,8 +370,3 @@ Medium: the `Decryptor` key accessor need to land in v1 after all? - **Where does the pending-annotation state live across a reload** in the mobile WebView — the browser only, or does the host persist the payload? -- **How do we test `/QuadPoints` ordering at all?** Every engine we have as an - oracle paints the `/AP` and ignores them. Options: write one annotation - *without* an appearance and see where a viewer puts it, or check what Acrobat - does with our file. Cheap either way, but it needs deciding before Phase 2 - claims the ordering is right. diff --git a/src/odr/internal/pdf/pdf_annotation.cpp b/src/odr/internal/pdf/pdf_annotation.cpp new file mode 100644 index 000000000..a3b6ab304 --- /dev/null +++ b/src/odr/internal/pdf/pdf_annotation.cpp @@ -0,0 +1,362 @@ +#include + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include + +namespace odr::internal::pdf { + +namespace { + +/// A rectangle in user space, as `/Rect` and `/BBox` state it. +struct Box { + double x0{0}; + double y0{0}; + double x1{0}; + double y1{0}; + + void include(const double x, const double y) { + x0 = std::min(x0, x); + y0 = std::min(y0, y); + x1 = std::max(x1, x); + y1 = std::max(y1, y); + } + [[nodiscard]] Box grown(const double margin) const { + return {x0 - margin, y0 - margin, x1 + margin, y1 + margin}; + } +}; + +/// The bounding box of a non-empty range of `x y` pair sequences. +template Box box_of(const std::vector &groups) { + Box result{groups.front()[0], groups.front()[1], groups.front()[0], + groups.front()[1]}; + for (const Points &points : groups) { + for (std::size_t i = 0; i < points.size(); i += 2) { + result.include(points[i], points[i + 1]); + } + } + return result; +} + +Object rectangle(const Box &box) { + Array result; + for (const double v : {box.x0, box.y0, box.x1, box.y1}) { + result.holder().emplace_back(Real{v}); + } + return Object(std::move(result)); +} + +std::string number(const double value) { + return util::number::to_string_significant(value, 6); +} + +/// The `/ExtGState` a multiplied appearance invokes as `/G0 gs`. Opacity is not +/// in it: `/CA` already applies to the appearance as a whole (12.5.2), so +/// repeating it here would square it. +Dictionary multiply_resources() { + Dictionary state; + state["BM"] = Object(Name{"Multiply"}); + Dictionary states; + states["G0"] = Object(std::move(state)); + Dictionary result; + result["ExtGState"] = Object(std::move(states)); + return result; +} + +/// The form XObject an annotation's `/AP /N` points at. A transparency group is +/// what lets `/BM /Multiply` composite against the page rather than against the +/// form's own backdrop. +Dictionary appearance_dictionary(const Box &box, Dictionary resources, + const bool transparency_group) { + Dictionary result; + result["Type"] = Object(Name{"XObject"}); + result["Subtype"] = Object(Name{"Form"}); + result["BBox"] = rectangle(box); + result["Resources"] = Object(std::move(resources)); + if (transparency_group) { + Dictionary group; + group["Type"] = Object(Name{"Group"}); + group["S"] = Object(Name{"Transparency"}); + group["CS"] = Object(Name{"DeviceRGB"}); + result["Group"] = Object(std::move(group)); + } + return result; +} + +void write_common(Dictionary &dictionary, const AnnotationCommon &common, + const ObjectReference &self) { + Array color; + for (const double c : common.color) { + color.holder().emplace_back(Real{c}); + } + dictionary["C"] = Object(std::move(color)); + dictionary["CA"] = Object(Real{common.opacity}); + // 12.5.3: bit 3, Print. Without it a viewer may show but never print it. + dictionary["F"] = Object(Integer{4}); + dictionary["NM"] = Object(StandardString{fmt::format("odr-{}", self.id)}); + if (!common.author.empty()) { + dictionary["T"] = Object(StandardString{common.author}); + } + if (!common.contents.empty()) { + dictionary["Contents"] = Object(StandardString{common.contents}); + } +} + +std::string_view subtype_of(const TextMarkupKind kind) { + switch (kind) { + case TextMarkupKind::highlight: + return "Highlight"; + case TextMarkupKind::underline: + return "Underline"; + case TextMarkupKind::strike_out: + return "StrikeOut"; + case TextMarkupKind::squiggly: + return "Squiggly"; + } + throw std::invalid_argument("unknown text markup kind"); +} + +/// A quad's extent, whichever corners it states. +struct QuadCorners { + double left{0}; + double right{0}; + double top{0}; + double bottom{0}; +}; + +QuadCorners corners_of(const Quad &quad) { + return {std::min({quad[0], quad[2], quad[4], quad[6]}), + std::max({quad[0], quad[2], quad[4], quad[6]}), + std::max({quad[1], quad[3], quad[5], quad[7]}), + std::min({quad[1], quad[3], quad[5], quad[7]})}; +} + +void bar(std::ostringstream &out, const QuadCorners &quad, const double bottom, + const double height) { + out << number(quad.left) << ' ' << number(bottom) << ' ' + << number(quad.right - quad.left) << ' ' << number(height) << " re\n"; +} + +/// A wave along the bottom of `quad`, as a stroked zigzag of `amplitude`. +void wave(std::ostringstream &out, const QuadCorners &quad, + const double amplitude) { + const double base = quad.bottom + amplitude; + out << number(quad.left) << ' ' << number(base) << " m\n"; + const auto steps = static_cast( + std::max(1.0, std::floor((quad.right - quad.left) / amplitude))); + for (std::size_t i = 1; i <= steps; ++i) { + out << number(quad.left + static_cast(i) * amplitude) << ' ' + << number(i % 2 == 1 ? base + amplitude : base) << " l\n"; + } + out << "S\n"; +} + +std::string text_markup_appearance(const TextMarkup &markup) { + std::ostringstream out; + if (markup.kind == TextMarkupKind::highlight) { + out << "/G0 gs\n"; + } + const auto &[r, g, b] = markup.common.color; + out << number(r) << ' ' << number(g) << ' ' << number(b); + out << (markup.kind == TextMarkupKind::squiggly ? " RG\n" : " rg\n"); + + for (const Quad &quad : markup.quads) { + const QuadCorners c = corners_of(quad); + const double height = c.top - c.bottom; + switch (markup.kind) { + case TextMarkupKind::highlight: + bar(out, c, c.bottom, height); + break; + case TextMarkupKind::underline: + bar(out, c, c.bottom + height / 16, std::max(height / 16, 0.5)); + break; + case TextMarkupKind::strike_out: + bar(out, c, c.bottom + height / 2, std::max(height / 16, 0.5)); + break; + case TextMarkupKind::squiggly: + out << number(std::max(height / 16, 0.5)) << " w\n"; + wave(out, c, std::max(height / 8, 1.0)); + break; + } + } + + if (markup.kind != TextMarkupKind::squiggly) { + out << "f\n"; + } + return std::move(out).str(); +} + +/// Catmull-Rom through `stroke`, emitted as the cubic beziers PDF has: the +/// tangent at a point is half the vector between its neighbours, and a control +/// point sits a third of that away. +void smooth_path(std::ostringstream &out, const InkStroke &stroke) { + const std::size_t count = stroke.size() / 2; + const auto x = [&](const std::size_t i) { + return stroke[2 * std::clamp(i, 0, count - 1)]; + }; + const auto y = [&](const std::size_t i) { + return stroke[2 * std::clamp(i, 0, count - 1) + 1]; + }; + + out << number(x(0)) << ' ' << number(y(0)) << " m\n"; + if (count == 1) { + // a dot: a zero-length segment, which round caps render as a disc + out << number(x(0)) << ' ' << number(y(0)) << " l\n"; + return; + } + for (std::size_t i = 0; i + 1 < count; ++i) { + const double c1x = x(i) + (x(i + 1) - x(i == 0 ? 0 : i - 1)) / 6; + const double c1y = y(i) + (y(i + 1) - y(i == 0 ? 0 : i - 1)) / 6; + const double c2x = x(i + 1) - (x(i + 2) - x(i)) / 6; + const double c2y = y(i + 1) - (y(i + 2) - y(i)) / 6; + out << number(c1x) << ' ' << number(c1y) << ' ' << number(c2x) << ' ' + << number(c2y) << ' ' << number(x(i + 1)) << ' ' << number(y(i + 1)) + << " c\n"; + } +} + +std::string ink_appearance(const Ink &ink) { + std::ostringstream out; + const auto &[r, g, b] = ink.common.color; + out << number(r) << ' ' << number(g) << ' ' << number(b) << " RG\n"; + out << number(ink.width) << " w 1 J 1 j\n"; + for (const InkStroke &stroke : ink.strokes) { + smooth_path(out, stroke); + out << "S\n"; + } + return std::move(out).str(); +} + +Object annotation_appearance(const ObjectReference &appearance) { + Dictionary result; + result["N"] = Object(appearance); + return Object(std::move(result)); +} + +} // namespace + +} // namespace odr::internal::pdf + +namespace odr::internal { + +pdf::ObjectReference pdf::write_text_markup(IncrementalWriter &writer, + const TextMarkup &markup) { + if (markup.quads.empty()) { + throw std::invalid_argument("text markup has no quads"); + } + + const Box box = box_of(markup.quads); + const ObjectReference appearance = writer.mint_object(); + const ObjectReference annotation = writer.mint_object(); + + // 11.6.4.1: only a highlight is a wash over the text; the others are marks + // drawn on top and blend normally. + const bool multiply = markup.kind == TextMarkupKind::highlight; + writer.set_stream_object( + appearance, + appearance_dictionary(box, multiply ? multiply_resources() : Dictionary{}, + multiply), + text_markup_appearance(markup)); + + Dictionary dictionary; + dictionary["Type"] = Object(Name{"Annot"}); + dictionary["Subtype"] = Object(Name{std::string(subtype_of(markup.kind))}); + dictionary["Rect"] = rectangle(box); + Array quad_points; + for (const Quad &quad : markup.quads) { + for (const double v : quad) { + quad_points.holder().emplace_back(Real{v}); + } + } + dictionary["QuadPoints"] = Object(std::move(quad_points)); + write_common(dictionary, markup.common, annotation); + dictionary["AP"] = annotation_appearance(appearance); + + writer.set_object(annotation, Object(std::move(dictionary))); + return annotation; +} + +pdf::ObjectReference pdf::write_ink(IncrementalWriter &writer, const Ink &ink) { + if (ink.strokes.empty()) { + throw std::invalid_argument("ink has no strokes"); + } + for (const InkStroke &stroke : ink.strokes) { + if (stroke.empty() || stroke.size() % 2 != 0) { + throw std::invalid_argument("ink stroke is not a sequence of x y pairs"); + } + } + + // the stroke straddles the path, and a round join can reach half a width out + const Box box = box_of(ink.strokes).grown(ink.width); + const ObjectReference appearance = writer.mint_object(); + const ObjectReference annotation = writer.mint_object(); + + writer.set_stream_object(appearance, + appearance_dictionary(box, Dictionary{}, false), + ink_appearance(ink)); + + Dictionary dictionary; + dictionary["Type"] = Object(Name{"Annot"}); + dictionary["Subtype"] = Object(Name{"Ink"}); + dictionary["Rect"] = rectangle(box); + Array ink_list; + for (const InkStroke &stroke : ink.strokes) { + Array points; + for (const double v : stroke) { + points.holder().emplace_back(Real{v}); + } + ink_list.holder().emplace_back(std::move(points)); + } + dictionary["InkList"] = Object(std::move(ink_list)); + Dictionary border; + border["W"] = Object(Real{ink.width}); + dictionary["BS"] = Object(std::move(border)); + write_common(dictionary, ink.common, annotation); + dictionary["AP"] = annotation_appearance(appearance); + + writer.set_object(annotation, Object(std::move(dictionary))); + return annotation; +} + +void pdf::append_page_annotations( + IncrementalWriter &writer, const Page &page, + const std::vector &annotations) { + if (annotations.empty()) { + return; + } + + Dictionary dictionary = page.object.as_dictionary(); + const Object &existing = dictionary.get("Annots"); + + const auto extend = [&annotations](Array array) { + for (const ObjectReference &annotation : annotations) { + array.holder().emplace_back(annotation); + } + return array; + }; + + // where `/Annots` is indirect, rewriting the array leaves the page dictionary + // untouched + if (existing.is_reference()) { + const ObjectReference reference = existing.as_reference(); + const Object &array = writer.parser().read_object(reference).object; + writer.set_object( + reference, + Object(extend(array.is_array() ? array.as_array() : Array{}))); + return; + } + + dictionary["Annots"] = + Object(extend(existing.is_array() ? existing.as_array() : Array{})); + writer.set_object(page.object_reference, Object(std::move(dictionary))); +} + +} // namespace odr::internal diff --git a/src/odr/internal/pdf/pdf_annotation.hpp b/src/odr/internal/pdf/pdf_annotation.hpp new file mode 100644 index 000000000..82f31ba15 --- /dev/null +++ b/src/odr/internal/pdf/pdf_annotation.hpp @@ -0,0 +1,63 @@ +#pragma once + +#include + +#include +#include +#include + +namespace odr::internal::pdf { + +class IncrementalWriter; +struct Page; + +/// A text markup quadrilateral in user space: upper-left, upper-right, +/// lower-left, lower-right — the order producers write, not the +/// counterclockwise one 12.5.6.10 states. +using Quad = std::array; + +/// One pen-down to pen-up stroke as flat `x y` pairs in user space. +using InkStroke = std::vector; + +enum class TextMarkupKind { + highlight, ///< 12.5.6.10, multiplied over the text it covers + underline, ///< a bar along the bottom of each quad + strike_out, ///< a bar across the middle of each quad + squiggly, ///< a wave along the bottom of each quad +}; + +/// Fields every markup annotation carries (12.5.2). +struct AnnotationCommon { + std::array color{0, 0, 0}; ///< `/C`, DeviceRGB in [0, 1] + double opacity{1}; ///< `/CA` + std::string author; ///< `/T`, omitted when empty + std::string contents; ///< `/Contents`, omitted when empty +}; + +struct TextMarkup { + TextMarkupKind kind{TextMarkupKind::highlight}; + std::vector quads; + AnnotationCommon common; +}; + +struct Ink { + std::vector strokes; + double width{1}; ///< `/BS /W`, in points + AnnotationCommon common; +}; + +/// Write the annotation together with the appearance stream it paints through, +/// so no viewer has to synthesize one. +/// @throws std::invalid_argument on empty or malformed geometry. +ObjectReference write_text_markup(IncrementalWriter &writer, + const TextMarkup &markup); +ObjectReference write_ink(IncrementalWriter &writer, const Ink &ink); + +/// Append `annotations` to `page`'s `/Annots`, rewriting whichever object holds +/// it — the page dictionary, or the array itself where `/Annots` is indirect. +/// Reads the page as the source file has it, so call it once per page with +/// everything that page gains. +void append_page_annotations(IncrementalWriter &writer, const Page &page, + const std::vector &annotations); + +} // namespace odr::internal::pdf diff --git a/src/odr/internal/pdf/pdf_writer.hpp b/src/odr/internal/pdf/pdf_writer.hpp index 1d7c094d7..5738c2667 100644 --- a/src/odr/internal/pdf/pdf_writer.hpp +++ b/src/odr/internal/pdf/pdf_writer.hpp @@ -19,6 +19,9 @@ class IncrementalWriter final { /// cross-reference table, or encrypted. explicit IncrementalWriter(DocumentParser &parser); + /// The parser the update is being written against. + [[nodiscard]] DocumentParser &parser() const noexcept { return *m_parser; } + /// An id past every one the file uses. [[nodiscard]] ObjectReference mint_object(); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index f1d862188..8d142089d 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -93,6 +93,7 @@ add_executable(odr_test "src/internal/ooxml/ooxml_util_test.cpp" "src/internal/ooxml/ooxml_presentation_style_test.cpp" + "src/internal/pdf/pdf_annotation.cpp" "src/internal/pdf/pdf_cid.cpp" "src/internal/pdf/pdf_cmap.cpp" "src/internal/pdf/pdf_color.cpp" diff --git a/test/src/internal/pdf/pdf_annotation.cpp b/test/src/internal/pdf/pdf_annotation.cpp new file mode 100644 index 000000000..b5448c864 --- /dev/null +++ b/test/src/internal/pdf/pdf_annotation.cpp @@ -0,0 +1,271 @@ +#include + +#include +#include +#include +#include +#include + +#include + +#include + +#include +#include +#include +#include + +#include + +using namespace odr::internal; +using namespace odr::internal::pdf; +using namespace odr::test; +using PdfFileBuilder = odr::test::pdf::PdfFileBuilder; + +namespace { + +std::string mini_pdf() { + PdfFileBuilder builder; + builder.object("<< /Type /Catalog /Pages 2 0 R >>") + .object("<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + .object("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + "/Resources << >> /Contents 4 0 R >>") + .stream_object("", "BT ET") + .trailer("/Root 1 0 R /ID [<0102> <0304>]"); + return builder.build_classic(); +} + +const Page *first_page(const Document &document) { + const auto &kids = document.catalog->pages->kids; + return kids.empty() ? nullptr : dynamic_cast(kids.front()); +} + +/// Writes `annotate`'s annotations onto the first page of `pdf` and returns the +/// resulting file. +template +std::string annotated(const std::string &pdf, Annotate &&annotate) { + DocumentParser parser(std::make_unique(pdf)); + const std::unique_ptr document = parser.parse_document(); + const Page *page = first_page(*document); + EXPECT_NE(page, nullptr); + + IncrementalWriter writer(parser); + append_page_annotations(writer, *page, annotate(writer)); + std::ostringstream out; + writer.write(out); + return std::move(out).str(); +} + +std::string with_markup(const TextMarkup &markup) { + return annotated(mini_pdf(), [&markup](IncrementalWriter &w) { + return std::vector{write_text_markup(w, markup)}; + }); +} + +/// The dictionary of the one annotation `markup` produces. +Dictionary markup_annotation(const TextMarkup &markup) { + DocumentParser parser( + std::make_unique(with_markup(markup))); + const std::unique_ptr document = parser.parse_document(); + return first_page(*document)->annotations.front()->object.as_dictionary(); +} + +TextMarkup one_line_highlight() { + TextMarkup markup; + markup.kind = TextMarkupKind::highlight; + markup.quads.push_back({72, 700, 300, 700, 72, 688, 300, 688}); + markup.common.color = {1, 0.9, 0.2}; + return markup; +} + +} // namespace + +// The annotation and its appearance both land, and the page reaches them. +TEST(PdfAnnotation, highlight_round_trips) { + DocumentParser parser( + std::make_unique(with_markup(one_line_highlight()))); + const std::unique_ptr document = parser.parse_document(); + const Page *page = first_page(*document); + ASSERT_NE(page, nullptr); + ASSERT_EQ(page->annotations.size(), 1); + + const Annotation &annotation = *page->annotations.front(); + const Dictionary &dictionary = annotation.object.as_dictionary(); + EXPECT_EQ(dictionary.get("Subtype").as_string(), "Highlight"); + EXPECT_EQ(dictionary.get("QuadPoints").as_array().size(), 8); + EXPECT_EQ(dictionary.get("F").as_integer(), 4); + EXPECT_EQ(dictionary.get("NM").as_string(), "odr-6"); + + // `/Rect` is the union of the quads + const std::vector rect = dictionary.get("Rect").as_reals(); + EXPECT_DOUBLE_EQ(rect[0], 72); + EXPECT_DOUBLE_EQ(rect[1], 688); + EXPECT_DOUBLE_EQ(rect[2], 300); + EXPECT_DOUBLE_EQ(rect[3], 700); + + // the parser resolved `/AP /N` into a form, which is what makes it paint + ASSERT_NE(annotation.appearance, nullptr); +} + +// 11.6.4.1: the highlight is a wash, so its appearance multiplies; the marks +// drawn on top of the text do not. Opacity stays out of the state — `/CA` +// already applies to the whole appearance, so a second `ca` would square it. +TEST(PdfAnnotation, only_highlight_multiplies) { + const auto resources = [](const TextMarkupKind kind) { + TextMarkup markup = one_line_highlight(); + markup.kind = kind; + markup.common.opacity = 0.5; + + DocumentParser parser( + std::make_unique(with_markup(markup))); + const std::unique_ptr document = parser.parse_document(); + const Dictionary &dictionary = + first_page(*document)->annotations.front()->object.as_dictionary(); + EXPECT_DOUBLE_EQ(dictionary.get("CA").as_real(), 0.5); + return parser + .read_object( + dictionary.get("AP").as_dictionary().get("N").as_reference()) + .object.as_dictionary() + .get("Resources") + .as_dictionary(); + }; + + const Dictionary state = resources(TextMarkupKind::highlight) + .get("ExtGState") + .as_dictionary() + .get("G0") + .as_dictionary(); + EXPECT_EQ(state.get("BM").as_string(), "Multiply"); + EXPECT_FALSE(state.has_key("ca")); + EXPECT_FALSE(state.has_key("CA")); + + for (const TextMarkupKind kind : + {TextMarkupKind::underline, TextMarkupKind::strike_out, + TextMarkupKind::squiggly}) { + EXPECT_FALSE(resources(kind).has_key("ExtGState")); + } +} + +TEST(PdfAnnotation, text_markup_subtypes) { + const auto subtype = [](const TextMarkupKind kind) { + TextMarkup markup = one_line_highlight(); + markup.kind = kind; + return markup_annotation(markup).get("Subtype").as_string(); + }; + + EXPECT_EQ(subtype(TextMarkupKind::highlight), "Highlight"); + EXPECT_EQ(subtype(TextMarkupKind::underline), "Underline"); + EXPECT_EQ(subtype(TextMarkupKind::strike_out), "StrikeOut"); + EXPECT_EQ(subtype(TextMarkupKind::squiggly), "Squiggly"); +} + +TEST(PdfAnnotation, ink_round_trips) { + Ink ink; + ink.strokes.push_back({100, 500, 130, 540, 160, 490}); + ink.width = 2; + ink.common.color = {0.9, 0.1, 0.1}; + + const std::string result = + annotated(mini_pdf(), [&ink](IncrementalWriter &w) { + return std::vector{write_ink(w, ink)}; + }); + + DocumentParser parser(std::make_unique(result)); + const std::unique_ptr document = parser.parse_document(); + const Page *page = first_page(*document); + ASSERT_NE(page, nullptr); + ASSERT_EQ(page->annotations.size(), 1); + + const Annotation &annotation = *page->annotations.front(); + const Dictionary &dictionary = annotation.object.as_dictionary(); + EXPECT_EQ(dictionary.get("Subtype").as_string(), "Ink"); + ASSERT_EQ(dictionary.get("InkList").as_array().size(), 1); + EXPECT_EQ(dictionary.get("InkList").as_array()[0].as_array().size(), 6); + EXPECT_DOUBLE_EQ(dictionary.get("BS").as_dictionary().get("W").as_real(), 2); + ASSERT_NE(annotation.appearance, nullptr); + + // the box is grown by the stroke width, which straddles the path + const std::vector rect = dictionary.get("Rect").as_reals(); + EXPECT_DOUBLE_EQ(rect[0], 98); + EXPECT_DOUBLE_EQ(rect[1], 488); + EXPECT_DOUBLE_EQ(rect[2], 162); + EXPECT_DOUBLE_EQ(rect[3], 542); +} + +TEST(PdfAnnotation, empty_geometry_throws) { + DocumentParser parser(std::make_unique(mini_pdf())); + IncrementalWriter writer(parser); + + EXPECT_THROW(std::ignore = write_text_markup(writer, TextMarkup{}), + std::invalid_argument); + EXPECT_THROW(std::ignore = write_ink(writer, Ink{}), std::invalid_argument); + + Ink odd; + odd.strokes.push_back({1, 2, 3}); + EXPECT_THROW(std::ignore = write_ink(writer, odd), std::invalid_argument); +} + +TEST(PdfAnnotation, optional_fields_are_omitted_when_empty) { + TextMarkup markup = one_line_highlight(); + { + const Dictionary dictionary = markup_annotation(markup); + EXPECT_FALSE(dictionary.has_key("T")); + EXPECT_FALSE(dictionary.has_key("Contents")); + } + + markup.common.author = "a reviewer"; + markup.common.contents = "why (this) matters"; + { + const Dictionary dictionary = markup_annotation(markup); + EXPECT_EQ(dictionary.get("T").as_string(), "a reviewer"); + // the parenthesis survives the escaping the writer applies + EXPECT_EQ(dictionary.get("Contents").as_string(), "why (this) matters"); + } +} + +// Several annotations on one page share a single page rewrite. +TEST(PdfAnnotation, appends_several_annotations) { + Ink ink; + ink.strokes.push_back({10, 10, 20, 20}); + + const std::string result = + annotated(mini_pdf(), [&ink](IncrementalWriter &w) { + return std::vector{write_text_markup(w, one_line_highlight()), + write_ink(w, ink)}; + }); + + DocumentParser parser(std::make_unique(result)); + const std::unique_ptr document = parser.parse_document(); + EXPECT_EQ(first_page(*document)->annotations.size(), 2); +} + +// A real file whose pages already carry link annotations: ours are appended, +// the existing ones stay. +TEST(PdfAnnotation, appends_to_an_existing_annots_array) { + const auto file = std::make_shared( + TestData::test_file_path("odr-public/pdf/style-various-1.pdf")); + + std::size_t before = 0; + std::ostringstream out; + { + DocumentParser parser(file->stream()); + const std::unique_ptr document = parser.parse_document(); + const Page *page = first_page(*document); + ASSERT_NE(page, nullptr); + before = page->annotations.size(); + ASSERT_GT(before, 0); + + IncrementalWriter writer(parser); + append_page_annotations(writer, *page, + {write_text_markup(writer, one_line_highlight())}); + writer.write(out); + } + + DocumentParser parser( + std::make_unique(std::move(out).str())); + const std::unique_ptr document = parser.parse_document(); + const Page *page = first_page(*document); + ASSERT_NE(page, nullptr); + EXPECT_EQ(page->annotations.size(), before + 1); + EXPECT_NE(page->annotations.back()->appearance, nullptr); +}