feat(reader): in-book search for PDFs

PDFs have fully searchable text (pdf.js text layer) — the previous
reflowable-only gate existed only because foliate's generic search
needs DOM documents that PDF sections don't provide. This adds a PDF
pipeline alongside it:

- Fork d065495 exposes the pdf.js document proxy as book.pdf so the
  host can drive text extraction directly.
- New web/src/reader/pdf-search.ts: extractPdfPages() pulls each
  page's textContent with item geometry (progress-reported, cached
  after first search). PDF text items often omit inter-word spaces
  (gaps are positional), so pages are joined gap-aware — baseline
  changes, hasEOL, or horizontal gaps past a font-size threshold
  become spaces — recording a char→item map. searchPdfPages() does
  case-insensitive matching over the joined text and maps each hit
  back to the page-fraction rects of the items it spans, with
  ellipsized pre/match/post excerpts. Pure functions, unit-sanity
  checked (cross-item 'brave new' → two rects).
- runSearch branches: EPUB keeps foliate's DOM search; PDFs search
  the extracted pages, group hits per page ('Page 12'), and render
  on-page hit rectangles through the existing fraction-rect overlay
  (addRectAnnotation) — which re-render automatically when pages
  revisit, same as highlights. Clearing the query removes them.
- Results navigate by page index; the 🔍 button and '/' shortcut now
  appear for PDFs too (comics remain without searchable text).
