Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a36cee4f85 | ||
|
|
14947608be | ||
|
|
03a6040360 | ||
|
|
93a468b435 | ||
|
|
bb3aef517c | ||
|
|
cbdddc095e | ||
|
|
5a0d9e0e17 | ||
|
|
19619ef817 | ||
|
|
a28ae5c88d | ||
|
|
1c209408cc | ||
|
|
93f020f2b5 | ||
|
|
a3ecac3507 | ||
|
|
db3b5b3d62 | ||
|
|
f69e659d94 | ||
|
|
dbeefb395c | ||
|
|
8acaeddda5 | ||
|
|
361fdaab39 | ||
|
|
4d1fb05e49 |
No files matched your search
@@ -79,3 +79,6 @@ web/static/vendor/
|
||||
VALID_TOKEN
|
||||
fresh_token.txt
|
||||
session-*.md
|
||||
|
||||
# Local prod-export scratch: personal annotation/reading data, never commit
|
||||
/export/
|
||||
@@ -93,7 +93,22 @@ func (h *KOReaderHandler) convertHighlightPositions(ec annotationEpub, pos0, pos
|
||||
if pos0 == "" || !ec.convertible() {
|
||||
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)
|
||||
endCFI := ""
|
||||
// A text-search start matched the selection text itself: its extent
|
||||
|
||||
@@ -250,6 +250,21 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
||||
registerWebSocketRoutes(cfg)
|
||||
registerFrontendRoutes(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")
|
||||
|
||||
// Catch-all 404 handler - must be last
|
||||
|
||||
@@ -626,7 +626,12 @@ func flattenDocumentForSearch(body *html.Node) ([]rune, []docRunePos) {
|
||||
lastBlock = block
|
||||
off := 0
|
||||
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
|
||||
} else {
|
||||
if pendingSpace && len(runes) > 0 {
|
||||
@@ -644,9 +649,12 @@ func flattenDocumentForSearch(body *html.Node) ([]rune, []docRunePos) {
|
||||
var walk func(n *html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.TextNode {
|
||||
if strings.TrimSpace(n.Data) != "" {
|
||||
appendNode(n)
|
||||
}
|
||||
// Whitespace-only nodes still participate: a space carried in
|
||||
// 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
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
source LocatorSource,
|
||||
canonicalCFI string,
|
||||
|
||||
@@ -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 & 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 inside a word, zero thin space, zero-width space, and trailing no-break space here.</p>
|
||||
<p id="un-entities">Ampersand &amp;, less &lt;, greater &gt;, apostrophe &#39;, quote &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 "quotes" and 'apostrophes'.</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))
|
||||
@@ -166,7 +166,7 @@ templ Reader(user User, metadata ReaderMetadata) {
|
||||
type="button"
|
||||
class="reader-popover-btn"
|
||||
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>
|
||||
</button>
|
||||
|
||||
@@ -123,7 +123,7 @@ func Reader(user User, metadata ReaderMetadata) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
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 {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+14
-2
@@ -210,8 +210,20 @@ Alpine.data("bookDetail", () => {
|
||||
headers: { Authorization: getAuthHeader() },
|
||||
}),
|
||||
]);
|
||||
const notes = noteResp.ok ? await noteResp.json() : [];
|
||||
const highlights = hlResp.ok ? await hlResp.json() : [];
|
||||
// A partial failure must not read as "no annotations": a failed
|
||||
// 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);
|
||||
this.bannerRows = built.rows;
|
||||
this.bannerSources = built.sources;
|
||||
|
||||
+602
-27
@@ -24,6 +24,12 @@ const HIGHLIGHT_COLORS = [
|
||||
// Alpine reactivity never wraps it and revisiting pages is instant.
|
||||
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}`;
|
||||
|
||||
const FONT_MAP: Record<string, string> = {
|
||||
@@ -496,6 +502,27 @@ document.addEventListener("alpine:init", () => {
|
||||
color: string;
|
||||
},
|
||||
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,
|
||||
progressText: "",
|
||||
progressLabel: "",
|
||||
@@ -691,7 +718,10 @@ document.addEventListener("alpine:init", () => {
|
||||
"show-rect-annotation",
|
||||
(e: any) => {
|
||||
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;
|
||||
this.openSelectionPopover({
|
||||
mode: "edit",
|
||||
@@ -718,6 +748,8 @@ document.addEventListener("alpine:init", () => {
|
||||
}
|
||||
this.view.addEventListener("load", (e: any) => {
|
||||
const { doc, index } = e.detail;
|
||||
if (doc && typeof index === "number")
|
||||
pdfFrameDocs.set(doc, index);
|
||||
this.currentDoc = doc;
|
||||
const link = doc.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
@@ -1521,6 +1553,426 @@ document.addEventListener("alpine:init", () => {
|
||||
}
|
||||
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) {
|
||||
if (this.selPopTimer) clearTimeout(this.selPopTimer);
|
||||
this.selPopTimer = setTimeout(() => {
|
||||
@@ -1617,6 +2069,84 @@ document.addEventListener("alpine:init", () => {
|
||||
const range = sel.getRangeAt(0);
|
||||
const text = sel.toString().replace(/\s+/g, " ").trim();
|
||||
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
|
||||
// the visible page. For PDFs that's the rendered canvas — pdf.js
|
||||
// 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
|
||||
// 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
|
||||
// CFI offsets are UTF-16 code units); multi-node selections just fail
|
||||
// resolution harmlessly and fall back to the point CFI.
|
||||
// CFI offsets are UTF-16 code units).
|
||||
//
|
||||
// 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 {
|
||||
if (!start) return end || start;
|
||||
if (start.includes(",")) return start; // already a range CFI
|
||||
const re =
|
||||
/^(epubcfi\(\/\d+\/\d+!\/\d+\/\d+(?:\[[^\]]*\])?)(\/(?:[^:)]+)?(?::(\d+))?)\)$/;
|
||||
const ms = re.exec(start);
|
||||
if (!ms) return start;
|
||||
const base = ms[1];
|
||||
const startLocal = ms[2];
|
||||
const startOff = ms[3] ? parseInt(ms[3], 10) : -1;
|
||||
const spineRe =
|
||||
/^(epubcfi\([^!]*!)((?:\/\d+(?:\[[^\]]*\])?)+)(?::(\d+))?\)$/;
|
||||
const parseCfi = (cfi: string) => {
|
||||
const m = spineRe.exec(cfi);
|
||||
if (!m) return null;
|
||||
const steps = (m[2].match(/\/\d+(?:\[[^\]]*\])?/g) ?? []).map(
|
||||
(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(
|
||||
(n, c) => n + (c.codePointAt(0)! > 0xffff ? 2 : 1),
|
||||
0,
|
||||
);
|
||||
let endLocal = "";
|
||||
if (end && !end.includes(",")) {
|
||||
const me = re.exec(end);
|
||||
if (me && me[1] === base) {
|
||||
const endOff = me[3] ? parseInt(me[3], 10) : -1;
|
||||
// Degenerate: end resolves to the document start (the old
|
||||
// converter fallback) or sits before the start offset.
|
||||
const degenerate =
|
||||
endOff === 0 ||
|
||||
(startOff >= 0 && endOff >= 0 && endOff < startOff);
|
||||
if (!degenerate) endLocal = me[2];
|
||||
// Range with the parent one step short of the terminal, so both
|
||||
// subpaths keep at least their terminal step; offsets ride on the
|
||||
// subpath terminators.
|
||||
const rem = (steps: string[], off: number) =>
|
||||
off >= 0 ? `${steps.join("")}:${off}` : steps.join("");
|
||||
const advanceEnd = () => {
|
||||
if (s.off < 0 || utf16len <= 0) return null;
|
||||
const last = s.steps[s.steps.length - 1];
|
||||
const parent = s.spine + s.steps.slice(0, -1).join("");
|
||||
return `${parent},${rem([last], s.off)},${rem([last], s.off + utf16len)})`;
|
||||
};
|
||||
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) {
|
||||
if (startOff < 0 || utf16len <= 0) return start; // point CFI
|
||||
const cut = startLocal.lastIndexOf(":");
|
||||
endLocal = `${startLocal.slice(0, cut)}:${startOff + utf16len}`;
|
||||
}
|
||||
return `${base},${startLocal},${endLocal})`;
|
||||
const parent = s.spine + s.steps.slice(0, common).join("");
|
||||
return `${parent},${rem(s.steps.slice(common), s.off)},${rem(
|
||||
e.steps.slice(common),
|
||||
e.off,
|
||||
)})`;
|
||||
},
|
||||
async refreshAnnotations() {
|
||||
const token = getToken();
|
||||
|
||||
@@ -972,6 +972,29 @@
|
||||
cursor: grabbing;
|
||||
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 {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
|
||||
@@ -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: }
|
||||
/*! 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
|
||||
Reference in new issue
Block a user