From 0f749d8bf4a63496a34b99e2cddc102069b7d82a Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 16 Aug 2026 08:49:55 +0200 Subject: [PATCH 1/2] feat: search every view that renders text, not only a document `odr.search()` and its siblings shipped with the document script, so a pdf, a plain text file, an xml source view and an archive listing had no search at all. The search code moves into its own `search.js` (plus the `mark` rules as `search.css`) that every one of those views writes. - a pdf's glyph layer is `aria-hidden` and rejected by the walker, so a hit lands in the text layer only; `color:inherit` keeps that layer invisible under the highlight and `mix-blend-mode` keeps the glyphs readable through it - the text view's line numbers are `aria-hidden` for the same reason, and its editor reads a position through a `` instead of past it - a hit inside a folded xml section opens it Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0192ViykqXESAC9VM8Pv2R3u --- CHANGELOG.md | 10 + src/odr/internal/html/document.cpp | 2 + src/odr/internal/html/filesystem.cpp | 3 + src/odr/internal/html/frontend.cpp | 258 +++++++++++++++++++----- src/odr/internal/html/frontend.hpp | 9 +- src/odr/internal/html/pdf_file.cpp | 48 ++++- src/odr/internal/html/text_file.cpp | 9 +- src/odr/internal/html/xml_file.cpp | 3 + src/odr/internal/xml/AGENTS.md | 12 +- test/src/html_test.cpp | 12 +- test/src/internal/xml/xml_file_test.cpp | 1 - 11 files changed, 298 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5ee823f..937dc64c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,16 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- `odr.search()`, `searchNext()`, `searchPrevious()` and `resetSearch()` now + come with every view that renders text — a pdf, a text file, an xml view, an + archive listing — as the shipped `search.css` and `search.js`, which a host + that links rather than embeds has to serve. +- A search hit in a pdf tints its glyphs instead of covering them; a pdf's + glyph layer and the text view's line numbers are not searched. +- A keyword is found across the spans it happens to be written in — a pdf puts + a word in each — and a space matches whichever kind the page carries. It + still does not run across a line, a cell or a paragraph. + ## v6.6.0 - 2026-08-14 - A paragraph states its own font, so a run that names none of its own is read diff --git a/src/odr/internal/html/document.cpp b/src/odr/internal/html/document.cpp index 062d4764..06d0d5d7 100644 --- a/src/odr/internal/html/document.cpp +++ b/src/odr/internal/html/document.cpp @@ -54,6 +54,7 @@ void front(const Document &document, const WritingState &state, : std::nullopt); write_document_style(state); + write_search_style(state); if (document.document_type() == DocumentType::spreadsheet) { write_spreadsheet_style(state); } @@ -93,6 +94,7 @@ void back(const Document &document, const WritingState &state) { out.write_element_end("div"); } + write_search_script(state); write_document_script(state); if (document.document_type() == DocumentType::spreadsheet) { write_spreadsheet_script(state); diff --git a/src/odr/internal/html/filesystem.cpp b/src/odr/internal/html/filesystem.cpp index 48bc5f46..5cfe1237 100644 --- a/src/odr/internal/html/filesystem.cpp +++ b/src/odr/internal/html/filesystem.cpp @@ -189,6 +189,7 @@ class HtmlServiceImpl final : public HtmlService { out.write_header_title("odr"); write_viewport_meta(out, config(), false); write_filesystem_style(state); + write_search_style(state); out.write_header_end(); out.write_body_begin(); @@ -263,6 +264,8 @@ class HtmlServiceImpl final : public HtmlService { out.write_element_end("tbody"); out.write_element_end("table"); + write_search_script(state); + out.write_body_end(); out.write_end(); diff --git a/src/odr/internal/html/frontend.cpp b/src/odr/internal/html/frontend.cpp index 15036ceb..86ab3a8a 100644 --- a/src/odr/internal/html/frontend.cpp +++ b/src/odr/internal/html/frontend.cpp @@ -32,8 +32,6 @@ x-s{display:inline} instead of going edge to edge. */ .odr-pages{display:flex;flex-direction:column;align-items:center;gap:16px;padding:16px 0;width:max-content;min-width:100%} .odr-page-outer{display:flex;margin:0 16px;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.5);z-index:-1000} -mark{background:#ff0} -mark.current{background:orange} /* The label is text rather than a `::marker`, which no selection would copy. It hangs into the item's padding so wrapped lines align under the text. */ .odr-list-item{padding-left:2em} @@ -178,6 +176,12 @@ body{margin:0;background:#000} .odr-media audio{width:100%;max-width:40rem;margin:0 1rem} )css"; +/// What the search script paints. +constexpr std::string_view search_css = R"css( +mark{background:#ff0} +mark.current{background:orange} +)css"; + constexpr std::string_view document_js = R"js( (function () { "use strict"; @@ -230,6 +234,15 @@ constexpr std::string_view document_js = R"js( odr.onError(errorIllegalEditNewLine.code, errorIllegalEditNewLine.message); } }); +})(); +)js"; + +/// Text search over the rendered page, format-agnostic: it walks text nodes. +constexpr std::string_view search_js = R"js( +(function () { + "use strict"; + + var odr = (window.odr = window.odr || {}); var marks = []; var current = -1; @@ -238,7 +251,9 @@ constexpr std::string_view document_js = R"js( // Case- and diacritic-folded `text` plus a folded-index to source-index map // (with an end sentinel), so a match maps back onto the source string. // Folding per character is what keeps that map right when a character folds - // to none or to several. + // to none or to several. Every space folds to one: a run's leading and + // trailing space is written as ` ` and a tab as ` `, and a keyword + // is typed with neither. function fold(text) { var folded = ""; var map = []; @@ -246,6 +261,7 @@ constexpr std::string_view document_js = R"js( var character = text[i] .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") + .replace(/[\u00a0\u2000-\u200a\u202f\u205f\u3000]/g, " ") .toLowerCase(); for (var j = 0; j < character.length; ++j) { map.push(i); @@ -256,18 +272,51 @@ constexpr std::string_view document_js = R"js( return { text: folded, map: map }; } + var inlineElements = new WeakMap(); + + function isInline(element) { + var inline = inlineElements.get(element); + if (inline === undefined) { + var display = getComputedStyle(element).display; + inline = display.indexOf("inline") === 0 || display === "contents"; + inlineElements.set(element, inline); + } + return inline; + } + + // The box a text node flows in. Text under one reads as a run; text under two + // does not, and a keyword must not match across the break between them. + function blockOf(node) { + var element = node.parentElement; + while (element !== null && isInline(element)) { + element = element.parentElement; + } + return element; + } + + // Rejected by the subtree, `aria-hidden` included: that is what keeps a pdf's + // glyph layer out of a search of the same page's text layer. function textNodes() { - var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, { - acceptNode: function (node) { - var name = node.parentNode ? node.parentNode.nodeName : ""; - if (name === "SCRIPT" || name === "STYLE" || name === "MARK") { - return NodeFilter.FILTER_REJECT; - } - return node.nodeValue.length > 0 - ? NodeFilter.FILTER_ACCEPT - : NodeFilter.FILTER_REJECT; - }, - }); + var walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, + { + acceptNode: function (node) { + if (node.nodeType !== Node.TEXT_NODE) { + var name = node.nodeName; + return name === "SCRIPT" || + name === "STYLE" || + name === "MARK" || + node.getAttribute("aria-hidden") === "true" + ? NodeFilter.FILTER_REJECT + : NodeFilter.FILTER_SKIP; + } + return node.nodeValue.length > 0 + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + }, + } + ); var nodes = []; while (walker.nextNode()) { nodes.push(walker.currentNode); @@ -275,31 +324,95 @@ constexpr std::string_view document_js = R"js( return nodes; } - function markNode(node, needle) { - var found = []; - while (true) { + // One folded string per block, with the piece of it each text node + // contributed. A pdf writes a word per span and a slide a run per span, so a + // keyword spanning several of them is the ordinary case, not the exception. + function blocks() { + var nodes = textNodes(); + var result = []; + var block = null; + var open = null; + for (var i = 0; i < nodes.length; ++i) { + var node = nodes[i]; + var owner = blockOf(node); + if (open === null || owner !== block) { + open = { text: "", pieces: [] }; + block = owner; + result.push(open); + } var folded = fold(node.nodeValue); - var at = folded.text.indexOf(needle); - if (at === -1) { - return found; + open.pieces.push({ + node: node, + begin: open.text.length, + end: open.text.length + folded.text.length, + map: folded.map, + }); + open.text += folded.text; + } + return result; + } + + // Wraps `[from, to)` of a text node. Applied back to front within a node, so + // the split never moves an offset still to be used. + function wrap(node, from, to) { + var match = node.splitText(from); + match.splitText(to - from); + var mark = document.createElement("mark"); + mark.className = "highlight"; + match.parentNode.replaceChild(mark, match); + mark.appendChild(match); + return mark; + } + + // Every occurrence in `block`, each as the slices of the text nodes it + // covers. Collected before anything is wrapped, because wrapping splits the + // nodes the offsets are measured in. + function findIn(block, needle) { + var found = []; + var at = block.text.indexOf(needle); + while (at !== -1) { + var end = at + needle.length; + var slices = []; + for (var i = 0; i < block.pieces.length; ++i) { + var piece = block.pieces[i]; + if (piece.end <= at || piece.begin >= end) { + continue; + } + slices.push({ + node: piece.node, + from: piece.map[Math.max(at, piece.begin) - piece.begin], + to: piece.map[Math.min(end, piece.end) - piece.begin], + }); + } + if (slices.length > 0) { + found.push(slices); } - var match = node.splitText(folded.map[at]); - node = match.splitText(folded.map[at + needle.length] - folded.map[at]); - var mark = document.createElement("mark"); - mark.className = "highlight"; - match.parentNode.replaceChild(mark, match); - mark.appendChild(match); - found.push(mark); + at = block.text.indexOf(needle, end); } + return found; } function select(index) { if (current >= 0 && marks[current]) { - marks[current].classList.remove("current"); + marks[current].forEach(function (mark) { + mark.classList.remove("current"); + }); } current = index; - marks[current].classList.add("current"); - marks[current].scrollIntoView({ block: "center", inline: "center" }); + marks[current].forEach(function (mark) { + mark.classList.add("current"); + }); + // A hit inside a folded section is scrolled to but not shown. + for ( + var element = marks[current][0].parentElement; + element !== null; + element = element.parentElement + ) { + if (element.nodeName === "DETAILS") { + element.open = true; + } + } + marks[current][0].scrollIntoView({ block: "center", inline: "center" }); } function step(delta, next) { @@ -314,33 +427,47 @@ constexpr std::string_view document_js = R"js( } odr.resetSearch = function () { - for (var i = 0; i < marks.length; ++i) { - var parent = marks[i].parentNode; - if (!parent) { - continue; - } - while (marks[i].firstChild) { - parent.insertBefore(marks[i].firstChild, marks[i]); - } - parent.removeChild(marks[i]); - parent.normalize(); - } + marks.forEach(function (hit) { + hit.forEach(function (mark) { + var parent = mark.parentNode; + if (!parent) { + return; + } + while (mark.firstChild) { + parent.insertBefore(mark.firstChild, mark); + } + parent.removeChild(mark); + parent.normalize(); + }); + }); marks = []; current = -1; keyword = ""; }; - // Highlights every occurrence, selects the first and returns the count. + // Highlights every occurrence, selects the first and returns the count. An + // occurrence is one hit however many nodes it is written across. odr.search = function (text) { odr.resetSearch(); keyword = fold(text === undefined || text === null ? "" : String(text)).text; if (keyword === "") { return 0; } - var nodes = textNodes(); - for (var i = 0; i < nodes.length; ++i) { - marks = marks.concat(markNode(nodes[i], keyword)); - } + blocks().forEach(function (block) { + var hits = findIn(block, keyword); + // Wrapped back to front: an offset still to be used sits before the split + // that would move it. The marks are collected back into reading order. + var wrapped = []; + for (var i = hits.length - 1; i >= 0; --i) { + var hit = []; + for (var j = hits[i].length - 1; j >= 0; --j) { + var slice = hits[i][j]; + hit.unshift(wrap(slice.node, slice.from, slice.to)); + } + wrapped.unshift(hit); + } + marks = marks.concat(wrapped); + }); if (marks.length > 0) { select(0); } @@ -681,12 +808,22 @@ constexpr std::string_view text_js = R"js( // Lines are the element children: formatted output puts a whitespace text // node between them, and counting or indexing those as lines is off by as - // much as a factor of two. + // much as a factor of two. The line is the ancestor the body owns and the + // offset is measured from its start: a search `` may sit in between. TextEditor.prototype.getPosition = function (container, offset) { - var line = container.nodeName === "DIV" ? container : container.parentNode; + var line = container; + while (line !== null && line.parentNode !== this.textBody) { + line = line.parentNode; + } + if (line === null) { + return { line: -1, offset: offset }; + } + var range = document.createRange(); + range.selectNodeContents(line); + range.setEnd(container, offset); return { line: Array.prototype.indexOf.call(this.textBody.children, line), - offset: offset, + offset: range.toString().length, }; }; @@ -963,8 +1100,12 @@ constexpr Asset filesystem_css_asset{HtmlResourceType::css, "text/css", "filesystem.css", filesystem_css}; constexpr Asset media_css_asset{HtmlResourceType::css, "text/css", "media.css", media_css}; +constexpr Asset search_css_asset{HtmlResourceType::css, "text/css", + "search.css", search_css}; constexpr Asset document_js_asset{HtmlResourceType::js, "text/javascript", "document.js", document_js}; +constexpr Asset search_js_asset{HtmlResourceType::js, "text/javascript", + "search.js", search_js}; constexpr Asset spreadsheet_js_asset{HtmlResourceType::js, "text/javascript", "spreadsheet.js", spreadsheet_js}; constexpr Asset text_js_asset{HtmlResourceType::js, "text/javascript", @@ -1047,10 +1188,18 @@ void html::write_media_style(const WritingState &state) { write_style(media_css_asset, state); } +void html::write_search_style(const WritingState &state) { + write_style(search_css_asset, state); +} + void html::write_document_script(const WritingState &state) { write_script(document_js_asset, state); } +void html::write_search_script(const WritingState &state) { + write_script(search_js_asset, state); +} + void html::write_spreadsheet_script(const WritingState &state) { write_script(spreadsheet_js_asset, state); } @@ -1060,12 +1209,19 @@ void html::write_text_script(const WritingState &state) { } HtmlResources html::locate_text_resources(const HtmlConfig &config) { - static constexpr std::array assets{text_css_asset, text_js_asset}; + static constexpr std::array assets{text_css_asset, search_css_asset, + search_js_asset, text_js_asset}; return locate_all(assets, config); } HtmlResources html::locate_xml_resources(const HtmlConfig &config) { - static constexpr std::array assets{xml_css_asset}; + static constexpr std::array assets{xml_css_asset, search_css_asset, + search_js_asset}; + return locate_all(assets, config); +} + +HtmlResources html::locate_search_resources(const HtmlConfig &config) { + static constexpr std::array assets{search_css_asset, search_js_asset}; return locate_all(assets, config); } diff --git a/src/odr/internal/html/frontend.hpp b/src/odr/internal/html/frontend.hpp index 4b433bb0..5d2c8ba9 100644 --- a/src/odr/internal/html/frontend.hpp +++ b/src/odr/internal/html/frontend.hpp @@ -16,13 +16,17 @@ void write_text_style(const WritingState &state); void write_xml_style(const WritingState &state); void write_filesystem_style(const WritingState &state); void write_media_style(const WritingState &state); +/// Written by every view that writes the search script. +void write_search_style(const WritingState &state); -/// The `odr` object a document view exposes to its host: `generateDiff()`, -/// `search()`, `searchNext()`, `searchPrevious()`, `resetSearch()`. +/// The `odr` object a document view exposes to its host: `generateDiff()`. void write_document_script(const WritingState &state); /// Written in addition to the document script. void write_spreadsheet_script(const WritingState &state); void write_text_script(const WritingState &state); +/// `odr.search()`, `searchNext()`, `searchPrevious()`, `resetSearch()` — the +/// rest of that object, for every view rendering text, whatever the format. +void write_search_script(const WritingState &state); /// What the corresponding `write_*` calls would link, without writing anything: /// a service has to answer for these paths as well as for its views. Every @@ -30,5 +34,6 @@ void write_text_script(const WritingState &state); HtmlResources locate_text_resources(const HtmlConfig &config); HtmlResources locate_xml_resources(const HtmlConfig &config); HtmlResources locate_media_resources(const HtmlConfig &config); +HtmlResources locate_search_resources(const HtmlConfig &config); } // namespace odr::internal::html diff --git a/src/odr/internal/html/pdf_file.cpp b/src/odr/internal/html/pdf_file.cpp index 1e91e109..93fd729f 100644 --- a/src/odr/internal/html/pdf_file.cpp +++ b/src/odr/internal/html/pdf_file.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -1072,8 +1073,8 @@ class AtomicStyles { class HtmlServiceImpl final : public HtmlService { public: HtmlServiceImpl(PdfFile pdf_file, HtmlConfig config, const Logger &logger) - : HtmlService(std::move(config), logger), - m_pdf_file{std::move(pdf_file)} {} + : HtmlService(std::move(config), logger), m_pdf_file{std::move(pdf_file)}, + m_resources{locate_search_resources(this->config())} {} /// Parses once, applies the `[page_range_begin, page_range_end)` range and /// builds the views: the combined document plus one per rendered page. The @@ -1121,20 +1122,33 @@ class HtmlServiceImpl final : public HtmlService { return m_views; } - [[nodiscard]] bool exists(const std::string &path) const override { + [[nodiscard]] bool is_view(const std::string &path) const { warmup(); return std::ranges::any_of( m_views, [&path](const auto &view) { return view.path() == path; }); } + [[nodiscard]] bool exists(const std::string &path) const override { + return is_view(path) || resource_at(m_resources, path) != nullptr; + } + [[nodiscard]] std::string mimetype(const std::string &path) const override { - if (exists(path)) { + if (is_view(path)) { return "text/html"; } + if (const odr::HtmlResource *resource = resource_at(m_resources, path); + resource != nullptr) { + return resource->mime_type(); + } throw FileNotFound("Unknown path: " + path); } void write(const std::string &path, std::ostream &out) const override { + if (const odr::HtmlResource *resource = resource_at(m_resources, path); + !is_view(path) && resource != nullptr) { + resource->write_resource(out); + return; + } HtmlWriter writer(out, config()); write_html(path, writer); } @@ -1256,6 +1270,7 @@ class HtmlServiceImpl final : public HtmlService { const std::size_t first_page_number, const PageHref &page_href) const { HtmlResources resources; + const WritingState state(out, config(), resources); pdf::DocumentParser &parser = *m_parser; LinkResolver &link_resolver = *m_link_resolver; @@ -1560,7 +1575,7 @@ class HtmlServiceImpl final : public HtmlService { } substitute_faces.append_faces(font_faces); - write_header_common(out, font_faces, font_styles, styles, [&] { + write_header_common(state, font_faces, font_styles, styles, [&] { // Visual layer glyph spans: not selectable (selection rides the `.sel` // layer). out.out() << ".g{user-select:none}"; @@ -1667,6 +1682,7 @@ class HtmlServiceImpl final : public HtmlService { out.write_element_end("div"); // .p } out.write_element_end("div"); // .d + write_search_script(state); out.write_body_end(); out.write_end(); @@ -1722,6 +1738,7 @@ class HtmlServiceImpl final : public HtmlService { HtmlWriter &out, const std::span pages, const std::size_t first_page_number, const PageHref &page_href) const { HtmlResources resources; + const WritingState state(out, config(), resources); pdf::DocumentParser &parser = *m_parser; LinkResolver &link_resolver = *m_link_resolver; @@ -2034,7 +2051,7 @@ class HtmlServiceImpl final : public HtmlService { substitute_faces.append_faces(font_faces); // ---- Pass 2: write HTML --------------------------------------------- - write_header_common(out, font_faces, font_styles, styles, [&] { + write_header_common(state, font_faces, font_styles, styles, [&] { // Invisible text render modes (Tr 3/7). out.out() << ".i{color:transparent}"; // Unclean glyphs via generated content, out of the DOM text stream. @@ -2051,6 +2068,12 @@ class HtmlServiceImpl final : public HtmlService { // height, while clipping nothing (the space is transparent). out.out() << ".sp{display:inline-block;" "color:transparent;vertical-align:baseline}"; + // A hit in the overlay is clipped away with it, so the glyphs it belongs + // to carry the highlight instead - the whole run of them, which is as + // narrow as the overlay can say. + out.out() << ".gl:has(+.ov mark){background:#ff0;" + "mix-blend-mode:multiply}"; + out.out() << ".gl:has(+.ov mark.current){background:orange}"; }); // A run's span class: `head` plus its optional margin-left and colour. @@ -2138,6 +2161,7 @@ class HtmlServiceImpl final : public HtmlService { out.write_element_end("div"); // .p } out.write_element_end("div"); // .d + write_search_script(state); out.write_body_end(); out.write_end(); @@ -2345,10 +2369,13 @@ class HtmlServiceImpl final : public HtmlService { /// The document/head prologue shared by both modes, with `write_mode_css()` /// slotted between the constant rules. Leaves the writer after ``. template - void write_header_common(HtmlWriter &out, const std::string &font_faces, + void write_header_common(const WritingState &state, + const std::string &font_faces, const std::string &font_styles, const AtomicStyles &styles, WriteModeCss &&write_mode_css) const { + HtmlWriter &out = state.out(); + out.write_begin(); out.write_header_begin(); out.write_header_charset("UTF-8"); @@ -2378,10 +2405,14 @@ class HtmlServiceImpl final : public HtmlService { "overflow:hidden;pointer-events:none}"; // Link annotation overlays (absolutely positioned in page-box points). out.out() << ".lk{position:absolute;transform-origin:0 0}"; + // A search hit marks the text layer over the page: the browser's own `mark` + // colour would paint invisible text, an opaque highlight hide the glyphs. + out.out() << "mark{color:inherit;mix-blend-mode:multiply}"; out.out() << font_faces; out.out() << font_styles; styles.write_rules(out.out()); out.write_header_style_end(); + write_search_style(state); out.write_header_end(); } @@ -2569,6 +2600,9 @@ class HtmlServiceImpl final : public HtmlService { protected: PdfFile m_pdf_file; + /// The search css and js every view links; empty of locations when the config + /// embeds them. + HtmlResources m_resources; // Lazily initialized by `warmup()` (all guarded by `m_mutex`): one parse // shared by the combined-document and per-page renders. diff --git a/src/odr/internal/html/text_file.cpp b/src/odr/internal/html/text_file.cpp index 0e959868..bf778d30 100644 --- a/src/odr/internal/html/text_file.cpp +++ b/src/odr/internal/html/text_file.cpp @@ -101,6 +101,7 @@ class HtmlServiceImpl final : public HtmlService { write_viewport_meta(out, config(), false); write_text_style(state); + write_search_style(state); out.write_header_end(); @@ -108,8 +109,11 @@ class HtmlServiceImpl final : public HtmlService { out.write_element_begin("div", HtmlElementOptions().set_class("odr-text")); - out.write_element_begin("div", - HtmlElementOptions().set_class("odr-text-nr")); + // `aria-hidden`: the numbers are ours, not the file's - nothing reading the + // page as content takes them. + out.write_element_begin("div", HtmlElementOptions() + .set_class("odr-text-nr") + .set_extra(R"(aria-hidden="true")")); std::istringstream in(text); for (std::uint32_t line = 1; !in.eof(); ++line) { out.write_element_begin("div", HtmlElementOptions().set_inline(true)); @@ -148,6 +152,7 @@ class HtmlServiceImpl final : public HtmlService { out.write_element_end("div"); + write_search_script(state); write_text_script(state); out.write_body_end(); diff --git a/src/odr/internal/html/xml_file.cpp b/src/odr/internal/html/xml_file.cpp index cb7daf1f..22509986 100644 --- a/src/odr/internal/html/xml_file.cpp +++ b/src/odr/internal/html/xml_file.cpp @@ -261,6 +261,7 @@ class HtmlServiceImpl final : public HtmlService { write_viewport_meta(out, config(), false); write_xml_style(state); + write_search_style(state); out.write_header_end(); @@ -272,6 +273,8 @@ class HtmlServiceImpl final : public HtmlService { } out.write_element_end("div"); + write_search_script(state); + out.write_body_end(); out.write_end(); diff --git a/src/odr/internal/xml/AGENTS.md b/src/odr/internal/xml/AGENTS.md index 71215fe9..bb52f015 100644 --- a/src/odr/internal/xml/AGENTS.md +++ b/src/odr/internal/xml/AGENTS.md @@ -86,11 +86,13 @@ two differ only for ` `, and the looser rule leaves it alone. - **Highlighting is server-side spans**, one per token, emitted as the writer walks the tree. A JavaScript highlighter would undo the self-contained output for a job the writer already does. -- **Folding is `
`/``, with no script** — keyboard access, - screen-reader semantics, and find-in-page that natively expands a collapsed - section. Start tag in the ``, children then end tag in the body, so - collapsing hides the whole node. Everything is open by default. Bulk - expand-all/collapse-all would need JavaScript, and there is none. +- **Folding is `
`/``, and nothing drives it** — keyboard + access, screen-reader semantics, and find-in-page that natively expands a + collapsed section. Start tag in the ``, children then end tag in the + body, so collapsing hides the whole node. Everything is open by default. Bulk + expand-all/collapse-all would need JavaScript of its own, and there is none. + The view's only script is the shared `search.js`, which opens the section a + hit is in. - **No line numbers**; the column carries the fold handles, and every line reserves it so folding does not shift siblings. - **Indentation is spaces, not padding**, so a copy of the page carries it. diff --git a/test/src/html_test.cpp b/test/src/html_test.cpp index fdffa716..89331af1 100644 --- a/test/src/html_test.cpp +++ b/test/src/html_test.cpp @@ -56,6 +56,9 @@ TEST(html, linked_resources_are_served) { check(DecodedFile(TestData::test_file_path("odr-public/txt/lorem ipsum.txt"), logger), "text.html"); + check( + DecodedFile(TestData::test_file_path("odr-public/pdf/empty.pdf"), logger), + "document.html"); } // The one archive the reference-output suite renders has no directory in it. @@ -78,14 +81,21 @@ TEST(html, archive_entry_yields_to_a_shipped_resource) { const HtmlResources resources = html::translate(file, config, logger).list_views().at(0).write_html(out); + // The locator puts every shipped resource on that one location, so what the + // count says is that the entry of that name is not among them. + std::size_t shipped = 0; std::size_t claimants = 0; for (const auto &[resource, location] : resources) { + if (resource.is_shipped()) { + ++shipped; + } if (location.has_value() && *location == "content.xml") { ++claimants; EXPECT_TRUE(resource.is_shipped()); } } - EXPECT_EQ(claimants, 1); + EXPECT_GT(shipped, 0); + EXPECT_EQ(claimants, shipped); } TEST(html, archive_listing) { diff --git a/test/src/internal/xml/xml_file_test.cpp b/test/src/internal/xml/xml_file_test.cpp index eb7e6330..8208836b 100644 --- a/test/src/internal/xml/xml_file_test.cpp +++ b/test/src/internal/xml/xml_file_test.cpp @@ -174,7 +174,6 @@ TEST(XmlHtml, an_element_with_element_children_folds) { EXPECT_THAT(html, HasSubstr("")); // the end tag is inside the fold, so collapsing hides the whole node EXPECT_THAT(html, HasSubstr(R"(</a)")); - EXPECT_THAT(html, Not(HasSubstr(" Date: Sun, 16 Aug 2026 09:41:33 +0200 Subject: [PATCH 2/2] chore(test): advance the reference-output pins Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0192ViykqXESAC9VM8Pv2R3u --- test/data.cmake | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/data.cmake b/test/data.cmake index feb0c849..b44af101 100644 --- a/test/data.cmake +++ b/test/data.cmake @@ -17,9 +17,9 @@ odr_test_data( odr_test_data( PATH "reference-output/odr-public" URL "https://github.com/opendocument-app/OpenDocument.test.output.git" - REVISION "7e3b094d53590374a19aa9cd9219c418740b72b5") + REVISION "b216dbe317768333081995d46382b0f01f5bcad8") odr_test_data( PATH "reference-output/odr-private" URL "https://github.com/opendocument-app/OpenDocument.test-private.output.git" - REVISION "3cd78a400b871854e1affb7b9209704b49a9c1d4") + REVISION "4c5030479e6412c3c8829fef96a7d00826b851cf")