This commit is contained in:
2026-08-17 08:04:30 -04:00
parent 5e73b0a4f6
commit 6fc4107e3c
5 changed files with 258 additions and 30 deletions
+3 -3
View File
@@ -246,7 +246,7 @@ templ ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) {
<h1 class="text-base sm:text-lg font-semibold hidden sm:block sm:truncate">{ metadata.Title }</h1>
<div class="flex items-center gap-1">
<button @click="addBookmark()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Bookmark this position (b)">🏷️</button>
<button x-show="!isFixedLayout" @click="toggleSearch()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Search (/)">🔍</button>
<button x-show="!isFixedLayout || isPDF" @click="toggleSearch()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Search (/)">🔍</button>
<button @click="toggleBookmarks()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Annotations">📝</button>
<button @click="toggleSettings()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Settings (s)">Aa</button>
</div>
@@ -541,10 +541,10 @@ templ ReaderSearchDrawer() {
<div class="mb-3">
<div class="text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary)" x-text="group.label"></div>
<div class="space-y-1">
<template x-for="item in group.items" :key="item.cfi">
<template x-for="(item, si) in group.items" :key="gi + '-' + si">
<a
href="#"
@click.prevent="goToSearchResult(item.cfi)"
@click.prevent="goToSearchResult(item)"
class="search-result"
>
<span x-text="item.pre"></span><mark x-text="item.match"></mark><span x-text="item.post"></span>
+2 -2
View File
@@ -220,7 +220,7 @@ func ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) templ.Compo
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</h1><div class=\"flex items-center gap-1\"><button @click=\"addBookmark()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Bookmark this position (b)\">🏷️</button> <button x-show=\"!isFixedLayout\" @click=\"toggleSearch()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Search (/)\">🔍</button> <button @click=\"toggleBookmarks()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Annotations\">📝</button> <button @click=\"toggleSettings()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Settings (s)\">Aa</button></div></div></div><!-- Bottom bar --><div id=\"reader-bottombar\" class=\"fixed bottom-0 left-0 right-0 border-t z-40 pb-[env(safe-area-inset-bottom)] reader-glass\"><!-- Reflowable row --><div x-show=\"!isFixedLayout\" class=\"flex items-center px-1.5 py-1.5 gap-0.5 sm:px-2 sm:py-2 sm:gap-1\"><button @click=\"goLeft()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Left\" aria-label=\"Go left\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 15 6 L 9 12 L 15 18\"></path></svg></button> <input id=\"progress-slider\" type=\"range\" min=\"0\" max=\"1\" step=\"any\" list=\"tick-marks\" @input=\"goToFraction($event.target.value)\" class=\"grow\"> <datalist id=\"tick-marks\"></datalist> <button @click=\"goRight()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Right\" aria-label=\"Go right\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 9 6 L 15 12 L 9 18\"></path></svg></button><div class=\"w-px h-6 mx-1 reader-sep\"></div><div id=\"progress-display\" @click=\"cycleProgressMode()\" :title=\"progressTooltip()\" class=\"text-sm min-w-[4rem] max-w-[5rem] sm:max-w-none text-center cursor-pointer truncate whitespace-nowrap overflow-hidden\"><span class=\"hidden sm:inline\" x-text=\"progressLabel\"></span><span x-text=\"progressMain\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</h1><div class=\"flex items-center gap-1\"><button @click=\"addBookmark()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Bookmark this position (b)\">🏷️</button> <button x-show=\"!isFixedLayout || isPDF\" @click=\"toggleSearch()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Search (/)\">🔍</button> <button @click=\"toggleBookmarks()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Annotations\">📝</button> <button @click=\"toggleSettings()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Settings (s)\">Aa</button></div></div></div><!-- Bottom bar --><div id=\"reader-bottombar\" class=\"fixed bottom-0 left-0 right-0 border-t z-40 pb-[env(safe-area-inset-bottom)] reader-glass\"><!-- Reflowable row --><div x-show=\"!isFixedLayout\" class=\"flex items-center px-1.5 py-1.5 gap-0.5 sm:px-2 sm:py-2 sm:gap-1\"><button @click=\"goLeft()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Left\" aria-label=\"Go left\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 15 6 L 9 12 L 15 18\"></path></svg></button> <input id=\"progress-slider\" type=\"range\" min=\"0\" max=\"1\" step=\"any\" list=\"tick-marks\" @input=\"goToFraction($event.target.value)\" class=\"grow\"> <datalist id=\"tick-marks\"></datalist> <button @click=\"goRight()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Right\" aria-label=\"Go right\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 9 6 L 15 12 L 9 18\"></path></svg></button><div class=\"w-px h-6 mx-1 reader-sep\"></div><div id=\"progress-display\" @click=\"cycleProgressMode()\" :title=\"progressTooltip()\" class=\"text-sm min-w-[4rem] max-w-[5rem] sm:max-w-none text-center cursor-pointer truncate whitespace-nowrap overflow-hidden\"><span class=\"hidden sm:inline\" x-text=\"progressLabel\"></span><span x-text=\"progressMain\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -423,7 +423,7 @@ func ReaderSearchDrawer() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"reader-search-bar\"><input type=\"search\" x-ref=\"searchInput\" x-model=\"searchQuery\" @keydown.enter.prevent=\"runSearch()\" placeholder=\"Search in book…\" class=\"reader-note-input grow\" aria-label=\"Search query\"> <button x-show=\"searchQuery || searchGroups.length\" @click=\"searchQuery = ''; clearSearchResults()\" class=\"p-2 rounded-lg hover:bg-gray-700 shrink-0\" title=\"Clear results\" aria-label=\"Clear results\">✕</button></div><div class=\"reader-search-status\" x-show=\"searchQuery || searching || searchGroups.length\"><span x-show=\"searching\" x-text=\"'Searching… ' + Math.round(searchProgress * 100) + '%'\">Searching…</span> <span x-show=\"!searching && searchGroups.length\" x-text=\"searchMatchCount + ' match' + (searchMatchCount === 1 ? '' : 'es')\">0 matches</span> <span x-show=\"!searching && searchQuery && !searchGroups.length\">No matches</span></div><div class=\"reader-drawer-body\"><template x-for=\"(group, gi) in searchGroups\" :key=\"gi\"><div class=\"mb-3\"><div class=\"text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary)\" x-text=\"group.label\"></div><div class=\"space-y-1\"><template x-for=\"item in group.items\" :key=\"item.cfi\"><a href=\"#\" @click.prevent=\"goToSearchResult(item.cfi)\" class=\"search-result\"><span x-text=\"item.pre\"></span><mark x-text=\"item.match\"></mark><span x-text=\"item.post\"></span></a></template></div></div></template></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"reader-search-bar\"><input type=\"search\" x-ref=\"searchInput\" x-model=\"searchQuery\" @keydown.enter.prevent=\"runSearch()\" placeholder=\"Search in book…\" class=\"reader-note-input grow\" aria-label=\"Search query\"> <button x-show=\"searchQuery || searchGroups.length\" @click=\"searchQuery = ''; clearSearchResults()\" class=\"p-2 rounded-lg hover:bg-gray-700 shrink-0\" title=\"Clear results\" aria-label=\"Clear results\">✕</button></div><div class=\"reader-search-status\" x-show=\"searchQuery || searching || searchGroups.length\"><span x-show=\"searching\" x-text=\"'Searching… ' + Math.round(searchProgress * 100) + '%'\">Searching…</span> <span x-show=\"!searching && searchGroups.length\" x-text=\"searchMatchCount + ' match' + (searchMatchCount === 1 ? '' : 'es')\">0 matches</span> <span x-show=\"!searching && searchQuery && !searchGroups.length\">No matches</span></div><div class=\"reader-drawer-body\"><template x-for=\"(group, gi) in searchGroups\" :key=\"gi\"><div class=\"mb-3\"><div class=\"text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary)\" x-text=\"group.label\"></div><div class=\"space-y-1\"><template x-for=\"(item, si) in group.items\" :key=\"gi + '-' + si\"><a href=\"#\" @click.prevent=\"goToSearchResult(item)\" class=\"search-result\"><span x-text=\"item.pre\"></span><mark x-text=\"item.match\"></mark><span x-text=\"item.post\"></span></a></template></div></div></template></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}