Compare commits

...
18 Commits
Author SHA1 Message Date
John O'Keefe a36cee4f85 chore: ignore local export directory
Release / build-and-push (push) Successful in 2m54s
The export/ directory holds database extracts (annotations, reading
positions, book metadata) pulled from production for debugging. The
contents are personal data and must never be committed.
2026-10-12 20:47:05 -04:00
John O'Keefe 14947608be chore(scripts): add synthetic torture EPUB generator
scripts/build_torture_epub.py deterministically builds a small EPUB
exercising every CRE-xpointer to CFI conversion hazard observed in the
wild, one chapter per hazard class: multi-text-node paragraphs (verse
spans, inline markup), whitespace-only inline spans and comments,
drop caps and footnote anchors, br-separated poetry with speaker and
line-number spans, nested blockquotes/lists/tables/definition lists,
surrogate pairs and ZWJ emoji, Hebrew niqqud, Arabic RTL, combining
diacritics, invisible characters, HTML entities, CJK ruby, a 400-
paragraph chapter with heavy phrase repetition for search
disambiguation, and edge constructs (span-first paragraphs, empty
spans, adjacent same-tag spans, deep same-tag nesting).

Default output lands in the Ebooks library for real-device testing
via OPDS; the script takes an output path argument for use as a test
fixture.
2026-10-12 20:46:40 -04:00
John O'Keefe 03a6040360 test(sync): add manual repro harness for CRE-to-CFI highlight conversion
TestManualCleanRepro replays a real device highlight export through the
same conversion path the sync handler uses, diffs the output against
the CFIs stored at ingest time, and computes a ground-truth oracle by
locating each selection in the flattened document itself. Classifies
every row as healthy / diff-base / backwards for both the converter
and the stored data. Skips automatically when fixture files are not
present on the machine (manual test, like cfi_verify_manual_test.go).

REPRO_VERBOSE=1 prints per-row detail; REPRO_EMIT_SQL=<path> emits
idempotent UPDATE statements that heal database rows written by the
old converter from the preserved raw device positions - used to
backfill dev and validated against production data.
2026-10-12 20:46:08 -04:00
John O'Keefe 93a468b435 fix(reader): emit two-subpath range CFIs for multi-node highlights
toRangeCfi required stored start and end CFIs to share a base path and
otherwise fell back to a bare point CFI, which resolves to a collapsed
range and paints nothing - or painted from the wrong anchor after end
repair. A legitimate selection crossing inline elements anchors at two
different text nodes by definition, so requiring a shared base path
discarded correct endpoints.

Build the range parent from the endpoints' longest shared step prefix
(down to the bare spine step when needed) and emit each divergent
remainder per the EPUB CFI range grammar, epubcfi(parent,start,end).
Degenerate-end repair (missing end, or end before start) now only
applies when both sides terminate on the SAME node - equal remaining
step count with different step values is a valid two-node range, not a
degenerate pair; document order is verified for those instead.

Verified with foliate's own parser against the real chapter XHTML:
ranges resolve to exactly the selected text for same-node, two-node,
and repaired-degenerate cases.
2026-10-12 20:45:41 -04:00
John O'Keefe bb3aef517c fix(sync): anchor device highlight ranges to selection text
KOReader highlights converted to wrong or unresolvable web CFIs
whenever a selection crossed text-node boundaries. Three compounding
defects: ParseCREXPointer discarded the text()[N] node index and
applied .offset as a global block offset; the end anchor converted
with an empty context (blind structural landing, zero verification);
and the block-containment check accepted wrong-node landings because
a selection's text always occurs somewhere in its own block. pos0 and
pos1 were converted independently, yielding mismatched start/end base
paths and backwards ranges - highlights stored but never painted, or
painted at the wrong location, in the web reader.

Add ConvertHighlightRange: both endpoints are anchored by locating the
selection text in the flattened document text (whitespace-normalized,
crossing block boundaries), with the device locators demoted to
disambiguation hints. A single document match yields a same-spine,
ordered start/end pair by construction. Falls back to the legacy
per-endpoint path when the text cannot be located, so behavior is
never worse than before.

Also fix the shared flatten stream: zero-width format characters
(ZWSP/ZWNJ/BOM) and whitespace-only inline spans (<span> </span>)
now act as word separators instead of fusing adjacent words, which
prevented text matching in real books.

Verified against 28 real device highlights from production: converter
output now matches the text-search oracle on all of them (previously
8/28 correct, 18 with mismatched base paths, 2 backwards).
2026-10-12 20:45:10 -04:00
John O'Keefe cbdddc095e fix(web): PDF adjust — remove the leftover line-band filter from the endpoint drag
Release / build-and-push (push) Successful in 2m39s
The "full 2D" commit (5a0d9e0) added the tip aim but never removed the
y-band candidate filter from pdfAdjustHandleMove — vertical handle
drags still snapped to the boundary rect's own line, so up/down
movement was impossible (the user's original report). With the filter
gone: the end handle drags down a line and the extent extends with
clean text — verified with real touch on the emulator (down one line,
release, PUT applied: 6 per-line rects).
2026-10-11 20:44:32 -04:00
John O'Keefe 5a0d9e0e17 feat(web): endpoint handles drag vertically — full 2D tip-aimed targeting
User report after the lifted-handle change: "I cannot go up or down,
just side to side" — a consequence of the line-band pin added with the
lift (the floating finger sits above the text; a band-constrained scan
ignored vertical movement entirely).

The endpoint drag is now full 2D: the finger position (plus the
~15px body-center-to-tip offset) maps to page fractions and the
nearest glyph re-anchors the grabbed end — up/down crosses lines,
side-to-side moves along them, clamped so the extent never inverts.
The boundary rect's own band constraint is gone; sliver rects remain
structurally impossible (rects rebuild from the char-table span as
per-line unions).

Direct-fraction probes verified: (0.4, 0.098) → mid line 1; (0.2,
0.117) → line 2 head; (0.72, 0.117) → line 2 mid — spans cluster into
the right per-line boxes. The dot-listener plumbing (pixels → map →
fractions → scan) verified by instrumented probe on the same load.
Final desktop finger pass pending (emulator input pipeline wedged).
2026-10-11 18:15:34 -04:00
John O'Keefe 19619ef817 fix(web): raise the adjust teardrops — tips rest above the glyph tops
User report: the teardrop tip touched the letters. Raised the handle
boxes 8px (tip now rests at the line-box top, ~4px above the glyph
tops) — matches the Android handle spacing the app leg shipped.
2026-10-11 17:57:03 -04:00
John O'Keefe a28ae5c88d feat(web): adjust handles float above the text with tips marking the boundary
User report: the handles covered the highlighted text. The dots now
hang ABOVE the extent's boundary corners (teardrop tip down at the
first rect's top-left / last rect's top-right), so the highlighted
line stays readable.

Same treatment as the app leg: the nearest-char scan in the endpoint
drag is band-constrained to the boundary rect's own line (the lifted
finger floats ABOVE the text — a whole-table scan re-anchored onto the
line above). Cross-line changes go through re-selection as before.
2026-10-11 17:25:58 -04:00
John O'Keefe 1c209408cc fix(web): PDF adjust — three compounding coordinate/table bugs, found by instrumented drag
User report (desktop Brave, fresh private window): the endpoint handle
"jumped several lines down and stuck". The instrumented drag exposed
the chain:

1. THE bug: the endpoint handleMove API takes PAGE FRACTIONS, but the
   dot's pointermove listener passed raw e.clientX/clientY PIXELS.
   Scanning for the nearest glyph to (296, 170) in a 0–1 fraction space
   picks the bottom-right-most glyph — the extent leaped to the page's
   end and never moved again (stuck). Engine-independent: failed
   identically on Chrome/Brave/Firefox desktops; my synthetic tests
   masked it by passing pre-converted fractions. handleMove now maps
   host px → fractions internally (one contract).

2. pdfPageMapping mixed coordinate spaces: the iframe-internal canvas
   rect with the host iframe rect. Whether that combination is valid
   depends on the engine's cross-document getBoundingClientRect
   semantics. Replaced with the page WRAPPER element (the iframe's
   parent — the same box the fork's rect-annotation overlay fills),
   measured in a single host context. Handles now align with the
   painted highlights by construction.

3. Char-table hygiene: per-char boxes are LINE-HEIGHT boxes (~one line
   gap tall). Dedupe at a full box height ate the line-below char in
   the same x-column (silent character loss); clustering at full box
   height never split lines (merged 0.45-tall "line" boxes). Dedupe
   now tolerates 0.75 of the SHORTER box; clustering breaks at 0.6 of
   the char's own height (inter-line gap ≈ 1.0× breaks, baseline
   variance ≈ 0.1× stays).

Verified end-to-end (CDP synthetic pointer path): drag to (0.72, 0.117)
→ span extends line 1 → line 2, 2 per-line rects saved, clean
multi-line text. Direct-fraction probes and the pointer path now agree
on the same load.
2026-10-11 13:01:16 -04:00
John O'Keefe 93f020f2b5 fix(web): PDF adjust handles — create once, reposition only
User report (desktop): the endpoint handles wouldn't move.
renderPdfAdjustHandles rebuilt the handle layer via innerHTML="" on
every preview frame — the first drag move triggered a repaint that
DESTROYED the dot being dragged. A removed element loses pointer
capture, so real mouse/touch drags died silently after one pixel; the
pointerup never reached the detached dot either, so nothing saved and
the session preview reverted — the same mechanism behind the earlier
"resets to the original selection" report. CDP tests masked it because
synthetic events dispatched directly at the detached node still ran its
listeners.

The two dots are now created once per session and every later call only
repositions them (style updates move nothing the listeners live on).
Grip-era code had this reuse pattern; the endpoint rewrite regressed it.

Real-touch verified on the emulator (adb-injected drag): the handle
tracks, the release saves a multi-line extent with clean recomputed
text (3 rects, PUT applied).
2026-10-11 11:21:51 -04:00
John O'Keefe a3ecac3507 fix(server): Cache-Control no-cache on /static — heuristic caching served stale bundles
The static handler sent no Cache-Control, so browsers fell back to
heuristic caching (10% of age since Last-Modified) and kept serving a
stale reader.js for hours after a deploy — fixed web bugs reported as
"not fixed". no-cache forces revalidation every load; the filesystem
Last-Modified/If-Modified-Since pair keeps unchanged loads cheap.

Found while closing out the PDF adjust v2 session: the user's Chrome
was running a pre-endpoint bundle, which read as "the web has not
been fixed".
2026-10-11 09:50:42 -04:00
John O'Keefe db3b5b3d62 fix(web): endpoint handles verified end-to-end — multi-line extent saved (5 rects, clean text); note: footnote markers between lines are geometrically included by design 2026-10-10 23:19:35 -04:00
John O'Keefe f69e659d94 feat(web): PDF adjust v2 — re-selection replaces the extent; vertical grip clamp
Multi-line partial extents ("line 1's tail + the first words of line
2") were inexpressible with one-rect-per-drag grips, and the vertical
drags produced sliver rects whose text recompute swept fragments. The
web's re-selection uses what creation already uses: a selection made
during a rect-edge session is intercepted in checkPDFSelectionFor —
rects rebuild from the selection's per-line client rects, text re-reads
via the deduped glyph boxes, saved immediately (grip release-to-save
parity), and the create popover is suppressed for that gesture.

Vertical grip drags clamp to the rect's OWN line box (band ±quarter
height) — multi-line changes go through re-selection, slivers are gone.

