diff --git a/src/odr/internal/html/common.cpp b/src/odr/internal/html/common.cpp index 2092e60c..fb4d6218 100644 --- a/src/odr/internal/html/common.cpp +++ b/src/odr/internal/html/common.cpp @@ -37,6 +37,14 @@ std::string html::escape_text(std::string text) { return text; } +std::string html::escape_attribute(std::string value) { + util::string::replace_all(value, "&", "&"); + util::string::replace_all(value, "\"", """); + util::string::replace_all(value, "<", "<"); + util::string::replace_all(value, ">", ">"); + return value; +} + std::string html::color(const Color &color) { std::stringstream ss; ss << "#"; diff --git a/src/odr/internal/html/common.hpp b/src/odr/internal/html/common.hpp index 188dbefe..711b193d 100644 --- a/src/odr/internal/html/common.hpp +++ b/src/odr/internal/html/common.hpp @@ -35,6 +35,10 @@ struct WritingState { std::string escape_text(std::string text); +/// Escape a string for use as an HTML double-quoted attribute value (`&`, `"`, +/// `<`, `>`). Unlike `escape_text`, it leaves leading/trailing spaces intact. +std::string escape_attribute(std::string value); + std::string color(const Color &color); std::string file_to_url(const std::string &file, const std::string &mime_type); diff --git a/src/odr/internal/html/pdf_file.cpp b/src/odr/internal/html/pdf_file.cpp index 4089769c..2e4ba193 100644 --- a/src/odr/internal/html/pdf_file.cpp +++ b/src/odr/internal/html/pdf_file.cpp @@ -57,6 +57,242 @@ std::string svg_matrix(const util::math::Transform2D &m) { return std::move(f).str(); } +/// One resolved link annotation, positioned in page-box points (y-down). +struct LinkOut { + double left{0}; + double top{0}; + double width{0}; + double height{0}; + std::string href; ///< external URI or internal "#pN"; already attr-escaped + bool internal{false}; ///< true for a `#pN` target (needs `target="_self"`) +}; + +/// Resolves a link annotation's destination to a page: a `page-object -> +/// 0-based index` map plus the catalog's named-destination table (`/Dests` +/// dictionary and the `/Names /Dests` name tree, ISO 32000-1 12.3.2.3). +struct LinkResolver { + pdf::DocumentParser &parser; + std::map page_index; + std::map named_dests; ///< name -> raw dest value + + /// A destination (a name/string, a `[page …]` array, or a dict with `/D`) + /// to its target page index, if it resolves to a known page. + [[nodiscard]] std::optional resolve_dest_page(pdf::Object dest) { + dest = parser.resolve_object_copy(std::move(dest)); + if (dest.is_string() || dest.is_name()) { + const std::string name = + dest.is_string() ? dest.as_string() : dest.as_name(); + const auto it = named_dests.find(name); + if (it == named_dests.end()) { + return std::nullopt; + } + dest = parser.resolve_object_copy(it->second); + } + if (dest.is_dictionary() && dest.as_dictionary().has_value("D")) { + dest = parser.resolve_object_copy(dest.as_dictionary().get("D")); + } + if (!dest.is_array() || dest.as_array().empty()) { + return std::nullopt; + } + const pdf::Object &target = dest.as_array()[0]; + if (!target.is_reference()) { + return std::nullopt; + } + const auto it = page_index.find(target.as_reference()); + return it != page_index.end() ? std::optional(it->second) + : std::nullopt; + } +}; + +/// Walk a destination name tree (`Names` leaf pairs, `Kids` intermediates), +/// depth-guarded, collecting `name -> dest` into `out`. +void collect_dest_name_tree(pdf::DocumentParser &parser, + const pdf::Object &node_ref, + std::map &out, + const int depth) { + if (depth > 50) { + return; + } + const pdf::Object node = parser.resolve_object_copy(node_ref); + if (!node.is_dictionary()) { + return; + } + const pdf::Dictionary &dictionary = node.as_dictionary(); + if (dictionary.has_value("Names")) { + const pdf::Array names = + parser.resolve_object_copy(dictionary.get("Names")).as_array(); + for (std::size_t i = 0; i + 1 < names.size(); i += 2) { + const pdf::Object key = parser.resolve_object_copy(names[i]); + if (key.is_string()) { + out.emplace(key.as_string(), names[i + 1]); + } + } + } + if (dictionary.has_value("Kids")) { + const pdf::Array kids = + parser.resolve_object_copy(dictionary.get("Kids")).as_array(); + for (const pdf::Object &kid : kids) { + collect_dest_name_tree(parser, kid, out, depth + 1); + } + } +} + +/// Build the link resolver once per document: the page-index map and the +/// catalog's named destinations. +LinkResolver build_link_resolver(pdf::DocumentParser &parser, + const pdf::Document &document, + const std::vector &pages) { + LinkResolver resolver{parser, {}, {}}; + for (std::size_t i = 0; i < pages.size(); ++i) { + resolver.page_index.emplace(pages[i]->object_reference, i); + } + if (document.catalog != nullptr && document.catalog->object.is_dictionary()) { + const pdf::Dictionary &catalog = document.catalog->object.as_dictionary(); + if (catalog.has_value("Dests")) { + const pdf::Object dests = + parser.resolve_object_copy(catalog.get("Dests")); + if (dests.is_dictionary()) { + for (const auto &[key, value] : dests.as_dictionary()) { + resolver.named_dests.emplace(key, value); + } + } + } + if (catalog.has_value("Names")) { + const pdf::Object names = + parser.resolve_object_copy(catalog.get("Names")); + if (names.is_dictionary() && names.as_dictionary().has_value("Dests")) { + collect_dest_name_tree(parser, names.as_dictionary().get("Dests"), + resolver.named_dests, 0); + } + } + } + return resolver; +} + +/// Whether a `/URI` action target is safe to emit as an `href`. A PDF is +/// untrusted input, so active schemes (`javascript:`, `data:`, `vbscript:`, …) +/// must not become a clickable link that executes in the generated document. +/// We allow only the common navigable schemes plus scheme-less (relative) +/// references. Embedded ASCII whitespace/control bytes are ignored when reading +/// the scheme, matching browsers that strip them before dispatch (so +/// `java\tscript:` cannot slip through). +bool is_safe_uri(std::string_view uri) { + std::string scheme; + for (const char ch : uri) { + const auto c = static_cast(ch); + if (ch == ':') { + for (char &s : scheme) { + s = static_cast(std::tolower(static_cast(s))); + } + static constexpr std::string_view allowed[] = {"http", "https", "mailto", + "ftp", "ftps", "tel"}; + return std::find(std::begin(allowed), std::end(allowed), scheme) != + std::end(allowed); + } + if (ch == '/' || ch == '?' || ch == '#') { + return true; // path/query/fragment reached first -> relative reference + } + if (c <= 0x20) { + continue; // browsers strip embedded whitespace/control bytes + } + if (std::isalnum(c) != 0 || ch == '+' || ch == '-' || ch == '.') { + scheme.push_back(ch); + continue; + } + return true; // not a valid scheme character -> relative reference + } + return true; // no ':' -> relative reference +} + +/// Resolve a page's `/Link` annotations (ISO 32000-1 12.5.6.5) to positioned +/// overlays: a `/URI` action becomes an external link, a `/GoTo` action or a +/// direct `/Dest` an internal `#pN` link. `to_box` maps PDF user space to the +/// page box (points, y-down). +std::vector collect_page_links(const pdf::Page &page, + const util::math::Transform2D &to_box, + LinkResolver &resolver) { + std::vector links; + pdf::DocumentParser &parser = resolver.parser; + for (const pdf::Annotation *annotation : page.annotations) { + if (annotation == nullptr || !annotation->object.is_dictionary()) { + continue; + } + const pdf::Dictionary &dictionary = annotation->object.as_dictionary(); + const pdf::Object subtype = + parser.resolve_object_copy(dictionary.get("Subtype")); + if (!subtype.is_name() || subtype.as_name() != "Link") { + continue; + } + const pdf::Object rect = parser.resolve_object_copy(dictionary.get("Rect")); + if (!rect.is_array() || rect.as_array().size() < 4) { + continue; + } + const std::vector r = rect.as_reals(); + + std::string href; + bool internal = false; + if (dictionary.has_value("A")) { + const pdf::Object action = + parser.resolve_object_copy(dictionary.get("A")); + if (action.is_dictionary()) { + const pdf::Dictionary &a = action.as_dictionary(); + const pdf::Object s = parser.resolve_object_copy(a.get("S")); + const std::string kind = s.is_name() ? s.as_name() : ""; + if (kind == "URI" && a.has_value("URI")) { + const pdf::Object uri = parser.resolve_object_copy(a.get("URI")); + if (uri.is_string() && is_safe_uri(uri.as_string())) { + href = uri.as_string(); + } + } else if (kind == "GoTo" && a.has_value("D")) { + if (const auto index = resolver.resolve_dest_page(a.get("D"))) { + href = "#p" + std::to_string(*index + 1); + internal = true; + } + } + } + } + if (href.empty() && dictionary.has_value("Dest")) { + if (const auto index = + resolver.resolve_dest_page(dictionary.get("Dest"))) { + href = "#p" + std::to_string(*index + 1); + internal = true; + } + } + if (href.empty()) { + continue; + } + + const std::array p0 = to_box.apply(r[0], r[1]); + const std::array p1 = to_box.apply(r[2], r[3]); + LinkOut link; + link.left = std::min(p0[0], p1[0]); + link.top = std::min(p0[1], p1[1]); + link.width = std::abs(p1[0] - p0[0]); + link.height = std::abs(p1[1] - p0[1]); + link.href = escape_attribute(std::move(href)); + link.internal = internal; + links.push_back(std::move(link)); + } + return links; +} + +/// Write a page's link overlays as absolutely-positioned `` elements (in +/// page-box points, matching the text layer's unit). +void write_page_links(HtmlWriter &out, const std::vector &links) { + for (const LinkOut &link : links) { + std::ostringstream a; + // Internal `#pN` links must override the document's `` so they scroll within the rendered PDF instead of + // opening a new copy. + a << ""; + out.write_raw(std::move(a).str()); + } +} + /// Clamp a colour component in [0, 1] to an 8-bit channel value. std::int32_t to255(const double v) { return static_cast( @@ -1006,6 +1242,7 @@ class HtmlServiceImpl final : public HtmlService { std::vector vis_items; std::vector sel_lines; std::string clip_defs; + std::vector links; }; HtmlResources write_document_dual_layer(HtmlWriter &out) const { @@ -1016,6 +1253,7 @@ class HtmlServiceImpl final : public HtmlService { pdf::DocumentParser parser = pdf_file.create_parser(*m_logger); const std::unique_ptr document = parser.parse_document(); const std::vector pages = document->collect_pages(); + LinkResolver link_resolver = build_link_resolver(parser, *document, pages); AtomicStyles styles; std::vector pages_out; @@ -1072,6 +1310,7 @@ class HtmlServiceImpl final : public HtmlService { page_out.classes = pb.classes; page_out.width = width; page_out.height = height; + page_out.links = collect_page_links(*page, to_box, link_resolver); std::string stream; for (const auto &ref : page->contents_reference) { @@ -1436,9 +1675,13 @@ class HtmlServiceImpl final : public HtmlService { }; out.write_body_begin(); + std::size_t page_number = 0; for (const DualPageOut &page : pages_out) { - out.write_element_begin("div", - HtmlElementOptions().set_class(page.classes)); + out.write_element_begin( + "div", + HtmlElementOptions() + .set_class(page.classes) + .set_extra(R"(id="p)" + std::to_string(++page_number) + R"(")")); // Visual layer: paint-order graphics and unselectable glyphs. out.write_element_begin("div", @@ -1455,6 +1698,9 @@ class HtmlServiceImpl final : public HtmlService { } out.write_element_end("div"); // .sel + // Link overlays on top so they stay clickable. + write_page_links(out, page.links); + out.write_element_end("div"); // .p } out.write_body_end(); @@ -1514,6 +1760,7 @@ class HtmlServiceImpl final : public HtmlService { double height{0}; std::vector items; std::string clip_defs; + std::vector links; }; HtmlResources write_document_single_layer(HtmlWriter &out) const { @@ -1524,6 +1771,7 @@ class HtmlServiceImpl final : public HtmlService { pdf::DocumentParser parser = pdf_file.create_parser(*m_logger); const std::unique_ptr document = parser.parse_document(); const std::vector pages = document->collect_pages(); + LinkResolver link_resolver = build_link_resolver(parser, *document, pages); // ---- Font registration ------------------------------------------------ // A real-Unicode scalar gets a cmap entry only inside the BMP and outside @@ -1662,6 +1910,7 @@ class HtmlServiceImpl final : public HtmlService { page_out.classes = pb.classes; page_out.width = width; page_out.height = height; + page_out.links = collect_page_links(page, to_box, link_resolver); ClipRegistry clips(static_cast(pages_out.size())); GradientRegistry gradients(static_cast(pages_out.size())); @@ -1950,11 +2199,16 @@ class HtmlServiceImpl final : public HtmlService { }; out.write_body_begin(); + std::size_t page_number = 0; for (const SinglePageOut &page : pages_out) { - out.write_element_begin("div", - HtmlElementOptions().set_class(page.classes)); + out.write_element_begin( + "div", + HtmlElementOptions() + .set_class(page.classes) + .set_extra(R"(id="p)" + std::to_string(++page_number) + R"(")")); write_page_items(out, page.clip_defs, page.items, page.width, page.height, write_line); + write_page_links(out, page.links); out.write_element_end("div"); } out.write_body_end(); @@ -2195,6 +2449,8 @@ class HtmlServiceImpl final : public HtmlService { // SVG overlay covering the page box (visual graphics layer). out.out() << ".s{position:absolute;left:0;top:0;width:100%;height:100%;" "overflow:hidden;pointer-events:none}"; + // Link annotation overlays (absolutely positioned in page-box points). + out.out() << ".lk{position:absolute;transform-origin:0 0}"; out.out() << font_faces; out.out() << font_styles; styles.write_rules(out.out()); diff --git a/src/odr/internal/pdf/AGENTS.md b/src/odr/internal/pdf/AGENTS.md index 195f05fd..99f365b7 100644 --- a/src/odr/internal/pdf/AGENTS.md +++ b/src/odr/internal/pdf/AGENTS.md @@ -274,6 +274,19 @@ inline SVG per page. Experimental and not production-quality. matches rendering) is unsettled — currently `/Ascent` wins. The ascent is per-font, not per-CID. No focused unit test yet (verified visually on `style-various-1.pdf`); the reference-output snapshot test is the gate. +- **Link annotations** (`html/pdf_file.cpp`): a page's `/Link` annotations + (ISO 32000-1 12.5.6.5) become absolutely-positioned `` overlays. A `/URI` + action is an external link (attribute-escaped `href`, opened in `_blank` via + the document target); a `/GoTo` action or a direct `/Dest` is an internal + `#pN` link — each page `div` carries a matching `id="pN"`. Destinations + resolve a page *reference* (the first element of a `[page …]` array) to its + index, including named destinations via the catalog `/Dests` dictionary and + the `/Names /Dests` name tree (depth-guarded). The `/Rect` is mapped through + the page transform to page-box points. Resolved entirely in the HTML layer + from the parser's raw annotation dictionaries — no parser/IR change. Both + render modes emit them. Deferred: remote/launch actions (`/GoToR`, `/Launch`), + the destination's scroll position/zoom (only the target page is used), and + non-`/Link` annotation appearances (see *Interaction & navigation*). ## Graphics, images & transparency @@ -632,12 +645,24 @@ design before implementation. ## Interaction & navigation The next feature cluster — needs destinations from the page tree, little else. +Link annotations (`/URI` + internal `/GoTo`) already land — see *What works*. -- **Links**: URI actions and internal `GoTo` destinations (incl. named) as `` - overlays. - **Annotation appearances**: render `/AP` appearance streams (form XObjects again) for highlights, stamps, form-field appearances; AcroForm *interactivity* stays out of scope (read-only). +- **Remote/launch link actions** (`/GoToR`, `/Launch`) and destination scroll + position/zoom (the internal-link handler uses only the target page). +- **Link overlays vs. text selection**: the `` overlays sit above the `.sel` + text, so a mousedown over a link starts navigation instead of a selection — + links are clickable but text under them cannot be selected, and there is no + hover cursor issue since native `:hover` applies. A JS workaround existed + (overlays `pointer-events:none` so mousedown falls through to `.sel`; a + once-per-document click handler re-enabled hit-testing via an `.lkhit` class, + `elementFromPoint`, and forwarded the click; a rAF-throttled `mousemove` + restored the pointer cursor via an `.lkc` class) but was rolled back as too + involved (reverted commit `5cfa8a09`, reachable for later). Options to + revisit: (a) reinstate that JS approach; (b) a CSS-only route if one exists; + (c) leave overlays clickable-only and accept no under-link selection. - **Document outline** (`/Outlines`) → navigation anchors/sidebar. - **Optional content groups** (layers): honor default visibility; no toggle UI. - **Output scaling**: monolithic HTML vs. per-page lazy loading for large @@ -694,8 +719,9 @@ The next feature cluster — needs destinations from the page tree, little else. `/W2`/`/DW2` vertical metrics and a perpendicular pen advance, which the horizontal-only `extract_text` and space inference assume away). No corpus fixture needs either yet; revisit when one does. -- **Annotations** are collected but their content is not interpreted - (*Interaction & navigation*). +- **Annotations**: `/Link` annotations render as `` overlays (see *What + works*); other annotation types are collected but their appearance streams are + not interpreted (*Interaction & navigation*). - **XMP metadata** (`/Metadata` XML packet) is not parsed; `file_meta()`'s `document_meta` comes from the `/Info` dictionary only. XMP would supplement or override `/Info` for producers that write it. diff --git a/src/odr/internal/pdf/pdf_document_parser.cpp b/src/odr/internal/pdf/pdf_document_parser.cpp index 3550aa79..badddca1 100644 --- a/src/odr/internal/pdf/pdf_document_parser.cpp +++ b/src/odr/internal/pdf/pdf_document_parser.cpp @@ -1192,17 +1192,20 @@ Page *parse_page(State &state, const ObjectReference &reference, Pages *parent, page->resources = parse_resources(state, resources); // /Contents is a content stream or an array of them, supplied directly or - // through an indirect reference (7.7.3.3). Resolve a reference first so that - // a reference to an array is expanded into its stream references rather than - // mistaken for a single stream. - const Object &contents = dictionary["Contents"]; - const Object resolved_contents = parser.resolve_object_copy(contents); - if (resolved_contents.is_array()) { - for (const Object &e : resolved_contents.as_array()) { - page->contents_reference.push_back(e.as_reference()); + // through an indirect reference (7.7.3.3). It is optional — a page may have + // none (e.g. a blank page carrying only annotations). Resolve a reference + // first so that a reference to an array is expanded into its stream + // references rather than mistaken for a single stream. + if (dictionary.has_key("Contents")) { + const Object &contents = dictionary["Contents"]; + const Object resolved_contents = parser.resolve_object_copy(contents); + if (resolved_contents.is_array()) { + for (const Object &e : resolved_contents.as_array()) { + page->contents_reference.push_back(e.as_reference()); + } + } else if (contents.is_reference()) { + page->contents_reference = {contents.as_reference()}; } - } else if (contents.is_reference()) { - page->contents_reference = {contents.as_reference()}; } if (dictionary.has_key("Annots")) { diff --git a/test/data/reference-output/odr-private b/test/data/reference-output/odr-private index 6be5d357..10c55b6b 160000 --- a/test/data/reference-output/odr-private +++ b/test/data/reference-output/odr-private @@ -1 +1 @@ -Subproject commit 6be5d35729848e19f1c298cec06232c9b5159023 +Subproject commit 10c55b6b637ba681f37804d8b12432fa04cc1688 diff --git a/test/data/reference-output/odr-public b/test/data/reference-output/odr-public index 7d8dfe1e..1f261826 160000 --- a/test/data/reference-output/odr-public +++ b/test/data/reference-output/odr-public @@ -1 +1 @@ -Subproject commit 7d8dfe1ef79e768a59950f1767bfaab4665f2c27 +Subproject commit 1f2618261533da094670d02413ca3ec02618c2f4 diff --git a/test/src/internal/pdf/pdf_file.cpp b/test/src/internal/pdf/pdf_file.cpp index 0871556d..11b45fe9 100644 --- a/test/src/internal/pdf/pdf_file.cpp +++ b/test/src/internal/pdf/pdf_file.cpp @@ -1,11 +1,15 @@ #include +#include +#include #include +#include #include #include #include +#include #include #include @@ -20,6 +24,47 @@ std::shared_ptr open_pdf(const std::string &bytes) { return std::make_shared(std::make_shared(bytes)); } +std::string render_html(const std::string &bytes, const PdfTextMode mode) { + const odr::PdfFile file(open_pdf(bytes)); + HtmlConfig config; + config.pdf_text_mode = mode; + const std::shared_ptr logger = Logger::create_null(); + const HtmlService service = + internal::html::create_pdf_service(file, "", config, logger); + std::ostringstream out; + service.write("document.html", out); + return std::move(out).str(); +} + +bool contains(const std::string &haystack, const std::string &needle) { + return haystack.find(needle) != std::string::npos; +} + +/// A three-page mini-PDF whose first page carries four `/Link` annotations: a +/// `/URI` action, a direct `/Dest` array to page 2, a `/GoTo` action to a named +/// destination (`chap3` → page 3, via the catalog `/Dests`), and a `/URI` +/// action with a `javascript:` scheme (which must not become a live link). +std::string link_annotations_mini_pdf() { + PdfFileBuilder builder; + builder + .object("<< /Type /Catalog /Pages 2 0 R " + "/Dests << /chap3 [5 0 R /Fit] >> >>") + .object("<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 >>") + .object("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + "/Annots [6 0 R 7 0 R 8 0 R 9 0 R] >>") + .object("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>") + .object("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>") + .object("<< /Type /Annot /Subtype /Link /Rect [100 700 200 720] " + "/A << /S /URI /URI (http://example.com/?a=1&b=2) >> >>") + .object("<< /Type /Annot /Subtype /Link /Rect [100 600 200 620] " + "/Dest [4 0 R /Fit] >>") + .object("<< /Type /Annot /Subtype /Link /Rect [100 500 200 520] " + "/A << /S /GoTo /D (chap3) >> >>") + .object("<< /Type /Annot /Subtype /Link /Rect [100 400 200 420] " + "/A << /S /URI /URI (javascript:alert\\(1\\)) >> >>"); + return builder.trailer("/Root 1 0 R").build_classic(); +} + /// A two-page mini-PDF with an `/Info` dictionary. `/Title` is a UTF-16BE /// (BOM) hex string ("HI"), the rest literal (PDFDocEncoding) strings. std::string info_mini_pdf() { @@ -78,3 +123,29 @@ TEST(PdfFile, file_meta_without_info) { EXPECT_FALSE(meta.document_meta->title.has_value()); EXPECT_FALSE(meta.document_meta->author.has_value()); } + +// `/Link` annotations render as `` overlays: a `/URI` action → external href +// (with `&` attr-escaped), a direct `/Dest` and a named `/GoTo` → internal +// `#pN` anchors that carry `target="_self"` (overriding ``); each page div carries a matching `id`. An active-scheme +// `/URI` is dropped. +TEST(PdfFile, link_annotations_render_as_anchors) { + const std::string pdf = link_annotations_mini_pdf(); + for (const PdfTextMode mode : + {PdfTextMode::dual_layer, PdfTextMode::single_layer}) { + const std::string html = render_html(pdf, mode); + EXPECT_TRUE(contains(html, R"(id="p1")")) + << "mode " << static_cast(mode); + EXPECT_TRUE(contains(html, R"(id="p3")")) + << "mode " << static_cast(mode); + EXPECT_TRUE(contains(html, R"(href="http://example.com/?a=1&b=2")")) + << "mode " << static_cast(mode); + EXPECT_TRUE(contains(html, R"(href="#p2" target="_self")")) + << "mode " << static_cast(mode); + EXPECT_TRUE(contains(html, R"(href="#p3" target="_self")")) + << "mode " << static_cast(mode); + // The `javascript:` action is not emitted as a link. + EXPECT_FALSE(contains(html, "javascript:alert")) + << "mode " << static_cast(mode); + } +}