Also verifies clean under the duplicated text-layer span sets (the
fork's pdf.js render never clears .textLayer — open bug): the text
recompute dedupes by glyph overlap.

CDP-verified end-to-end: session → multi-line selection → 6-rect
extent + clean text persisted (PUT 200); create popover correctly
suppressed during the session.
2026-10-10 18:25:29 -04:00
John O'Keefe dbeefb395c fix(web): PDF adjust text recompute — overlap-based glyph dedupe
User report: extending a highlight doubled letters into the recomputed
selection text ("aauuthenticcity off ... ruunss iinnto"). The exact-
position dedupe key (toFixed(3)) only collapsed duplicate glyphs whose
re-render copies landed inside the same rounding bucket — pdf.js
re-render copies can sit SUB-PIXEL SHIFTED, so per character the jitter
sometimes straddled a boundary and both copies survived, partially.

Dedupe by overlap instead: a glyph is a duplicate when a kept glyph on
the same line (y0 within half a glyph height) overlaps more than half
the narrower glyph's width. Distinct glyphs — including tight pairs —
never reach that; shifted duplicate copies always do. The snap tables
share the source, so their boundary lists are clean too.

The root cause remains the fork's pdf.js render() appending a fresh
span set without clearing .textLayer (open bug, fork-cycle fix); this
is the defensive layer that makes the adjust feature correct regardless.

Repaired the affected highlight's stored text with the fixed recompute
(verified: the same live duplicated DOM now yields the exact line text
with zero doubling).
2026-10-10 14:25:48 -04:00
John O'Keefe 8acaeddda5 fix(web): detail banner — a failed annotation fetch must not wipe its rows
fetchBannerAnnotations published the rebuilt rows unconditionally, with
a failed endpoint's list defaulted to empty — so one failing request
(a single handler 500, an auth blip on one call) silently wiped that
annotation kind from the banner while the other kind stayed, and the
empty state could claim "No notes or highlights on this book yet" over
real data. A full outage was already safe (the thrown fetch takes the
catch path, which keeps previous rows and toasts) — the partial case
was the hole, spotted while mirroring the app's fetch-wipe fix
(bookhoard-app 07bbb4c) onto the web.

Both responses must now succeed before the rows are published;
otherwise the previous rows stay and the next banner open retries
(bannerLoaded only sets on success). Verified on the emulator: banner
renders 3 rows, a full outage keeps them with bannerError recorded,
and a post-recovery refetch re-renders from live data.
2026-10-10 13:56:32 -04:00
John O'Keefe 361fdaab39 fix(web): PDF rect-edge adjust — a start-edge drag pins the far edge
The left-edge drag kept the rect width instead of anchoring the opposite
edge, so shrinking from the left silently dragged the right edge along
and the recomputed text grew words at the far end (found by the Android
device test of the app leg). Same fix for the top edge / bottom pin.
The end-edge branches were already correct.
2026-10-10 11:47:41 -04:00
John O'Keefe 4d1fb05e49 feat(web): PDF highlight extent adjustment — rect-edge grips with word-token snap
The reflowable adjust (objective 9) had no fixed-layout counterpart: the
popover's Adjust button relied on adjustContext, which is only stashed by
the reflowable show-annotation path, so PDF rows showed a button that
silently did nothing. PDF highlights persist as page-fraction rect
payloads ({v:1,page,rects}), so their adjustment is geometry, not range
re-anchoring.

- startAnyAdjust routes the popover button by format; startPdfAdjust
  runs a rect-edge session: 4 grips per rect (all edges, one rect per
  drag), live preview through the renderer's own addRectAnnotation
  (replace-by-key — no fork change), commit via saveHighlightChanges.
- Snap vocabulary mirrors snapBoundary: maximal letter runs are atomic
  tokens (start edge takes the word start, end edge the word end), each
  punctuation glyph is its own token with both edges grabbable (a period
  or line-break hyphen can be taken or shed precisely). Horizontal edges
  snap to line top/bottom bands (own line + neighbours).
- selection_text is recomputed from the glyph boxes inside the final
  rects (reading order, lines joined with spaces) so the stored text
  stays honest with the extent.
- Cancel paths mirror the reflowable session: page turn (renderer
  relocate) discards and restores; a committed session skips the restore.
- getContents() from the fixed-layout renderer carries no index — page
  iframe docs are mapped through a WeakMap filled by the view's load
  events. Pointer mapping goes through the canvas's post-transform rect
  (the dpr denominator lesson) via the fork's own convertPoint ratios.
- pdfCharBoxes dedupes by box key: pdf.js re-renders append a second
  span set into the same .textLayer without clearing, and undeduped
  glyphs doubled the recomputed text.

Device-verified (emulator Chrome + CDP): popover route, grip placement,
word-atomic shrink ("But this" -> "this"), extension to the exact
line-break hyphen boundary (0.8777), far-drag clamp at the last token,
page-turn cancel, and the reflowable adjust still routing to handles.
2026-10-10 11:22:09 -04:00
14 changed files with 1486 additions and 37 deletions

No files matched your search

+3
View File
@@ -79,3 +79,6 @@ web/static/vendor/
VALID_TOKEN VALID_TOKEN
fresh_token.txt fresh_token.txt
session-*.md session-*.md
# Local prod-export scratch: personal annotation/reading data, never commit
/export/
+16 -1
View File
@@ -93,7 +93,22 @@ func (h *KOReaderHandler) convertHighlightPositions(ec annotationEpub, pos0, pos
if pos0 == "" || !ec.convertible() { if pos0 == "" || !ec.convertible() {
return "", "" return "", ""
} }
startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, percentage, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "") // Primary: anchor both endpoints by the selection text itself. One
// document match yields a same-spine ordered pair — the invariant the
// web reader's range-CFI builder needs. Independent per-endpoint
// conversion mis-anchors any selection past a block's first text node
// (crengine text-node indices don't survive to the raw XHTML).
startLoc := wsync.ConvertHighlightRangeToCanonical(ec.epubPath, ec.mediaItem.FormatGroup, pos0, pos1, contextText, percentage)
if startLoc.Precision == "exact" || startLoc.Precision == "structural" {
startCFI := webUsableCFI(startLoc)
endCFI := ""
if startCFI != "" && startLoc.EndCFI != "" {
endCFI = webUsableCFI(wsync.CanonicalLocator{CFI: startLoc.EndCFI, Precision: startLoc.Precision})
}
return startCFI, endCFI
}
// Fallback: legacy independent per-endpoint conversion.
startLoc = wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, percentage, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "")
startCFI := webUsableCFI(startLoc) startCFI := webUsableCFI(startLoc)
endCFI := "" endCFI := ""
// A text-search start matched the selection text itself: its extent // A text-search start matched the selection text itself: its extent
+15
View File
@@ -250,6 +250,21 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
registerWebSocketRoutes(cfg) registerWebSocketRoutes(cfg)
registerFrontendRoutes(cfg) registerFrontendRoutes(cfg)
registerDocumentationRoutes(cfg) registerDocumentationRoutes(cfg)
// Static bundles are served unhashed (reader.js, index.js), so the
// browser must revalidate on every load — with no Cache-Control it
// fell back to heuristic caching and served stale bundles for hours
// after a deploy (user-visible: fixed web bugs "not fixed"). no-cache
// still allows cheap If-Modified-Since 304 revalidation.
e.Pre(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
if strings.HasPrefix(c.Request().URL.Path, "/static/") {
c.Response().Header().Set(echo.HeaderCacheControl, "no-cache")
}
return next(c)
}
})
e.Static("/static", "web/static") e.Static("/static", "web/static")
// Catch-all 404 handler - must be last // Catch-all 404 handler - must be last
+12 -4
View File
@@ -626,7 +626,12 @@ func flattenDocumentForSearch(body *html.Node) ([]rune, []docRunePos) {
lastBlock = block lastBlock = block
off := 0 off := 0
for _, r := range n.Data { for _, r := range n.Data {
if unicode.IsSpace(r) { // Zero-width format characters are line-break opportunities,
// not word characters: devices render them as breaks and
// capture plain spaces, so they must not fuse words in the
// search stream. ZWJ (U+200D) is exempt — it glues emoji
// sequences.
if unicode.IsSpace(r) || r == '\u200b' || r == '\u200c' || r == '\ufeff' {
pendingSpace = true pendingSpace = true
} else { } else {
if pendingSpace && len(runes) > 0 { if pendingSpace && len(runes) > 0 {
@@ -644,9 +649,12 @@ func flattenDocumentForSearch(body *html.Node) ([]rune, []docRunePos) {
var walk func(n *html.Node) var walk func(n *html.Node)
walk = func(n *html.Node) { walk = func(n *html.Node) {
if n.Type == html.TextNode { if n.Type == html.TextNode {
if strings.TrimSpace(n.Data) != "" { // Whitespace-only nodes still participate: a space carried in
appendNode(n) // its own inline span (<span class="sp"> </span>) is a real
} // word separator, and skipping it fuses adjacent words in the
// search stream. appendNode turns such nodes into pending
// space without emitting anything.
appendNode(n)
return return
} }
for c := n.FirstChild; c != nil; c = c.NextSibling { for c := n.FirstChild; c != nil; c = c.NextSibling {
+167
View File
@@ -0,0 +1,167 @@
package sync
// Text-anchored range conversion for device highlights.
//
// Why this exists: CRE xpointers address text as text()[N].offset, but
// crengine's text-node enumeration does not survive to the raw XHTML on
// disk (whitespace runs, entity merges, live-DOM splits), and the trailing
// offset alone lands at a global block offset that is wrong for any anchor
// past the first text node of a block. Converting pos0 and pos1
// independently made it worse: mismatched start/end base paths and
// backwards ranges — highlights that store but never paint (or paint in
// the wrong place) in the web reader.
//
// The device's selection text, however, is a quote of the document. So for
// highlights we anchor BOTH endpoints by locating that quote in the
// flattened document text (whitespace-normalized, crossing block
// boundaries), and use the device locators only as disambiguation hints
// when the quote occurs more than once. Endpoints derived from one match
// are same-spine and ordered by construction — the two properties the
// reader's range-CFI builder needs.
import (
"log"
"regexp"
"strings"
"unicode/utf8"
"golang.org/x/net/html"
)
// ConvertHighlightRange resolves a highlight's pos0/pos1 pair into a start
// CFI plus a true range end by locating the selection text in the document.
// It returns nil when the text cannot be located; callers fall back to the
// legacy per-endpoint conversion.
func (c *CFIConverter) ConvertHighlightRange(pos0, pos1, contextText string, storedPercentage float64) *ConversionResult {
if !IsCREXPointer(pos0) {
return nil
}
xp, err := ParseCREXPointer(pos0)
if err != nil {
return nil
}
doc, href, err := c.getContentDoc(xp.FragmentIndex)
if err != nil {
return nil
}
body := findBody(doc)
if body == nil {
return nil
}
spineIndex := xp.FragmentIndex - 1
// KOReader wraps partial-paragraph captures in curly braces; they are
// wire markers, not document text. Zero-width format characters in the
// capture are break opportunities the device rendered — normalize to
// spaces so both sides of the match agree.
ctx := normalizeWhitespace(strings.Trim(contextText, "{}"))
ctx = strings.NewReplacer("\u200b", " ", "\u200c", " ", "\ufeff", " ").Replace(ctx)
ctx = normalizeWhitespace(ctx)
if ctx == "" {
return nil
}
runes, poss := flattenDocumentForSearch(body)
text := string(runes)
words := strings.Fields(ctx)
quoted := make([]string, len(words))
for i, w := range words {
quoted[i] = regexp.QuoteMeta(w)
}
re, err := regexp.Compile(strings.Join(quoted, `\s+`))
if err != nil {
return nil
}
matches := re.FindAllStringIndex(text, -1)
if len(matches) == 0 {
log.Printf("Bookhoard: highlight range: selection %q not found in %s", truncateForLog(ctx, 60), href)
return nil
}
loc := pickHighlightMatch(matches, text, poss, xp, pos1, body)
if len(matches) > 1 {
log.Printf("Bookhoard: highlight range: %d matches for %q in %s, resolved by device hint", len(matches), truncateForLog(ctx, 40), href)
}
startRune := utf8.RuneCountInString(text[:loc[0]])
endRune := utf8.RuneCountInString(text[:loc[1]]) // exclusive
startPos := poss[startRune]
startCFI, err := buildCFI(spineIndex, startPos.node, startPos.off)
if err != nil || startCFI == "" {
log.Printf("Bookhoard: highlight range: buildCFI(start) failed: %v", err)
return nil
}
endCFI := ""
if endRune > startRune && endRune <= len(poss) {
endPos := poss[endRune-1]
endOff := endPos.off + 1
if n := utf8.RuneCountInString(endPos.node.Data); endOff > n {
endOff = n
}
if ec, err := buildCFI(spineIndex, endPos.node, endOff); err == nil && ec != "" {
endCFI = ec
}
}
log.Printf("Bookhoard: highlight range %q → %s (precision: exact)", truncateForLog(ctx, 60), startCFI)
return &ConversionResult{
EPUBCFI: startCFI,
EndEPUBCFI: endCFI,
Href: href,
Percentage: storedPercentage,
Precision: "exact",
}
}
// pickHighlightMatch chooses among multiple occurrences of the selection.
// pos1's element path (the end anchor's landing block) is the strongest
// hint: the true occurrence is the one whose extent stays inside that
// block. Without a usable hint, document order wins (first occurrence).
func pickHighlightMatch(matches [][]int, text string, poss []docRunePos, xp *CREXPointer, pos1 string, body *html.Node) []int {
if len(matches) <= 1 {
return matches[0]
}
if xp1, err := ParseCREXPointer(pos1); err == nil && len(xp1.ElementPath) > 0 {
if elem := resolveCREElementPath(body, xp1.ElementPath); elem != nil {
lo, hi := elementFlatRange(poss, elem)
if lo >= 0 {
for _, m := range matches {
s := utf8.RuneCountInString(text[:m[0]])
e := utf8.RuneCountInString(text[:m[1]]) - 1
if s >= lo && e < hi {
return m
}
}
}
}
}
return matches[0]
}
// elementFlatRange returns the [lo, hi) rune range of the flattened stream
// covered by elem's subtree, or (-1, 0) when elem contributes no text.
func elementFlatRange(poss []docRunePos, elem *html.Node) (int, int) {
lo, hi := -1, -1
for i, p := range poss {
if isDescendantNode(p.node, elem) {
if lo < 0 {
lo = i
}
hi = i + 1
}
}
if lo < 0 {
return -1, 0
}
return lo, hi
}
func isDescendantNode(n, ancestor *html.Node) bool {
for p := n.Parent; p != nil; p = p.Parent {
if p == ancestor {
return true
}
}
return false
}
+379
View File
@@ -0,0 +1,379 @@
package sync
// Manual repro harness for the CRE→CFI highlight conversion defects found in
// "Clean" (Rosser K Powitzky): 28 device highlights whose stored web CFIs are
// structurally inconsistent (mismatched start/end base paths, backwards
// ranges). This test mirrors handlers.convertHighlightPositions exactly —
// pos0 converted WITH the selection text as context, pos1 WITHOUT any — then
// reports the result alongside the CFIs stored at ingest time and a
// ground-truth oracle computed by locating the selection text in the document
// itself. Skips silently when the book/export are not on this machine, like
// cfi_verify_manual_test.go.
import (
"fmt"
"encoding/json"
"os"
"strings"
"testing"
"golang.org/x/net/html"
)
const (
reproExport = "/home/nymusicman/Code/bookhoard/export/media_highlights.json"
reproEpub = "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Rosser K Powitzky/Clean_ How the Jewish Roots of Atone (77)/Clean_ How the Jewish Roots of - Rosser K Powitzky.epub"
)
type reproRow struct {
ID string `json:"id"`
StartPosition string `json:"start_position"`
EndPosition string `json:"end_position"`
StoredStart string `json:"epubcfi_start"`
StoredEnd string `json:"epubcfi_end"`
SelectionText string `json:"selection_text"`
PctStart float64 `json:"percentage_start"`
}
// webUsable mirrors handlers.webUsableCFI (unexported there; cannot import
// the handlers package from here without a cycle).
func webUsable(precision, cfi string) string {
if precision != "structural" && precision != "exact" {
return ""
}
if !strings.HasPrefix(cfi, "epubcfi(") || !strings.HasSuffix(cfi, ")") {
return ""
}
return cfi
}
// reproExtendCFIByLength mirrors handlers.extendCFIByLength: advance the
// trailing character offset of a point CFI by the UTF-16 length of text.
func reproExtendCFIByLength(cfi, text string) string {
if cfi == "" || text == "" {
return ""
}
if !strings.HasSuffix(cfi, ")") {
return ""
}
inner := cfi[:len(cfi)-1]
idx := strings.LastIndex(inner, ":")
if idx < 0 {
return cfi
}
var off int
if _, err := fmtSscanInt(inner[idx+1:], &off); err != nil {
return cfi
}
return inner[:idx+1] + itoa(off+utf16Len(text)) + ")"
}
// cfiBase splits "epubcfi(...)" into (base, offset): base is everything
// before the final ":N" character-offset terminator.
func cfiBase(cfi string) (string, int, bool) {
if !strings.HasSuffix(cfi, ")") {
return "", 0, false
}
inner := cfi[:len(cfi)-1]
idx := strings.LastIndex(inner, ":")
if idx < 0 {
return inner, 0, true
}
off := 0
if _, err := fmtSscanInt(inner[idx+1:], &off); err != nil {
return inner, 0, true
}
return inner[:idx], off, true
}
func fmtSscanInt(s string, out *int) (int, error) {
n := 0
ok := false
for _, r := range s {
if r < '0' || r > '9' {
break
}
n = n*10 + int(r-'0')
ok = true
}
if !ok {
return 0, errReproNoInt
}
*out = n
return len(s), nil
}
var errReproNoInt = errReproNoIntErr{}
type errReproNoIntErr struct{}
func (errReproNoIntErr) Error() string { return "no integer" }
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
b[i] = '-'
}
return string(b[i:])
}
// oracleCFIs locates the selection text in the document named by the start
// xpointer's fragment and returns ground-truth (startCFI, endCFI, spineIndex).
// Whitespace runs are collapsed on both sides before matching, so device
// text with reflowed newlines still lands.
func oracleCFIs(t *testing.T, conv *CFIConverter, row reproRow) (string, string, string) {
xp, err := ParseCREXPointer(row.StartPosition)
if err != nil {
return "", "", "unparseable start xpointer"
}
doc, href, err := conv.getContentDoc(xp.FragmentIndex)
if err != nil {
return "", "", "content doc missing: " + err.Error()
}
body := findBody(doc)
if body == nil {
return "", "", "no body"
}
// Flatten every text node in document order.
var segs []textSegment
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.TextNode {
segs = append(segs, textSegment{node: n, runes: []rune(n.Data)})
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(body)
var flat []rune
for _, s := range segs {
flat = append(flat, s.runes...)
}
needle := []rune(normalizeWhitespace(row.SelectionText))
if len(needle) == 0 {
return "", "", "empty selection text"
}
// Collapse whitespace in `flat` while mapping each kept rune back to a
// flat index.
var norm []rune
var normToFlat []int
lastWS := false
for i, r := range flat {
isWS := r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == ' ' || r == '​'
if isWS {
if lastWS {
continue
}
norm = append(norm, ' ')
normToFlat = append(normToFlat, i)
lastWS = true
continue
}
norm = append(norm, r)
normToFlat = append(normToFlat, i)
lastWS = false
}
// Trim wrapped-quote artifacts KOReader adds to partial-paragraph text.
trimmed := needle
for len(trimmed) > 0 && (trimmed[0] == '{' || trimmed[0] == '}') {
trimmed = trimmed[1:]
}
for len(trimmed) > 0 && (trimmed[len(trimmed)-1] == '{' || trimmed[len(trimmed)-1] == '}') {
trimmed = trimmed[:len(trimmed)-1]
}
pos := indexRunes(norm, trimmed)
if pos < 0 {
return "", "", "selection text not found in doc " + href
}
flatStart := normToFlat[pos]
flatEnd := normToFlat[pos+len(trimmed)-1] + 1 // end is exclusive
nodeAt := func(flatIdx int) (*html.Node, int) {
acc := 0
for _, s := range segs {
n := len(s.runes)
if flatIdx < acc+n {
return s.node, flatIdx - acc
}
acc += n
}
return nil, 0
}
startNode, startOff := nodeAt(flatStart)
endNode, endOff := nodeAt(flatEnd)
if startNode == nil || endNode == nil {
return "", "", "flat index out of range"
}
spineIndex := xp.FragmentIndex - 1
startCFI, err := buildCFI(spineIndex, startNode, startOff)
if err != nil {
return "", "", "buildCFI start: " + err.Error()
}
endCFI, err := buildCFI(spineIndex, endNode, endOff)
if err != nil {
return "", "", "buildCFI end: " + err.Error()
}
return startCFI, endCFI, ""
}
func indexRunes(hay, needle []rune) int {
if len(needle) == 0 || len(needle) > len(hay) {
return -1
}
for i := 0; i+len(needle) <= len(hay); i++ {
match := true
for j := range needle {
if hay[i+j] != needle[j] {
match = false
break
}
}
if match {
return i
}
}
return -1
}
func TestManualCleanRepro(t *testing.T) {
raw, err := os.ReadFile(reproExport)
if err != nil {
t.Skipf("export json not present: %v", err)
}
if _, err := os.Stat(reproEpub); err != nil {
t.Skipf("book epub not present: %v", err)
}
var rows []reproRow
if err := json.Unmarshal(raw, &rows); err != nil {
t.Fatalf("unmarshal export: %v", err)
}
conv := cachedConverter(reproEpub)
stats := map[string]int{}
for _, row := range rows {
// Mirror handlers.convertHighlightPositions (text-anchored primary
// path, legacy per-endpoint fallback).
startLoc := ConvertHighlightRangeToCanonical(reproEpub, "reflowable", row.StartPosition, row.EndPosition, row.SelectionText, row.PctStart)
startCFI := webUsable(startLoc.Precision, startLoc.CFI)
endCFI := ""
if startCFI != "" && startLoc.EndCFI != "" {
endCFI = webUsable(startLoc.Precision, startLoc.EndCFI)
}
if startCFI == "" {
startLoc = ConvertToCanonical(LocatorSourceKOReader, row.StartPosition, row.PctStart, row.SelectionText, "reflowable", reproEpub, "")
startCFI = webUsable(startLoc.Precision, startLoc.CFI)
endCFI = ""
if startCFI != "" && startLoc.EndCFI != "" {
endCFI = startLoc.EndCFI
}
if endCFI == "" && row.EndPosition != "" {
endLoc := ConvertToCanonical(LocatorSourceKOReader, row.EndPosition, row.PctStart, "", "reflowable", reproEpub, "")
endCFI = webUsable(endLoc.Precision, endLoc.CFI)
}
if endCFI == "" && startCFI != "" && row.SelectionText != "" {
endCFI = reproExtendCFIByLength(startCFI, row.SelectionText)
}
}
oracleStart, oracleEnd, oracleNote := oracleCFIs(t, conv, row)
stats["oracleMiss:"+statusOr(oracleNote)]++
cls := func(a, b string) string {
ba, oa, okA := cfiBase(a)
bb, ob, okB := cfiBase(b)
switch {
case !okA || !okB || a == "" || b == "":
return "missing"
case ba != bb:
return "diff-base"
case ob <= oa:
return "backwards"
default:
return "healthy"
}
}
cur := cls(startCFI, endCFI)
sto := cls(row.StoredStart, row.StoredEnd)
ora := cls(oracleStart, oracleEnd)
stats["current:"+cur]++
stats["stored:"+sto]++
stats["oracle:"+ora]++
if os.Getenv("REPRO_VERBOSE") != "" || cur != "healthy" || sto != "healthy" {
t.Logf("row %s", row.ID[:8])
t.Logf(" raw: %s -> %s", row.StartPosition, row.EndPosition)
t.Logf(" ctx: %q", truncateRunes(row.SelectionText, 60))
t.Logf(" stored: %-38s %-38s [%s]", row.StoredStart, row.StoredEnd, sto)
t.Logf(" current: %-38s %-38s [%s]", startCFI, endCFI, cur)
t.Logf(" oracle: %-38s %-38s [%s] %s", oracleStart, oracleEnd, ora, oracleNote)
}
}
t.Logf("summary: %v", stats)
// REPRO_EMIT_SQL=<path>: emit UPDATE statements carrying the NEW
// converter output into the database rows this export came from
// (one-off healing of rows written by the broken converter).
if out := os.Getenv("REPRO_EMIT_SQL"); out != "" {
var b strings.Builder
b.WriteString("BEGIN;\n")
for _, row := range rows {
startLoc := ConvertHighlightRangeToCanonical(reproEpub, "reflowable", row.StartPosition, row.EndPosition, row.SelectionText, row.PctStart)
startCFI := webUsable(startLoc.Precision, startLoc.CFI)
endCFI := ""
if startCFI != "" && startLoc.EndCFI != "" {
endCFI = webUsable(startLoc.Precision, startLoc.EndCFI)
}
if startCFI == "" {
continue // leave untouched; legacy fallback rows keep old CFIs
}
b.WriteString(fmt.Sprintf(
"UPDATE media_highlights SET epubcfi_start=%s, epubcfi_end=%s WHERE id='%s';\n",
pgQuote(startCFI), pgQuote(endCFI), row.ID,
))
}
b.WriteString("COMMIT;\n")
if err := os.WriteFile(out, []byte(b.String()), 0o600); err != nil {
t.Fatalf("write sql: %v", err)
}
t.Logf("wrote %s", out)
}
}
func pgQuote(s string) string {
if s == "" {
return "''"
}
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
func statusOr(s string) string {
if s == "" {
return "found"
}
return s
}
+32
View File
@@ -125,6 +125,38 @@ func ConvertToCanonical(
} }
} }
// ConvertHighlightRangeToCanonical is the highlight-specific twin of
// ConvertToCanonical: it anchors a device highlight's pos0/pos1 pair by the
// selection text so both endpoints derive from one document match — same
// spine, ordered — instead of two independent structural landings. Returns
// a "fallback" locator with an empty CFI when the text cannot be located;
// callers fall back to ConvertToCanonical per endpoint.
func ConvertHighlightRangeToCanonical(
epubPath string,
formatGroup string,
pos0 string,
pos1 string,
contextText string,
percentage float64,
) CanonicalLocator {
if !isConvertible(formatGroup) || epubPath == "" {
return CanonicalLocator{CFI: pos0, Precision: "passthrough", Percentage: percentage}
}
if !IsCREXPointer(pos0) {
return CanonicalLocator{CFI: pos0, Precision: "already-standard", Percentage: percentage}
}
result := cachedConverter(epubPath).ConvertHighlightRange(pos0, pos1, contextText, percentage)
if result == nil || result.EPUBCFI == "" {
return CanonicalLocator{CFI: "", Precision: "fallback", Percentage: percentage}
}
return CanonicalLocator{
CFI: result.EPUBCFI,
EndCFI: result.EndEPUBCFI,
Precision: result.Precision,
Percentage: result.Percentage,
}
}
func ConvertFromCanonical( func ConvertFromCanonical(
source LocatorSource, source LocatorSource,
canonicalCFI string, canonicalCFI string,
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""Build a synthetic 'torture test' EPUB exercising every CRE-xpointer -> EPUB-CFI
conversion hazard the server converter can encounter.
Each chapter targets one hazard class; constructs carry comments naming the
hazard. Output: uploads/Ebooks/Bookhoard Test/CRE CFI Torture Test/CRE CFI Torture Test.epub
(or argv[1]). Deterministic output for reproducible tests.
"""
import os
import sys
import zipfile
MIMETYPE = "application/epub+zip"
CONTAINER = """<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>
"""
def page(name, title, body):
return f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head><title>{title}</title><link rel="stylesheet" type="text/css" href="../css/style.css"/></head>
<body>
<!-- chapter: {name} -->
<h1 id="{name}-h">{title}</h1>
{body}
</body>
</html>
"""
CHAPTERS = []
# 1. The core bug shape: multi-text-node paragraphs (inline markup splitting text)
CHAPTERS.append(("split-text", "Split Text Nodes", """
<p id="st-p1">In the beginning <span class="verse">1</span> God created the heaven and the earth <span class="verse">2</span> and the earth was without form.</p>
<p id="st-p2">A <em>single</em> sentence with <strong>several</strong> inline <span class="mark">marked</span> spans inside it.</p>
<p id="st-p3">First text node here.<span class="cont">Immediately-following span text.</span>Text that continues after the span.<span class="cont2">And a second span.</span>Tail text node.</p>
<p id="st-p4">Quoted "double" and 'single' marks, an ampersand &amp; entity, and a unicode em—dash.</p>
<div class="group" id="st-group">
<p id="st-p5">Nested group paragraph one <span class="verse">1</span> with more text after the marker.</p>
<p id="st-p6">Nested group paragraph two, plain text only.</p>
</div>
"""))
# 2. Whitespace-only text nodes + comments between elements (sibling-index drift)
CHAPTERS.append(("whitespace-comments", "Whitespace and Comments", """
<ul id="wc-list">
<!-- comment directly inside list -->
<li id="wc-li1">
<p id="wc-p1">Indented markup with whitespace text nodes on every side.</p>
<!-- comment between siblings -->
<p id="wc-p2">Second indented paragraph with irregular internal spacing.</p>
</li>
<!-- another comment -->
<li id="wc-li2">
<p id="wc-p3">Paragraph after multiple comment and whitespace siblings.</p>
</li>
</ul>
<p id="wc-p4">Tail paragraph after the list.</p>
"""))
# 3. Drop caps + footnote anchors (single-char inline splits)
CHAPTERS.append(("dropcaps-footnotes", "Dropcaps and Footnotes", """
<p id="df-p1"><span class="dropcap">O</span>nce upon a time a drop cap split the first word from itself.</p>
<p id="df-p2">A claim requiring support<span class="fnref"><a id="fnref-1" href="#fn-1">1</a></span> and a second claim<span class="fnref"><a id="fnref-2" href="#fn-2">2</a></span> in the same paragraph.</p>
<p id="df-p3">Multiple <a href="#df-p1">internal links</a> interleaved <a href="#df-p2">through text</a> as anchors.</p>
<div class="footnotes" id="df-notes">
<p id="df-fn1"><a id="fn-1" href="#fnref-1">1.</a> The first footnote, body text follows the backlink.</p>
<p id="df-fn2"><a id="fn-2" href="#fnref-2">2.</a> The second footnote with <em>emphasis</em> inside.</p>
</div>
"""))
# 4. Poetry / drama: br-separated lines, speaker spans, line numbers
CHAPTERS.append(("poetry", "Poetry and Drama", """
<p id="po-dramap"><span class="speaker">HAMLET.</span> To be, or not to be, that is the question:<br/>Whether 'tis nobler in the mind to suffer<br/>The slings and arrows of outrageous fortune.</p>
<p id="po-verse1"><span class="line"><span class="lnum">1</span>The curfew tolls the knell of parting day,</span><br/><span class="line"><span class="lnum">2</span>The lowing herd wind slowly o'er the lea,</span><br/><span class="line"><span class="lnum">3</span>The plowman homeward plods his weary way,</span><br/><span class="line"><span class="lnum">4</span>And leaves the world to darkness and to me.</span></p>
"""))
# 5. Deep nesting: quotes in quotes, lists in lists, tables with inline markup
CHAPTERS.append(("nested", "Nested Structures", """
<blockquote id="ne-q1"><p id="ne-q1p1">Outer quotation text begins here <span class="verse">1</span> and continues.</p>
<blockquote id="ne-q2"><p id="ne-q2p1">Inner quotation, one level deeper <em>with emphasis</em> mid-sentence.</p></blockquote>
<p id="ne-q1p2">Outer quotation resumes after the inner block.</p>
</blockquote>
<ul id="ne-list1">
<li id="ne-li1">Level one item
<ul id="ne-list2">
<li id="ne-li2">Level two item
<ul id="ne-list3">
<li id="ne-li3">Level three item with <strong>bold</strong> text.</li>
<li id="ne-li4">Level three sibling item.</li>
</ul>
</li>
</ul>
</li>
<li id="ne-li5">Level one sibling item.</li>
</ul>
<table id="ne-table">
<tr id="ne-tr1"><td id="ne-td1">Cell with <em>inline</em> markup</td><td id="ne-td2">Second cell, plain</td></tr>
<tr id="ne-tr2"><td id="ne-td3">Cell three with a <span class="verse">7</span> marker</td><td id="ne-td4">Cell four</td></tr>
</table>
<dl id="ne-dl"><dt id="ne-dt1">Term one</dt><dd id="ne-dd1">Definition one with text.</dd><dt id="ne-dt2">Term two</dt><dd id="ne-dd2">Definition two with text.</dd></dl>
"""))
# 6. Unicode hazards: surrogate pairs, combining marks, RTL, invisible chars, entities
CHAPTERS.append(("unicode", "Unicode Hazards", """
<p id="un-emoji">Emoji with surrogate pairs: 🎉🚀 and a ZWJ family 👨‍👩‍👧‍👦 plus math 𝛑𝕏 and flags 🇺🇸🇯🇵.</p>
<p id="un-hebrew">Hebrew with niqqud: בְּרֵאשִׁית בָּרָא אֱלֹהִים and a Midrash on דָּבָר מִשְׁפָּט אֱמֶת.</p>
<p id="un-arabic">Arabic RTL with embedded LTR: يبدأ النص العربي هنا ثم 42 ثم ينتهي، مع تشكيل الكلمات المُعرَبة.</p>
<p id="un-combining">Decomposed e + combining acute: é vs precomposed é, and ĉ vs ĉ with combining circumflex.</p>
<p id="un-invisible">Soft hyphen in­side a word, zero&#8201;thin space, zero-width ​space, and trailing no-break space&#160;here.</p>
<p id="un-entities">Ampersand &amp;amp;, less &amp;lt;, greater &amp;gt;, apostrophe &amp;#39;, quote &amp;quot;, all rendered literally.</p>
"""))
# 7. Ruby / CJK
CHAPTERS.append(("ruby", "Ruby Annotations", """
<p id="ru-p1"><ruby>漢字<rt>かんじ</rt></ruby>の<ruby>読<rt>よ</rt></ruby>み<span>方</span>をテストする。</p>
<p id="ru-p2">日本語のテキストには<ruby>難<rt>むずか</rt></ruby>しい<ruby>漢字<rt>かんじ</rt></ruby>がたくさんあります。</p>
<p id="ru-p3">The ruby base 漢 and its annotation かん are separate text nodes in raw XHTML.</p>
"""))
# 8. Giant chapter with heavy phrase repetition (text-search disambiguation)
giant = ['<p id="gi-p0">Opening paragraph of the giant chapter, unique opening sentence.</p>']
for i in range(1, 401):
giant.append(
f'<p id="gi-p{i}">Verse <span class="verse">{i}</span> And it came to pass in those days that a repeated phrase appeared again among the people. '
f'Variation marker alpha-{i}: the sentence continues with unique tail number {i * 7} before ending.</p>'
)
CHAPTERS.append(("giant", "Giant Repeated Chapter", "\n".join(giant)))
# 9. Edge constructs: empties, same-tag adjacency, span-first paragraphs, deep same-tag nesting
deep = "<p id=\"ed-deep\">Deep nesting bottom text.</p>"
for lvl in range(10, 0, -1):
deep = f'<div id="ed-lvl{lvl}">{deep}</div>'
CHAPTERS.append(("edge", "Edge Constructs", f"""
<p id="ed-spanfirst"><span class="lead">Leading span before any direct text.</span> Direct text follows the span.<span class="tail">Tail span.</span></p>
<p id="ed-adjacent"><span class="a">Adjacent</span><span class="b">same-tag</span><span class="c">spans</span> then plain text.</p>
<p id="ed-empty">Text with an empty span <span></span> mid-sentence and an empty emphasis <em></em> after it.</p>
<p id="ed-emptyp"></p>
<div id="ed-onlychild">{deep}</div>
<p id="ed-attrs" data-why="not">Paragraph with odd attribute values like &quot;quotes&quot; and &apos;apostrophes&apos;.</p>
"""))
CSS = """body { margin: 1em; }
.verse, .lnum, .fnref { font-size: 0.7em; vertical-align: super; color: #666; }
.speaker { font-variant: small-caps; }
.dropcap { font-size: 2.5em; font-weight: bold; float: left; line-height: 1; }
.line { display: block; margin-left: 2em; }
blockquote { margin: 1em 2em; border-left: 2px solid #ccc; padding-left: 1em; }
ruby rt { font-size: 0.5em; }
"""
def build(out_path):
files = {}
files["mimetype"] = MIMETYPE
files["META-INF/container.xml"] = CONTAINER
files["css/style.css"] = CSS
manifests, spines = [], []
for idx, (name, title, body) in enumerate(CHAPTERS, start=1):
files[f"OPS/chapters/{name}.xhtml"] = page(name, title, body)
manifests.append(f' <item id="ch{idx}" href="chapters/{name}.xhtml" media-type="application/xhtml+xml"/>')
spines.append(f' <itemref idref="ch{idx}"/>')
nav_items = "\n".join(
f' <li><a href="chapters/{name}.xhtml">{title}</a></li>'
for name, title, _ in CHAPTERS
)
files["OPS/nav.xhtml"] = f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head><title>Navigation</title></head>
<body>
<nav epub:type="toc" id="toc"><h1>Contents</h1>
<ol>
{nav_items}
</ol>
</nav>
</body>
</html>
"""
nav_manifest = ' <item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>'
files["OPS/content.opf"] = f"""<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="uid">urn:uuid:1f2e3d4c-5b6a-7948-b7c6-d5e4f3a2b1c0</dc:identifier>
<dc:title>CRE CFI Torture Test</dc:title>
<dc:creator>Bookhoard Test</dc:creator>
<dc:language>en</dc:language>
<meta property="dcterms:modified">2026-10-12T00:00:00Z</meta>
</metadata>
<manifest>
{nav_manifest}
{chr(10).join(manifests)}
</manifest>
<spine>
{chr(10).join(spines)}
</spine>
</package>
"""
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with zipfile.ZipFile(out_path, "w") as z:
z.writestr(zipfile.ZipInfo("mimetype"), MIMETYPE, compress_type=zipfile.ZIP_STORED)
for arcname in sorted(files):
if arcname == "mimetype":
continue
z.writestr(arcname, files[arcname], compress_type=zipfile.ZIP_DEFLATED)
print(f"wrote {out_path} ({os.path.getsize(out_path):,} bytes, {len(CHAPTERS)} chapters)")
if __name__ == "__main__":
out = sys.argv[1] if len(sys.argv) > 1 else os.path.join(
os.path.dirname(__file__), "..",
"uploads/Ebooks/Bookhoard Test/CRE CFI Torture Test/CRE CFI Torture Test.epub")
build(os.path.normpath(out))
+1 -1
View File
@@ -166,7 +166,7 @@ templ Reader(user User, metadata ReaderMetadata) {
type="button" type="button"
class="reader-popover-btn" class="reader-popover-btn"
title="Adjust highlight extent" title="Adjust highlight extent"
@click="startAdjust()" @click="startAnyAdjust()"
> >
<svg class="reader-icon" width="16" height="16" aria-hidden="true"><path d="M 5 3 v 18 M 19 3 v 18 M 5 8 h 14 M 5 16 h 14"></path></svg> <svg class="reader-icon" width="16" height="16" aria-hidden="true"><path d="M 5 3 v 18 M 19 3 v 18 M 5 8 h 14 M 5 16 h 14"></path></svg>
</button> </button>
+1 -1
View File
@@ -123,7 +123,7 @@ func Reader(user User, metadata ReaderMetadata) templ.Component {
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div><!-- Selection popover (highlights) --><div id=\"selection-popover\" x-show=\"selectionPopover.open\" x-transition:enter=\"transition ease-out duration-150\" x-transition:enter-start=\"opacity-0 scale-95\" x-transition:enter-end=\"opacity-100 scale-100\" x-transition:leave=\"transition ease-in duration-100\" x-transition:leave-start=\"opacity-100 scale-100\" x-transition:leave-end=\"opacity-0 scale-95\" :style=\"'left:' + selectionPopover.x + 'px; top:' + selectionPopover.y + 'px'\" class=\"reader-popover\" role=\"dialog\" aria-label=\"Highlight selection\" @pointerdown.stop><div class=\"flex items-center gap-2\"><template x-for=\"c in highlightColors\" :key=\"c\"><button type=\"button\" class=\"color-dot\" :class=\"selectionPopover.color === c ? 'selected' : ''\" :style=\"'background-color:' + c\" :title=\"selectionPopover.mode === 'create' ? 'Highlight' : 'Set color'\" :aria-label=\"'Highlight color ' + c\" @click=\"selectionPopover.noteOpen\n\t\t\t\t\t\t\t? (selectionPopover.mode === 'create'\n\t\t\t\t\t\t\t\t? (selectionPopover.color = c)\n\t\t\t\t\t\t\t\t: (selectionPopover.color = c, saveHighlightChanges(true)))\n\t\t\t\t\t\t\t: (selectionPopover.mode === 'create'\n\t\t\t\t\t\t\t\t? createHighlight(c)\n\t\t\t\t\t\t\t\t: (selectionPopover.color = c, saveHighlightChanges()))\"></button></template><div class=\"w-px h-5 reader-sep\"></div><button type=\"button\" class=\"reader-popover-btn\" title=\"Note\" @click=\"selectionPopover.noteOpen = true\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M 4 13 L 4 16 L 7 16 L 14.5 8.5 L 11.5 5.5 L 4 13 M 12.5 4.5 L 15.5 7.5\"></path></svg></button> <button type=\"button\" class=\"reader-popover-btn\" title=\"Copy text\" @click=\"copySelectionText()\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M 6 6 V 3 H 16 V 13 H 13 M 3 6 H 13 V 16 H 3 Z\"></path></svg></button><template x-if=\"selectionPopover.mode === 'edit'\"><button type=\"button\" class=\"reader-popover-btn\" title=\"Adjust highlight extent\" @click=\"startAdjust()\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M 5 3 v 18 M 19 3 v 18 M 5 8 h 14 M 5 16 h 14\"></path></svg></button></template><template x-if=\"selectionPopover.mode === 'edit'\"><button type=\"button\" class=\"reader-popover-btn danger\" title=\"Delete highlight\" @click=\"deleteHighlightById(selectionPopover.id)\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M 5 6 H 15 L 14 17 H 6 Z M 8 6 V 4 H 12 V 6 M 4 6 H 16\"></path></svg></button></template></div><div x-show=\"selectionPopover.noteOpen\" class=\"mt-2\"><textarea x-model=\"selectionPopover.note\" rows=\"3\" class=\"reader-note-input\" placeholder=\"Note…\" @paste=\"richNotePaste\"></textarea><p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">Markdown supported — rich-text paste converts to markdown</p><div class=\"text-xs mt-0.5 text-right\" x-show=\"selectionPopover.note.length > 0\" :style=\"noteOverLimit(selectionPopover.note) ? 'color: #f87171;' : 'color: var(--text-secondary);'\" x-text=\"noteCountLabel(selectionPopover.note)\"></div><div class=\"flex gap-2 mt-1\"><button type=\"button\" class=\"flex-1 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed\" :disabled=\"noteOverLimit(selectionPopover.note)\" @click=\"selectionPopover.mode === 'create' ? createHighlight(selectionPopover.color) : saveHighlightChanges()\" x-text=\"selectionPopover.mode === 'create' ? 'Highlight with note' : 'Save note'\">Save</button> <button type=\"button\" class=\"flex-1 py-1 text-xs border rounded hover:opacity-80\" style=\"border-color: var(--border);\" @click=\"hideSelectionPopover()\">Cancel</button></div></div></div><!-- Annotations drawer (right): highlights / notes / bookmarks --><div id=\"annotations-drawer\" x-show=\"bookmarksOpen\" x-transition:enter=\"transition-transform duration-200 ease-out\" x-transition:enter-start=\"translate-x-full\" x-transition:enter-end=\"translate-x-0\" x-transition:leave=\"transition-transform duration-150 ease-in\" x-transition:leave-start=\"translate-x-0\" x-transition:leave-end=\"translate-x-full\" class=\"reader-drawer right\" role=\"dialog\" aria-label=\"Annotations\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div><!-- Selection popover (highlights) --><div id=\"selection-popover\" x-show=\"selectionPopover.open\" x-transition:enter=\"transition ease-out duration-150\" x-transition:enter-start=\"opacity-0 scale-95\" x-transition:enter-end=\"opacity-100 scale-100\" x-transition:leave=\"transition ease-in duration-100\" x-transition:leave-start=\"opacity-100 scale-100\" x-transition:leave-end=\"opacity-0 scale-95\" :style=\"'left:' + selectionPopover.x + 'px; top:' + selectionPopover.y + 'px'\" class=\"reader-popover\" role=\"dialog\" aria-label=\"Highlight selection\" @pointerdown.stop><div class=\"flex items-center gap-2\"><template x-for=\"c in highlightColors\" :key=\"c\"><button type=\"button\" class=\"color-dot\" :class=\"selectionPopover.color === c ? 'selected' : ''\" :style=\"'background-color:' + c\" :title=\"selectionPopover.mode === 'create' ? 'Highlight' : 'Set color'\" :aria-label=\"'Highlight color ' + c\" @click=\"selectionPopover.noteOpen\n\t\t\t\t\t\t\t? (selectionPopover.mode === 'create'\n\t\t\t\t\t\t\t\t? (selectionPopover.color = c)\n\t\t\t\t\t\t\t\t: (selectionPopover.color = c, saveHighlightChanges(true)))\n\t\t\t\t\t\t\t: (selectionPopover.mode === 'create'\n\t\t\t\t\t\t\t\t? createHighlight(c)\n\t\t\t\t\t\t\t\t: (selectionPopover.color = c, saveHighlightChanges()))\"></button></template><div class=\"w-px h-5 reader-sep\"></div><button type=\"button\" class=\"reader-popover-btn\" title=\"Note\" @click=\"selectionPopover.noteOpen = true\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M 4 13 L 4 16 L 7 16 L 14.5 8.5 L 11.5 5.5 L 4 13 M 12.5 4.5 L 15.5 7.5\"></path></svg></button> <button type=\"button\" class=\"reader-popover-btn\" title=\"Copy text\" @click=\"copySelectionText()\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M 6 6 V 3 H 16 V 13 H 13 M 3 6 H 13 V 16 H 3 Z\"></path></svg></button><template x-if=\"selectionPopover.mode === 'edit'\"><button type=\"button\" class=\"reader-popover-btn\" title=\"Adjust highlight extent\" @click=\"startAnyAdjust()\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M 5 3 v 18 M 19 3 v 18 M 5 8 h 14 M 5 16 h 14\"></path></svg></button></template><template x-if=\"selectionPopover.mode === 'edit'\"><button type=\"button\" class=\"reader-popover-btn danger\" title=\"Delete highlight\" @click=\"deleteHighlightById(selectionPopover.id)\"><svg class=\"reader-icon\" width=\"16\" height=\"16\" aria-hidden=\"true\"><path d=\"M 5 6 H 15 L 14 17 H 6 Z M 8 6 V 4 H 12 V 6 M 4 6 H 16\"></path></svg></button></template></div><div x-show=\"selectionPopover.noteOpen\" class=\"mt-2\"><textarea x-model=\"selectionPopover.note\" rows=\"3\" class=\"reader-note-input\" placeholder=\"Note…\" @paste=\"richNotePaste\"></textarea><p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">Markdown supported — rich-text paste converts to markdown</p><div class=\"text-xs mt-0.5 text-right\" x-show=\"selectionPopover.note.length > 0\" :style=\"noteOverLimit(selectionPopover.note) ? 'color: #f87171;' : 'color: var(--text-secondary);'\" x-text=\"noteCountLabel(selectionPopover.note)\"></div><div class=\"flex gap-2 mt-1\"><button type=\"button\" class=\"flex-1 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed\" :disabled=\"noteOverLimit(selectionPopover.note)\" @click=\"selectionPopover.mode === 'create' ? createHighlight(selectionPopover.color) : saveHighlightChanges()\" x-text=\"selectionPopover.mode === 'create' ? 'Highlight with note' : 'Save note'\">Save</button> <button type=\"button\" class=\"flex-1 py-1 text-xs border rounded hover:opacity-80\" style=\"border-color: var(--border);\" @click=\"hideSelectionPopover()\">Cancel</button></div></div></div><!-- Annotations drawer (right): highlights / notes / bookmarks --><div id=\"annotations-drawer\" x-show=\"bookmarksOpen\" x-transition:enter=\"transition-transform duration-200 ease-out\" x-transition:enter-start=\"translate-x-full\" x-transition:enter-end=\"translate-x-0\" x-transition:leave=\"transition-transform duration-150 ease-in\" x-transition:leave-start=\"translate-x-0\" x-transition:leave-end=\"translate-x-full\" class=\"reader-drawer right\" role=\"dialog\" aria-label=\"Annotations\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+14 -2
View File
@@ -210,8 +210,20 @@ Alpine.data("bookDetail", () => {
headers: { Authorization: getAuthHeader() }, headers: { Authorization: getAuthHeader() },
}), }),
]); ]);
const notes = noteResp.ok ? await noteResp.json() : []; // A partial failure must not read as "no annotations": a failed
const highlights = hlResp.ok ? await hlResp.json() : []; // endpoint used to be published as an empty list, silently wiping
// that kind from the banner while the other kind stayed (found
// auditing the app's fetch-wipe fix, bookhoard-app 07bbb4c).
// Keep the previous rows; the next banner open retries.
if (!noteResp.ok || !hlResp.ok) {
console.warn(
`banner annotation load failed ` +
`(notes ${noteResp.status}, highlights ${hlResp.status}) — keeping previous`,
);
return;
}
const notes = await noteResp.json();
const highlights = await hlResp.json();
const built = buildAnnotationRows(notes, highlights); const built = buildAnnotationRows(notes, highlights);
this.bannerRows = built.rows; this.bannerRows = built.rows;
this.bannerSources = built.sources; this.bannerSources = built.sources;
+602 -27
View File
@@ -24,6 +24,12 @@ const HIGHLIGHT_COLORS = [
// Alpine reactivity never wraps it and revisiting pages is instant. // Alpine reactivity never wraps it and revisiting pages is instant.
const thumbCache = new Map<number, HTMLCanvasElement>(); const thumbCache = new Map<number, HTMLCanvasElement>();
// Fixed-layout page iframe → its page index. The fixed-layout renderer's
// getContents() returns {doc} WITHOUT an index (unlike the reflowable
// renderer), so the rect-edge adjust maps a page index back to its iframe
// doc through this registry, filled by the view's load events.
const pdfFrameDocs = new WeakMap<object, number>();
foliateConfig.pdfjsPath = (path) => `/static/vendor/pdfjs/${path}`; foliateConfig.pdfjsPath = (path) => `/static/vendor/pdfjs/${path}`;
const FONT_MAP: Record<string, string> = { const FONT_MAP: Record<string, string> = {
@@ -496,6 +502,27 @@ document.addEventListener("alpine:init", () => {
color: string; color: string;
}, },
adjustCancelOnRelocate: null as null | (() => void), adjustCancelOnRelocate: null as null | (() => void),
/** PDF rect-edge adjust session (fixed-layout highlights): the working
* copy of the highlight's rects (page fractions) plus the original
* for cancel. Null outside adjust mode. */
pdfAdjust: null as null | {
id: string;
index: number;
page: number;
color: string;
rects: number[][];
original: number[][];
/** The working extent as a live DOM range: handles re-anchor its
* boundaries via caret probes, and rects rebuild from its line
* boxes on every move. */
range: Range;
/** True once finalize handed off to saveHighlightChanges — cancel
* must not restore the original paint over the saved new one. */
committed: boolean;
},
pdfAdjustRaf: 0,
pdfAdjustOnRelocate: null as null | (() => void),
pdfAdjustOnZoom: null as null | (() => void),
pdfSelDoc: null as any, pdfSelDoc: null as any,
progressText: "", progressText: "",
progressLabel: "", progressLabel: "",
@@ -691,7 +718,10 @@ document.addEventListener("alpine:init", () => {
"show-rect-annotation", "show-rect-annotation",
(e: any) => { (e: any) => {
const { key, clientX, clientY } = e.detail; const { key, clientX, clientY } = e.detail;
const h = this.highlightItems.find((x) => x.id === key); // A tap anywhere on the page during rect adjust must not pop
// the editor mid-session (page turn is the cancel gesture).
if (this.pdfAdjust) return;
const h = this.highlightItems.find((x: any) => x.id === key);
if (!h) return; if (!h) return;
this.openSelectionPopover({ this.openSelectionPopover({
mode: "edit", mode: "edit",
@@ -718,6 +748,8 @@ document.addEventListener("alpine:init", () => {
} }
this.view.addEventListener("load", (e: any) => { this.view.addEventListener("load", (e: any) => {
const { doc, index } = e.detail; const { doc, index } = e.detail;
if (doc && typeof index === "number")
pdfFrameDocs.set(doc, index);
this.currentDoc = doc; this.currentDoc = doc;
const link = doc.createElement("link"); const link = doc.createElement("link");
link.rel = "stylesheet"; link.rel = "stylesheet";
@@ -1521,6 +1553,426 @@ document.addEventListener("alpine:init", () => {
} }
this.adjustContext = null; this.adjustContext = null;
}, },
// ----- PDF highlight extent adjustment (v2: endpoint handles) -----
/** Format router for the popover's Adjust button: PDF highlights
* adjust with two endpoint handles, reflowable ones with range
* handles. */
startAnyAdjust() {
if (this.selectionPopover.pdfPage >= 0) this.startPdfAdjust();
else this.startAdjust();
},
/** Page→host mapping for a fixed-layout index. Basis: the page's
* WRAPPER element (the iframe's parent — the same box the fork's
* own rect-annotation overlay fills with percentage-positioned
* children). Measuring it is a single same-context host measurement:
* the previous version combined the iframe-internal canvas rect
* with the host iframe rect, and whether that mix is valid depends
* on which coordinate space a given engine returns for
* cross-document getBoundingClientRect — on desktop Brave it
* mismatched, so drags re-anchored "several lines down" and stuck.
* The wrapper is also what the highlights are painted against, so
* handle positions align with the paint by construction. */
pdfPageMapping(index: number) {
// The fixed-layout renderer's getContents() carries no index — map
// through the load-event registry, keeping only live frames.
const contents = this.renderer?.getContents?.() ?? [];
const doc = contents
.map((c: any) => c.doc)
.find(
(d: any) =>
d && pdfFrameDocs.get(d) === index && d.defaultView?.frameElement,
);
if (!doc) return null;
const frame = doc.defaultView?.frameElement as HTMLElement | null;
const wrapper = frame?.parentElement;
const wr = wrapper?.getBoundingClientRect();
if (!wr || !wr.width || !wr.height) return null;
const left = wr.left;
const top = wr.top;
const width = wr.width;
const height = wr.height;
return {
doc: doc as Document,
toHost(fx: number, fy: number) {
return { x: left + fx * width, y: top + fy * height };
},
toFraction(clientX: number, clientY: number) {
return {
fx: (clientX - left) / width,
fy: (clientY - top) / height,
};
},
};
},
/** Character glyph boxes (page fractions) in reading order, with
* dedupe: pdf.js re-renders (zoom settle, scale updates) append a
* second span set into the same .textLayer without clearing — char
* boxes come back duplicated, and the copies can sit SUB-PIXEL
* SHIFTED (a doubled-letter text recompute once slipped through an
* exact-position key). Dedupe by overlap instead: a candidate is a
* duplicate when a kept glyph on the same line overlaps more than
* half the narrower glyph. Distinct glyphs never reach that. */
pdfCharBoxes(doc: Document): {
x0: number;
x1: number;
y0: number;
y1: number;
ch: string;
}[] {
const canvas =
(doc.querySelector("#canvas canvas") as HTMLElement) ||
(doc.querySelector("img") as HTMLElement);
const dr = canvas?.getBoundingClientRect();
if (!dr || !dr.width || !dr.height) return [];
const out: { x0: number; x1: number; y0: number; y1: number; ch: string }[] =
[];
const range = doc.createRange();
const spans = doc.querySelectorAll(".textLayer span");
spans.forEach((span) => {
const node = span.firstChild;
if (!node || node.nodeType !== Node.TEXT_NODE) return;
const text = node.nodeValue ?? "";
for (let i = 0; i < text.length; i++) {
if (/\s/.test(text[i])) continue;
range.setStart(node, i);
range.setEnd(node, i + 1);
const r = range.getBoundingClientRect();
if (!r.width && !r.height) continue;
const box = {
x0: (r.left - dr.left) / dr.width,
x1: (r.right - dr.left) / dr.width,
y0: (r.top - dr.top) / dr.height,
y1: (r.bottom - dr.top) / dr.height,
ch: text[i],
};
if (box.x1 <= 0 || box.x0 >= 1 || box.y1 <= 0 || box.y0 >= 1) continue;
const w = box.x1 - box.x0;
const h = box.y1 - box.y0;
const dup = out.some((k) => {
// A duplicate is the SAME glyph re-rendered at a drifted
// position: y within 0.75 of the SHORTER box (per-char boxes
// are LINE-HEIGHT boxes — a full-height tolerance let a
// line-1 char eat the line-2 char beneath it in the same
// x-column), x overlapping more than half the narrower box.
// The staircase of offset re-render copies steps by ~0.004 —
// well inside this tolerance; real inter-line gaps (~0.0196)
// are outside it.
const kh = k.y1 - k.y0;
const tolY = Math.min(kh, h) * 0.75;
if (Math.abs(k.y0 - box.y0) > tolY) return false;
const kw = k.x1 - k.x0;
const overlap = Math.min(k.x1, box.x1) - Math.max(k.x0, box.x0);
return overlap > Math.min(kw, w) * 0.5;
});
if (dup) continue;
out.push(box);
}
});
out.sort((a, b) => (a.y0 + a.y1) / 2 - (b.y0 + b.y1) / 2 || a.x0 - b.x0);
return out;
},
/** Enter PDF rect-edge adjust mode from the edit popover: hide it,
* build the page's character table (once — every drag reads the
* cached table in pure fraction space; no DOM reads, no
* coordinate-space hazards — portable to any browser engine), map
* the stored rects to a word... char range, put the two endpoint
* handles up, and arm the cancel paths. */
startPdfAdjust() {
const p = this.selectionPopover;
if (p.pdfPage < 0 || !p.id || !p.pdfRects.length) return;
const map = this.pdfPageMapping(p.pdfPage);
if (!map) return;
const chars = this.pdfCharBoxes(map.doc);
if (!chars.length) return;
// The extent's char range: chars whose centers sit inside the
// stored rects (creation- and adjust-made extents both cover their
// own glyphs).
let startI = -1;
let endI = -1;
chars.forEach((c, i) => {
const cx = (c.x0 + c.x1) / 2;
const cy = (c.y0 + c.y1) / 2;
if (
p.pdfRects.some(
(r) =>
cx >= r[0] &&
cx <= r[0] + r[2] &&
cy >= r[1] &&
cy <= r[1] + r[3],
)
) {
if (startI < 0) startI = i;
endI = i;
}
});
if (startI < 0) return;
this.pdfAdjust = {
id: p.id,
index: p.pdfPage,
page: p.pdfPage,
color: p.color,
rects: p.pdfRects.map((r) => [...r]),
original: p.pdfRects.map((r) => [...r]),
chars,
startI,
endI,
committed: false,
};
this.hideSelectionPopover();
const layer = this.ensureAdjustLayer();
layer.innerHTML = "";
this.renderPdfAdjustHandles();
// A page turn ends the session safely — the preview is discarded,
// nothing saved (same contract as the reflowable adjust).
this.pdfAdjustOnRelocate = () => this.cancelPdfAdjust();
this.renderer?.addEventListener("relocate", this.pdfAdjustOnRelocate, {
once: true,
});
// Zoom re-renders the page — re-derive the handles from live rects.
this.pdfAdjustOnZoom = () => this.renderPdfAdjustHandles();
this.renderer?.addEventListener("zoom", this.pdfAdjustOnZoom);
},
/** (Re)draw the two endpoint handles, positioned through the live
* page mapping. The dots are created ONCE per session — every later
* call only repositions the existing elements (an innerHTML rebuild
* mid-drag destroys the dot being dragged; it loses pointer capture
* and the drag dies silently — user report). */
renderPdfAdjustHandles() {
const ctx = this.pdfAdjust;
if (!ctx) return;
const layer = document.getElementById("adjust-handles");
if (!layer) return;
const map = this.pdfPageMapping(ctx.index);
if (!map) return;
const first = ctx.rects[0];
const last = ctx.rects[ctx.rects.length - 1];
if (!first || !last) return;
// Start dot floating ABOVE the first rect's top-left corner, end
// dot above the last rect's top-right corner — the teardrop tip
// points down at the boundary so the highlighted text underneath
// stays readable (user request: the handle covered the text).
const pts: Array<{ which: "start" | "end"; fx: number; fy: number }> = [
{ which: "start", fx: first[0], fy: first[1] },
{ which: "end", fx: last[0] + last[2], fy: last[1] },
];
for (const pt of pts) {
let dot = layer.querySelector<HTMLDivElement>(
`.adjust-handle[data-which="${pt.which}"]`,
);
if (!dot) {
dot = document.createElement("div");
dot.className = "adjust-handle";
dot.dataset.which = pt.which;
const which = pt.which;
// Pointer events (mouse + touch + pen); the page's own touch
// handlers are not engaged because the dots live in the HOST
// document, above the iframe.
dot.addEventListener("pointerdown", (e) => {
e.preventDefault();
try {
dot!.setPointerCapture(e.pointerId);
} catch {}
dot!.dataset.dragging = "1";
});
dot.addEventListener("pointermove", (e) => {
if (!dot!.dataset.dragging) return;
// Aim at the TIP (finger + ~15px, the body-center-to-tip
// offset): the body floats above the text, the tip shows
// where the boundary actually is.
this.pdfAdjustHandleMove(which, e.clientX, e.clientY + 15);
});
dot.addEventListener("pointerup", (e) => {
if (!dot!.dataset.dragging) return;
delete dot!.dataset.dragging;
try {
dot!.releasePointerCapture?.(e.pointerId);
} catch {}
this.pdfAdjustRelease();
});
dot.addEventListener("pointercancel", () => {
delete dot!.dataset.dragging;
});
layer.appendChild(dot);
}
const host = map.toHost(pt.fx, pt.fy);
// The teardrop's rotated tip is its bottom point: hang the box
// ABOVE the boundary corner, tip resting ~4px above the glyph
// tops — a clear gap so the tip never covers letters (Android
// handle spacing).
dot.style.left = `${host.x - 13}px`;
dot.style.top = `${host.y - 30}px`;
}
},
/** Line boxes (page fractions) of a contiguous char-table span:
* cluster the glyphs by center-y. Per-char boxes are LINE-HEIGHT
* boxes (~one line gap tall), so the break threshold must sit
* clearly below the inter-line gap yet above intra-line baseline
* variance — 0.6 of the char's own height does that (gap ≈ 1.0 ×
* height breaks; variance ≈ 0.1 × height stays). */
charSpanRects(
chars: { x0: number; x1: number; y0: number; y1: number }[],
): number[][] {
const rects: number[][] = [];
let line: typeof chars = [];
let lastCy = -9;
for (const c of chars) {
const cy = (c.y0 + c.y1) / 2;
const tol = (c.y1 - c.y0) * 0.6;
if (line.length && Math.abs(cy - lastCy) > tol) {
const x0 = Math.min(...line.map((w) => w.x0));
const y0 = Math.min(...line.map((w) => w.y0));
const x1 = Math.max(...line.map((w) => w.x1));
const y1 = Math.max(...line.map((w) => w.y1));
rects.push([x0, y0, x1 - x0, y1 - y0]);
line = [];
}
line.push(c);
lastCy = cy;
}
if (line.length) {
const x0 = Math.min(...line.map((w) => w.x0));
const y0 = Math.min(...line.map((w) => w.y0));
const x1 = Math.max(...line.map((w) => w.x1));
const y1 = Math.max(...line.map((w) => w.y1));
rects.push([x0, y0, x1 - x0, y1 - y0]);
}
return rects;
},
/** Endpoint drag: the finger (HOST px) maps to page fractions via
* the live mapping, then re-anchors the grabbed end at the nearest
* glyph in the cached char table — character-precise (a trailing
* period or line-break hyphen can be taken or shed), crossing
* lines freely — and the rects rebuild from the new char span as
* per-line unions. Pure fraction space after the mapping: no
* caretRangeFromPoint, no coordinate-space conversion, works on
* any browser engine (Firefox included). */
pdfAdjustHandleMove(
which: "start" | "end",
clientX: number,
clientY: number,
) {
const ctx = this.pdfAdjust;
if (!ctx) return;
const map = this.pdfPageMapping(ctx.index);
if (!map) return;
const { fx, fy } = map.toFraction(clientX, clientY);
const chars = ctx.chars;
if (!chars.length) return;
// Full 2D nearest glyph: the tip crosses lines freely (up/down)
// and moves along them (side to side). No line-band pin — the
// lifted handle's finger sits above the text, and a y-band filter
// here made vertical drags impossible (user report). Sliver rects
// stay impossible: the extent is a contiguous char span and the
// rects are its per-line unions.
let best = 0;
let bestD = Infinity;
for (let i = 0; i < chars.length; i++) {
const c = chars[i];
const cx = (c.x0 + c.x1) / 2;
const cy = (c.y0 + c.y1) / 2;
const d = (cx - fx) * (cx - fx) + (cy - fy) * (cy - fy);
if (d < bestD) {
bestD = d;
best = i;
}
}
let s = ctx.startI;
let e = ctx.endI;
if (which === "start") s = Math.min(best, e);
else e = Math.max(best, s);
if (s > e) return;
ctx.startI = s;
ctx.endI = e;
ctx.rects = this.charSpanRects(chars.slice(s, e + 1));
this.schedulePdfAdjustPreview();
},
/** Preview scheduling: coalesce pointermove bursts into one overlay
* repaint per frame. The preview replaces the highlight's OWN
* overlay entry (addRectAnnotation is replace-by-key), so there is
* never a double paint and the save re-adds the same key. */
schedulePdfAdjustPreview() {
if (this.pdfAdjustRaf) return;
this.pdfAdjustRaf = requestAnimationFrame(() => {
this.pdfAdjustRaf = 0;
const ctx = this.pdfAdjust;
if (!ctx) return;
this.renderer?.addRectAnnotation?.({
key: ctx.id,
index: ctx.index,
rects: ctx.rects,
color: ctx.color,
});
this.renderPdfAdjustHandles();
});
},
/** Release: the extent's chars ARE the text — join them (line gaps
* re-insert spaces, matching the creation path's normalized
* selection text) and save through the popover's own PUT path. */
async pdfAdjustRelease() {
const ctx = this.pdfAdjust;
if (!ctx) return;
const chars = ctx.chars.slice(ctx.startI, ctx.endI + 1);
let text = "";
let prev: (typeof chars)[number] | null = null;
for (const c of chars) {
if (prev) {
const pc = (prev.y0 + prev.y1) / 2;
const cc = (c.y0 + c.y1) / 2;
const half = (prev.y1 - prev.y0 + (c.y1 - c.y0)) / 4;
if (Math.abs(cc - pc) > half) text += " ";
else if (c.x0 - prev.x1 > 0.004) text += " ";
}
text += c.ch;
prev = c;
}
text = text.replace(/\s+/g, " ").trim();
const rects = ctx.rects;
const { id, page } = ctx;
ctx.committed = true;
this.cancelPdfAdjust();
if (!text) {
showToast("Highlights can't be empty", "error");
return;
}
const p = this.selectionPopover;
p.id = id;
p.pdfPage = page;
p.pdfRects = rects;
p.text = text;
await this.saveHighlightChanges();
},
/** Leave rect adjust: restore the stored paint unless the adjustment
* was committed (cancel runs after finalize too), and drop the mode. */
cancelPdfAdjust() {
const ctx = this.pdfAdjust;
if (!ctx) return;
if (!ctx.committed) {
this.renderer?.addRectAnnotation?.({
key: ctx.id,
index: ctx.index,
rects: ctx.original,
color: ctx.color,
});
}
this.pdfAdjust = null;
if (this.pdfAdjustRaf) {
cancelAnimationFrame(this.pdfAdjustRaf);
this.pdfAdjustRaf = 0;
}
if (this.pdfAdjustOnRelocate) {
this.renderer?.removeEventListener?.(
"relocate",
this.pdfAdjustOnRelocate,
);
this.pdfAdjustOnRelocate = null;
}
if (this.pdfAdjustOnZoom) {
this.renderer?.removeEventListener?.("zoom", this.pdfAdjustOnZoom);
this.pdfAdjustOnZoom = null;
}
document.getElementById("adjust-handles")?.remove();
},
scheduleSelectionPopover(doc: any, index: number) { scheduleSelectionPopover(doc: any, index: number) {
if (this.selPopTimer) clearTimeout(this.selPopTimer); if (this.selPopTimer) clearTimeout(this.selPopTimer);
this.selPopTimer = setTimeout(() => { this.selPopTimer = setTimeout(() => {
@@ -1617,6 +2069,84 @@ document.addEventListener("alpine:init", () => {
const range = sel.getRangeAt(0); const range = sel.getRangeAt(0);
const text = sel.toString().replace(/\s+/g, " ").trim(); const text = sel.toString().replace(/\s+/g, " ").trim();
if (!text) return; if (!text) return;
// Adjust re-selection (v2): a selection made during a rect-edge
// session REPLACES the working extent — any lines, partial lines
// (the exact gap the handles alone could not express from stale
// geometry). Chars inside the selection's line boxes become the
// new extent; text reads from them; saved immediately: the
// handles' release-to-save parity.
if (this.pdfAdjust) {
const canvasRect =
(doc.querySelector("#canvas canvas") as HTMLElement) ||
(doc.querySelector("img") as HTMLElement);
const dr = canvasRect?.getBoundingClientRect();
if (!dr || !dr.width || !dr.height) return;
const selRects: number[][] = [];
for (const r of range.getClientRects()) {
const x = (r.left - dr.left) / dr.width;
const y = (r.top - dr.top) / dr.height;
const w = r.width / dr.width;
const h = r.height / dr.height;
if (w > 0 && h > 0) selRects.push([x, y, w, h]);
}
if (!selRects.length) return;
const chars = this.pdfAdjust.chars;
const picked: number[] = [];
chars.forEach((c, i) => {
const cx = (c.x0 + c.x1) / 2;
const cy = (c.y0 + c.y1) / 2;
if (
selRects.some(
(r) =>
cx >= r[0] &&
cx <= r[0] + r[2] &&
cy >= r[1] &&
cy <= r[1] + r[3],
)
)
picked.push(i);
});
if (!picked.length) {
showToast("Highlights can't be empty", "error");
return;
}
const session = this.pdfAdjust;
const startI = picked[0];
const endI = picked[picked.length - 1];
const extentRects = this.charSpanRects(chars.slice(startI, endI + 1));
let t = "";
let prev: (typeof chars)[number] | null = null;
for (const c of chars.slice(startI, endI + 1)) {
if (prev) {
const pc = (prev.y0 + prev.y1) / 2;
const cc = (c.y0 + c.y1) / 2;
const half = (prev.y1 - prev.y0 + (c.y1 - c.y0)) / 4;
if (Math.abs(cc - pc) > half) t += " ";
else if (c.x0 - prev.x1 > 0.004) t += " ";
}
t += c.ch;
prev = c;
}
t = t.replace(/\s+/g, " ").trim();
if (!t) {
showToast("Highlights can't be empty", "error");
return;
}
session.committed = true;
this.cancelPdfAdjust();
const p = this.selectionPopover;
p.id = session.id;
p.pdfPage = session.page;
p.pdfRects = extentRects;
p.text = t;
this.saveHighlightChanges();
try {
sel.removeAllRanges();
} catch {
/* doc may be gone */
}
return;
}
// Denominator: the element whose post-transform screen rect IS // Denominator: the element whose post-transform screen rect IS
// the visible page. For PDFs that's the rendered canvas — pdf.js // the visible page. For PDFs that's the rendered canvas — pdf.js
// scales <html> by 1/devicePixelRatio, so documentElement's rect // scales <html> by 1/devicePixelRatio, so documentElement's rect
@@ -1852,41 +2382,86 @@ document.addEventListener("alpine:init", () => {
// converter used to fall back to a document-start CFI when the end // converter used to fall back to a document-start CFI when the end
// xpointer didn't resolve exactly. In both cases the end is derived // xpointer didn't resolve exactly. In both cases the end is derived
// from the start offset advanced by the text's UTF-16 length (EPUB // from the start offset advanced by the text's UTF-16 length (EPUB
// CFI offsets are UTF-16 code units); multi-node selections just fail // CFI offsets are UTF-16 code units).
// resolution harmlessly and fall back to the point CFI. //
// The endpoints may legitimately anchor at different nodes (a
// selection crossing inline spans lands in two text nodes), so the
// range parent is the endpoints' longest shared step prefix — down to
// the bare spine step if needed — and each side emits its divergent
// remainder, per the EPUB CFI range grammar
// epubcfi(parent,start,end).
toRangeCfi(start: string, end: string, text: string): string { toRangeCfi(start: string, end: string, text: string): string {
if (!start) return end || start; if (!start) return end || start;
if (start.includes(",")) return start; // already a range CFI if (start.includes(",")) return start; // already a range CFI
const re = const spineRe =
/^(epubcfi\(\/\d+\/\d+!\/\d+\/\d+(?:\[[^\]]*\])?)(\/(?:[^:)]+)?(?::(\d+))?)\)$/; /^(epubcfi\([^!]*!)((?:\/\d+(?:\[[^\]]*\])?)+)(?::(\d+))?\)$/;
const ms = re.exec(start); const parseCfi = (cfi: string) => {
if (!ms) return start; const m = spineRe.exec(cfi);
const base = ms[1]; if (!m) return null;
const startLocal = ms[2]; const steps = (m[2].match(/\/\d+(?:\[[^\]]*\])?/g) ?? []).map(
const startOff = ms[3] ? parseInt(ms[3], 10) : -1; (s) => s,
);
return { spine: m[1], steps, off: m[3] ? parseInt(m[3], 10) : -1 };
};
const s = parseCfi(start);
if (!s || s.steps.length === 0) return start;
const utf16len = [...(text ?? "")].reduce( const utf16len = [...(text ?? "")].reduce(
(n, c) => n + (c.codePointAt(0)! > 0xffff ? 2 : 1), (n, c) => n + (c.codePointAt(0)! > 0xffff ? 2 : 1),
0, 0,
); );
let endLocal = ""; // Range with the parent one step short of the terminal, so both
if (end && !end.includes(",")) { // subpaths keep at least their terminal step; offsets ride on the
const me = re.exec(end); // subpath terminators.
if (me && me[1] === base) { const rem = (steps: string[], off: number) =>
const endOff = me[3] ? parseInt(me[3], 10) : -1; off >= 0 ? `${steps.join("")}:${off}` : steps.join("");
// Degenerate: end resolves to the document start (the old const advanceEnd = () => {
// converter fallback) or sits before the start offset. if (s.off < 0 || utf16len <= 0) return null;
const degenerate = const last = s.steps[s.steps.length - 1];
endOff === 0 || const parent = s.spine + s.steps.slice(0, -1).join("");
(startOff >= 0 && endOff >= 0 && endOff < startOff); return `${parent},${rem([last], s.off)},${rem([last], s.off + utf16len)})`;
if (!degenerate) endLocal = me[2]; };
const e = end && !end.includes(",") ? parseCfi(end) : null;
if (!e || e.spine !== s.spine || e.steps.length === 0) {
const adv = advanceEnd();
return adv ?? start; // point CFI
}
let common = 0;
const maxCommon = Math.min(s.steps.length, e.steps.length);
while (common < maxCommon && s.steps[common] === e.steps[common]) {
common++;
}
if (common === maxCommon) common = maxCommon - 1;
// Same terminal node only when the single remaining steps are
// EQUAL — one step per side with different step values is a
// legitimate two-node range. Degenerate ends (document-start
// fallback of the old converter, or an end before the start) are
// repaired via the UTF-16 advance.
if (
common === s.steps.length - 1 &&
common === e.steps.length - 1 &&
s.steps[common] === e.steps[common]
) {
const degenerate =
e.off === 0 || (s.off >= 0 && e.off >= 0 && e.off < s.off);
if (degenerate) {
const adv = advanceEnd();
if (adv) return adv;
}
} else {
// Different terminal nodes: require end after start in document
// order; otherwise repair.
const ai = parseInt(s.steps[common].slice(1), 10);
const bi = parseInt(e.steps[common].slice(1), 10);
if (bi < ai) {
const adv = advanceEnd();
return adv ?? start;
} }
} }
if (!endLocal) { const parent = s.spine + s.steps.slice(0, common).join("");
if (startOff < 0 || utf16len <= 0) return start; // point CFI return `${parent},${rem(s.steps.slice(common), s.off)},${rem(
const cut = startLocal.lastIndexOf(":"); e.steps.slice(common),
endLocal = `${startLocal.slice(0, cut)}:${startOff + utf16len}`; e.off,
} )})`;
return `${base},${startLocal},${endLocal})`;
}, },
async refreshAnnotations() { async refreshAnnotations() {
const token = getToken(); const token = getToken();
+23
View File
@@ -972,6 +972,29 @@
cursor: grabbing; cursor: grabbing;
scale: 1.15; scale: 1.15;
} }
/* PDF rect-edge adjust grips (fixed-layout highlights): short edge bars
centered on each rect's edge midpoint. Vertical edges drag in x,
horizontal edges in y — resize only, one rect per drag. */
.adjust-grip {
position: fixed;
width: 10px;
height: 30px;
border-radius: 5px;
background: #3b82f6;
border: 2px solid #ffffff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
pointer-events: auto;
cursor: ew-resize;
touch-action: none;
}
.adjust-grip[data-dir="h"] {
width: 30px;
height: 10px;
cursor: ns-resize;
}
.adjust-grip[data-dragging] {
scale: 1.1;
}
.reader-note-input { .reader-note-input {
width: 100%; width: 100%;
resize: vertical; resize: vertical;
+1 -1
View File
@@ -1,2 +1,2 @@
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated /*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated