Release / build-and-push (push) Successful in 2m57s
countTextCharsBefore started its sibling walk at the target node itself and recursed after counting the current node's subtree, so every ancestor level re-counted everything accumulated so far — a node at depth 4 in the single-document 1984 epub reported 2.38M chars before it in a 589k-char document, producing healed percentages of 4.04 (>1) and a 500 on the reading_progress percentage check constraint. Every healed progress save from a fresh client failed; only exact-context matches (no heal) stored. The walk now starts at target.PrevSibling: strictly the characters before the node, per the contract all three callers already assume (healed book offsets, CRE convert percentage, kepub offsets). Found by the real-phone validation pass: the phone's first healed submission 500ed where the emulator's had matched context exactly and never taken the heal path.
1853 lines
46 KiB
Go
1853 lines
46 KiB
Go
package sync
|
|
|
|
import (
|
|
"archive/zip"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/url"
|
|
"path"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"golang.org/x/net/html"
|
|
)
|
|
|
|
type CFIConverter struct {
|
|
epubPath string
|
|
cache *spineCache
|
|
// mu guards the lazily-built spine/doc caches: converter instances are
|
|
// shared across concurrent requests via the package cache in locators.go.
|
|
mu sync.Mutex
|
|
}
|
|
|
|
type spineItem struct {
|
|
href string
|
|
mediaType string
|
|
}
|
|
|
|
type spineCache struct {
|
|
items []spineItem
|
|
opfDir string
|
|
docCache map[string]*html.Node
|
|
}
|
|
|
|
func NewCFIConverter(epubPath string) *CFIConverter {
|
|
return &CFIConverter{epubPath: epubPath}
|
|
}
|
|
|
|
func (c *CFIConverter) loadSpine() (*spineCache, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.cache != nil {
|
|
return c.cache, nil
|
|
}
|
|
|
|
r, err := zip.OpenReader(c.epubPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open epub: %w", err)
|
|
}
|
|
defer r.Close()
|
|
|
|
containerData, err := readZipFile(&r.Reader, "META-INF/container.xml")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read container.xml: %w", err)
|
|
}
|
|
|
|
opfPath, err := extractOPFPath(containerData)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extract opf path: %w", err)
|
|
}
|
|
|
|
opfData, err := readZipFile(&r.Reader, opfPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read opf: %w", err)
|
|
}
|
|
|
|
items, err := parseOPFSpine(opfData)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse opf spine: %w", err)
|
|
}
|
|
|
|
opfDir := path.Dir(opfPath)
|
|
if opfDir == "." {
|
|
opfDir = ""
|
|
}
|
|
|
|
c.cache = &spineCache{
|
|
items: items,
|
|
opfDir: opfDir,
|
|
docCache: make(map[string]*html.Node),
|
|
}
|
|
return c.cache, nil
|
|
}
|
|
|
|
func (c *CFIConverter) getContentDoc(fragmentIndex int) (*html.Node, string, error) {
|
|
spine, err := c.loadSpine()
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
spineIndex := fragmentIndex - 1
|
|
if spineIndex < 0 || spineIndex >= len(spine.items) {
|
|
return nil, "", fmt.Errorf("fragment index %d out of range (spine has %d items)", fragmentIndex, len(spine.items))
|
|
}
|
|
|
|
item := spine.items[spineIndex]
|
|
href := item.href
|
|
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if cached, ok := spine.docCache[href]; ok {
|
|
return cached, href, nil
|
|
}
|
|
|
|
r, err := zip.OpenReader(c.epubPath)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("open epub: %w", err)
|
|
}
|
|
defer r.Close()
|
|
|
|
zipPath := resolveZipPath(spine.opfDir, href)
|
|
data, err := readZipFile(&r.Reader, zipPath)
|
|
if err != nil {
|
|
decoded, _ := url.QueryUnescape(zipPath)
|
|
if decoded != zipPath {
|
|
data, err = readZipFile(&r.Reader, decoded)
|
|
}
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("read content doc %s: %w", href, err)
|
|
}
|
|
}
|
|
|
|
doc, err := html.Parse(strings.NewReader(preprocessXHTML(string(data))))
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("parse html: %w", err)
|
|
}
|
|
|
|
spine.docCache[href] = doc
|
|
return doc, href, nil
|
|
}
|
|
|
|
type CREXPointer struct {
|
|
FragmentIndex int
|
|
ElementPath []pathStep
|
|
CharOffset int
|
|
}
|
|
|
|
type pathStep struct {
|
|
tag string
|
|
index int
|
|
isText bool
|
|
}
|
|
|
|
type CREFragmentID struct {
|
|
SpineIndex int
|
|
Anchor string
|
|
}
|
|
|
|
var creFragmentIDRe = regexp.MustCompile(`^#_doc_fragment_(\d+)(?:[_ ](.*))?$`)
|
|
|
|
func ParseCREFragmentID(s string) (*CREFragmentID, error) {
|
|
m := creFragmentIDRe.FindStringSubmatch(s)
|
|
if m == nil {
|
|
return nil, fmt.Errorf("not a CREngine fragment ID: %s", s)
|
|
}
|
|
idx, err := strconv.Atoi(m[1])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse fragment spine index: %w", err)
|
|
}
|
|
return &CREFragmentID{
|
|
SpineIndex: idx,
|
|
Anchor: strings.TrimSpace(m[2]),
|
|
}, nil
|
|
}
|
|
|
|
var creXPointerRe = regexp.MustCompile(`^/body/DocFragment\[(\d+)\](.*)`)
|
|
|
|
func ParseCREXPointer(xp string) (*CREXPointer, error) {
|
|
m := creXPointerRe.FindStringSubmatch(xp)
|
|
if m == nil {
|
|
return nil, fmt.Errorf("not a CREngine XPointer: %s", xp)
|
|
}
|
|
|
|
fragIdx, err := strconv.Atoi(m[1])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse fragment index: %w", err)
|
|
}
|
|
|
|
result := &CREXPointer{
|
|
FragmentIndex: fragIdx,
|
|
CharOffset: 0,
|
|
}
|
|
|
|
rest := m[2]
|
|
if rest == "" {
|
|
return result, nil
|
|
}
|
|
|
|
if strings.HasPrefix(rest, "/body") {
|
|
rest = strings.TrimPrefix(rest, "/body")
|
|
if rest == "" {
|
|
return result, nil
|
|
}
|
|
}
|
|
rest = strings.TrimPrefix(rest, "/")
|
|
|
|
if rest == "" {
|
|
return result, nil
|
|
}
|
|
|
|
parts := strings.Split(rest, "/")
|
|
for _, part := range parts {
|
|
if part == "" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(part, "text()") {
|
|
rest := part[6:]
|
|
offset := 0
|
|
if rest != "" {
|
|
if idx := strings.Index(rest, "."); idx >= 0 {
|
|
offset, _ = strconv.Atoi(rest[idx+1:])
|
|
}
|
|
}
|
|
result.CharOffset = offset
|
|
continue
|
|
}
|
|
|
|
tag, idx := parseElementPart(part)
|
|
result.ElementPath = append(result.ElementPath, pathStep{tag: tag, index: idx})
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func parseElementPart(part string) (string, int) {
|
|
idxStr := ""
|
|
if i := strings.Index(part, "["); i >= 0 {
|
|
idxStr = part[i+1:]
|
|
part = part[:i]
|
|
if strings.HasSuffix(idxStr, "]") {
|
|
idxStr = idxStr[:len(idxStr)-1]
|
|
}
|
|
}
|
|
idx := 1
|
|
if idxStr != "" {
|
|
if v, err := strconv.Atoi(idxStr); err == nil {
|
|
idx = v
|
|
}
|
|
}
|
|
return part, idx
|
|
}
|
|
|
|
type ConversionResult struct {
|
|
EPUBCFI string
|
|
// EndEPUBCFI is set when the conversion matched a context that is a
|
|
// quote of the document (text search): the range end anchor of the
|
|
// quoted text, valid across block boundaries. Selections use it as
|
|
// the highlight end; point positions ignore it.
|
|
EndEPUBCFI string
|
|
Href string
|
|
Percentage float64
|
|
Precision string
|
|
}
|
|
|
|
// SectionPercentage derives an approximate book-wide percentage for a CRE
|
|
// xpointer from the char distribution across the spine: the midpoint of the
|
|
// document it points into. Precision is per-section, which is what
|
|
// percentage_start is used for (ordering/filtering) — and it lets thin
|
|
// clients skip their own per-annotation page lookups entirely.
|
|
func (c *CFIConverter) SectionPercentage(xpointer string) float64 {
|
|
xp, err := ParseCREXPointer(xpointer)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
spine, err := c.loadSpine()
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
total := 0
|
|
charCounts := make([]int, len(spine.items))
|
|
for i := range spine.items {
|
|
doc, _, docErr := c.getContentDoc(i + 1)
|
|
if docErr != nil {
|
|
continue
|
|
}
|
|
if b := findBody(doc); b != nil {
|
|
charCounts[i] = countTextChars(b)
|
|
total += charCounts[i]
|
|
}
|
|
}
|
|
if total <= 0 {
|
|
return 0
|
|
}
|
|
idx := xp.FragmentIndex - 1
|
|
if idx < 0 || idx >= len(spine.items) {
|
|
return 0
|
|
}
|
|
before := 0
|
|
for i := 0; i < idx; i++ {
|
|
before += charCounts[i]
|
|
}
|
|
return (float64(before) + float64(charCounts[idx])/2) / float64(total)
|
|
}
|
|
|
|
func (c *CFIConverter) ConvertCREToStandard(xpointer string, storedPercentage float64, contextText string) (*ConversionResult, error) {
|
|
if IsCREFragmentID(xpointer) {
|
|
return c.convertFragmentID(xpointer, storedPercentage)
|
|
}
|
|
|
|
xp, err := ParseCREXPointer(xpointer)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
spine, err := c.loadSpine()
|
|
if err != nil {
|
|
return &ConversionResult{
|
|
Percentage: storedPercentage,
|
|
Precision: "percentage",
|
|
}, nil
|
|
}
|
|
|
|
doc, href, docErr := c.getContentDoc(xp.FragmentIndex)
|
|
if docErr != nil {
|
|
return &ConversionResult{
|
|
Percentage: storedPercentage,
|
|
Precision: "percentage",
|
|
}, nil
|
|
}
|
|
body := findBody(doc)
|
|
if body == nil {
|
|
return &ConversionResult{
|
|
Percentage: storedPercentage,
|
|
Precision: "percentage",
|
|
}, nil
|
|
}
|
|
|
|
// Primary: structural walk of the CRE element path. This is exact when
|
|
// the live CRE DOM still lines up with the raw XHTML (same-tag indices,
|
|
// see buildCREXPointer for the counting rule). Text is the final
|
|
// verification, not a fallback: a usable context that disagrees with
|
|
// the structural landing rejects it so text search gets a chance.
|
|
if len(xp.ElementPath) > 0 {
|
|
if result := c.convertByStructuralPath(body, xp, spine, href, storedPercentage, contextText); result != nil {
|
|
return result, nil
|
|
}
|
|
}
|
|
|
|
if usable, normalized := usableContextText(contextText); usable {
|
|
result := c.convertByTextSearch(body, xp, spine, href, storedPercentage, normalized)
|
|
if result != nil {
|
|
return result, nil
|
|
}
|
|
} else if normalizeWhitespace(contextText) != "" {
|
|
log.Printf("Bookhoard: ignoring too-short context %q for text search in %s (structural missed)", contextText, href)
|
|
}
|
|
|
|
return c.convertByPercentageOffset(body, xp, spine, href, storedPercentage)
|
|
}
|
|
|
|
// usableContextText reports whether a device context string is distinctive
|
|
// enough to claim an "exact" text-search hit. Single characters from split
|
|
// inline markup (e.g. drop-cap <span>C</span>onvergence) must never qualify:
|
|
// findTextInNode would match the first "C" in the chapter (doc start).
|
|
// Callers treat unusable as empty and fall through to the next strategy.
|
|
func usableContextText(s string) (bool, string) {
|
|
normalized := normalizeWhitespace(s)
|
|
if normalized == "" {
|
|
return false, ""
|
|
}
|
|
if utf8.RuneCountInString(normalized) < 8 {
|
|
return false, ""
|
|
}
|
|
if len(strings.Fields(normalized)) < 2 {
|
|
return false, ""
|
|
}
|
|
return true, normalized
|
|
}
|
|
|
|
// resolveCREElementPath walks a parsed CRE element path against the raw
|
|
// XHTML body. Indexing mirrors buildCREXPointer: 1-based nth element with
|
|
// the same tag among element siblings (case-insensitive). Returns nil when
|
|
// the live CRE DOM has drifted too far from disk (splits, injected wrappers)
|
|
// so callers can fall through instead of landing on the wrong node.
|
|
func resolveCREElementPath(body *html.Node, path []pathStep) *html.Node {
|
|
current := body
|
|
for _, step := range path {
|
|
if step.tag == "" {
|
|
continue
|
|
}
|
|
if strings.EqualFold(step.tag, "body") {
|
|
continue
|
|
}
|
|
var matches []*html.Node
|
|
for child := current.FirstChild; child != nil; child = child.NextSibling {
|
|
if child.Type == html.ElementNode && strings.EqualFold(child.Data, step.tag) {
|
|
matches = append(matches, child)
|
|
}
|
|
}
|
|
if len(matches) == 0 {
|
|
return nil
|
|
}
|
|
idx := step.index - 1
|
|
if idx < 0 || idx >= len(matches) {
|
|
return nil
|
|
}
|
|
current = matches[idx]
|
|
}
|
|
if current == body {
|
|
return nil
|
|
}
|
|
return current
|
|
}
|
|
|
|
// firstTextDescendant returns the first text node with non-whitespace content
|
|
// under n (document order), for structural landings on elements.
|
|
func firstTextDescendant(n *html.Node) *html.Node {
|
|
var found *html.Node
|
|
var walk func(*html.Node) bool
|
|
walk = func(node *html.Node) bool {
|
|
if node.Type == html.TextNode && strings.TrimSpace(node.Data) != "" {
|
|
found = node
|
|
return true
|
|
}
|
|
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
|
if walk(child) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
walk(n)
|
|
return found
|
|
}
|
|
|
|
// textNodeAtRuneOffset walks text nodes under elem in document order and
|
|
// returns the node containing the rune offset plus the local offset within
|
|
// that node. Offsets beyond the end clamp to the last node.
|
|
// textNodeAtUTF16Offset resolves a crengine text().N offset — UTF-16 code
|
|
// units — to (text node, rune offset) within elem's text.
|
|
func textNodeAtUTF16Offset(elem *html.Node, offset int) (*html.Node, int) {
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
var target *html.Node
|
|
local := 0
|
|
remaining := offset
|
|
var walk func(*html.Node) bool
|
|
walk = func(node *html.Node) bool {
|
|
if node.Type == html.TextNode {
|
|
length := utf16Len(node.Data)
|
|
if remaining < length {
|
|
target = node
|
|
local = utf16ToRuneIndex(node.Data, remaining)
|
|
return true
|
|
}
|
|
remaining -= length
|
|
target = node
|
|
local = utf8.RuneCountInString(node.Data)
|
|
return false
|
|
}
|
|
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
|
if walk(child) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
walk(elem)
|
|
return target, local
|
|
}
|
|
|
|
// blockFlattenedText returns the flattened visible text of the nearest
|
|
// non-inline block ancestor (span/a/em/etc. are transparent), so drop-cap
|
|
// splits like <span>C</span>onvergence verify against the full words.
|
|
func blockFlattenedText(textNode *html.Node) string {
|
|
block := findBlockParent(textNode)
|
|
if block == nil {
|
|
if textNode.Parent != nil {
|
|
block = textNode.Parent
|
|
} else {
|
|
return strings.TrimSpace(textNode.Data)
|
|
}
|
|
}
|
|
segments := collectInlineText(block)
|
|
var runes []rune
|
|
for _, seg := range segments {
|
|
runes = append(runes, seg.runes...)
|
|
}
|
|
// Include leading direct text children the inline walk skips.
|
|
var direct []rune
|
|
for child := block.FirstChild; child != nil; child = child.NextSibling {
|
|
if child.Type == html.TextNode {
|
|
direct = append(direct, []rune(child.Data)...)
|
|
}
|
|
}
|
|
combined := string(direct)
|
|
if flattened := string(runes); flattened != "" {
|
|
if combined != "" {
|
|
combined += " " + flattened
|
|
} else {
|
|
combined = flattened
|
|
}
|
|
}
|
|
return normalizeWhitespace(combined)
|
|
}
|
|
|
|
func (c *CFIConverter) convertByStructuralPath(body *html.Node, xp *CREXPointer, spine *spineCache, href string, storedPercentage float64, contextText string) *ConversionResult {
|
|
elem := resolveCREElementPath(body, xp.ElementPath)
|
|
if elem == nil {
|
|
return nil
|
|
}
|
|
|
|
var textNode *html.Node
|
|
var localOffset int
|
|
if xp.CharOffset > 0 {
|
|
textNode, localOffset = textNodeAtUTF16Offset(elem, xp.CharOffset)
|
|
} else {
|
|
textNode = firstTextDescendant(elem)
|
|
localOffset = 0
|
|
}
|
|
if textNode == nil {
|
|
return nil
|
|
}
|
|
|
|
// Text is the final verification, in quote form: a usable context must
|
|
// be a prefix of the document as read forward from this landing point.
|
|
// Device captures are exactly that shape — a position's context is the
|
|
// text from the position onward (the plugin's walk-up may concatenate
|
|
// the heading with the paragraphs below), and a selection is the
|
|
// document text between its two anchors. The single-block containment
|
|
// checks stay as secondary acceptance for reverse shapes.
|
|
if usable, normalized := usableContextText(contextText); usable {
|
|
doc := documentTextFrom(body, textNode, localOffset, utf8.RuneCountInString(normalized)+64)
|
|
flat := blockFlattenedText(textNode)
|
|
if !(strings.HasPrefix(doc, normalized) ||
|
|
strings.Contains(flat, normalized) ||
|
|
strings.Contains(normalized, flat)) {
|
|
log.Printf("Bookhoard: structural landing disagrees with context in %s (reads %q vs ctx %q)",
|
|
href, truncateForLog(doc, 80), truncateForLog(normalized, 80))
|
|
return nil
|
|
}
|
|
}
|
|
|
|
spineIndex := xp.FragmentIndex - 1
|
|
cfi, err := buildCFI(spineIndex, textNode, localOffset)
|
|
if err != nil || cfi == "" {
|
|
return nil
|
|
}
|
|
log.Printf("Bookhoard: structural match → %s (precision: structural)", cfi)
|
|
return &ConversionResult{
|
|
EPUBCFI: cfi,
|
|
Href: href,
|
|
Percentage: storedPercentage,
|
|
Precision: "structural",
|
|
}
|
|
}
|
|
|
|
func truncateForLog(s string, n int) string {
|
|
if utf8.RuneCountInString(s) <= n {
|
|
return s
|
|
}
|
|
return string([]rune(s)[:n]) + "…"
|
|
}
|
|
|
|
func (c *CFIConverter) convertFragmentID(s string, storedPercentage float64) (*ConversionResult, error) {
|
|
frag, err := ParseCREFragmentID(s)
|
|
if err != nil {
|
|
return &ConversionResult{
|
|
Percentage: storedPercentage,
|
|
Precision: "percentage",
|
|
}, nil
|
|
}
|
|
|
|
spine, err := c.loadSpine()
|
|
if err != nil {
|
|
return &ConversionResult{
|
|
Percentage: storedPercentage,
|
|
Precision: "percentage",
|
|
}, nil
|
|
}
|
|
|
|
if frag.SpineIndex < 0 || frag.SpineIndex >= len(spine.items) {
|
|
return &ConversionResult{
|
|
Percentage: storedPercentage,
|
|
Precision: "percentage",
|
|
}, nil
|
|
}
|
|
|
|
href := spine.items[frag.SpineIndex].href
|
|
if frag.Anchor != "" {
|
|
idHref := href + "#" + frag.Anchor
|
|
return &ConversionResult{
|
|
Href: idHref,
|
|
Percentage: storedPercentage,
|
|
Precision: "element",
|
|
}, nil
|
|
}
|
|
|
|
return &ConversionResult{
|
|
Href: href,
|
|
Percentage: storedPercentage,
|
|
Precision: "section",
|
|
}, nil
|
|
}
|
|
|
|
// docRunePos records which text node (and rune offset within it) produced
|
|
// each rune of the flattened document text.
|
|
type docRunePos struct {
|
|
node *html.Node
|
|
off int
|
|
}
|
|
|
|
// flattenDocumentForSearch renders the document's text as a
|
|
// whitespace-normalized rune stream in document order, crossing block
|
|
// boundaries: a single space separates blocks, while inline spans join
|
|
// directly (drop-cap splits like <span>C</span>onvergence read as one
|
|
// word). Every emitted rune carries its source (node, rune offset) so
|
|
// matches map back to CFIs.
|
|
func flattenDocumentForSearch(body *html.Node) ([]rune, []docRunePos) {
|
|
var runes []rune
|
|
var poss []docRunePos
|
|
pendingSpace := false
|
|
var lastBlock *html.Node
|
|
|
|
appendNode := func(n *html.Node) {
|
|
block := findBlockParent(n)
|
|
if lastBlock != nil && block != lastBlock {
|
|
pendingSpace = true
|
|
}
|
|
lastBlock = block
|
|
off := 0
|
|
for _, r := range n.Data {
|
|
if unicode.IsSpace(r) {
|
|
pendingSpace = true
|
|
} else {
|
|
if pendingSpace && len(runes) > 0 {
|
|
runes = append(runes, ' ')
|
|
poss = append(poss, docRunePos{node: n, off: off})
|
|
}
|
|
pendingSpace = false
|
|
runes = append(runes, r)
|
|
poss = append(poss, docRunePos{node: n, off: off})
|
|
}
|
|
off++
|
|
}
|
|
}
|
|
|
|
var walk func(n *html.Node)
|
|
walk = func(n *html.Node) {
|
|
if n.Type == html.TextNode {
|
|
if strings.TrimSpace(n.Data) != "" {
|
|
appendNode(n)
|
|
}
|
|
return
|
|
}
|
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
walk(c)
|
|
}
|
|
}
|
|
walk(body)
|
|
return runes, poss
|
|
}
|
|
|
|
// documentTextFrom reads the whitespace-normalized document text starting
|
|
// at rune `offset` in `start` (typically a structural landing point),
|
|
// crossing block boundaries — the document "as read" from that position.
|
|
// Capped at maxRunes.
|
|
func documentTextFrom(body *html.Node, start *html.Node, offset, maxRunes int) string {
|
|
runes, poss := flattenDocumentForSearch(body)
|
|
begin := -1
|
|
for i := range poss {
|
|
if poss[i].node == start && poss[i].off >= offset {
|
|
begin = i
|
|
break
|
|
}
|
|
}
|
|
if begin < 0 {
|
|
return ""
|
|
}
|
|
// The separator space before a block's first content rune shares its
|
|
// (node, offset) — skip it so the read starts on real text.
|
|
if runes[begin] == ' ' && begin+1 < len(runes) {
|
|
begin++
|
|
}
|
|
end := len(runes)
|
|
if begin+maxRunes < end {
|
|
end = begin + maxRunes
|
|
}
|
|
return string(runes[begin:end])
|
|
}
|
|
|
|
func (c *CFIConverter) convertByTextSearch(body *html.Node, xp *CREXPointer, spine *spineCache, href string, storedPercentage float64, contextText string) *ConversionResult {
|
|
normalizedCtx := normalizeWhitespace(contextText)
|
|
if normalizedCtx == "" {
|
|
return nil
|
|
}
|
|
|
|
// Match against the whole document flattened in reading order — the
|
|
// context may span block boundaries (a selection covering several
|
|
// paragraphs, a walk-up capture joining a heading with what follows).
|
|
runes, poss := flattenDocumentForSearch(body)
|
|
text := string(runes)
|
|
|
|
words := strings.Fields(normalizedCtx)
|
|
quoted := make([]string, len(words))
|
|
for i, w := range words {
|
|
quoted[i] = regexp.QuoteMeta(w)
|
|
}
|
|
pattern := strings.Join(quoted, `\s+`)
|
|
re, err := regexp.Compile(pattern)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
loc := re.FindStringIndex(text)
|
|
if loc == nil {
|
|
log.Printf("Bookhoard: text search no match for %q in %s", truncateForLog(normalizedCtx, 60), href)
|
|
return nil
|
|
}
|
|
|
|
startRune := utf8.RuneCountInString(text[:loc[0]])
|
|
endRune := utf8.RuneCountInString(text[:loc[1]]) // exclusive
|
|
spineIndex := xp.FragmentIndex - 1
|
|
|
|
startPos := poss[startRune]
|
|
startCFI, err := buildCFI(spineIndex, startPos.node, startPos.off)
|
|
if err != nil || startCFI == "" {
|
|
log.Printf("Bookhoard: text search found match but buildCFI failed: %v", err)
|
|
return nil
|
|
}
|
|
|
|
// The matched context is a quote of the document: its extent gives
|
|
// selections a true range end, across block boundaries.
|
|
endCFI := ""
|
|
if endRune > startRune && endRune <= len(poss) {
|
|
endPos := poss[endRune-1]
|
|
endOff := endPos.off + 1
|
|
if nRunes := utf8.RuneCountInString(endPos.node.Data); endOff > nRunes {
|
|
endOff = nRunes
|
|
}
|
|
if ec, err := buildCFI(spineIndex, endPos.node, endOff); err == nil && ec != "" {
|
|
endCFI = ec
|
|
}
|
|
}
|
|
|
|
log.Printf("Bookhoard: text search matched %q → %s (precision: exact)", truncateForLog(normalizedCtx, 60), startCFI)
|
|
return &ConversionResult{
|
|
EPUBCFI: startCFI,
|
|
EndEPUBCFI: endCFI,
|
|
Href: href,
|
|
Percentage: storedPercentage,
|
|
Precision: "exact",
|
|
}
|
|
}
|
|
|
|
func findTextInNode(root *html.Node, searchText string) (*html.Node, int) {
|
|
normalizedSearch := normalizeWhitespace(searchText)
|
|
if normalizedSearch == "" {
|
|
return nil, 0
|
|
}
|
|
|
|
words := strings.Fields(normalizedSearch)
|
|
quoted := make([]string, len(words))
|
|
for i, w := range words {
|
|
quoted[i] = regexp.QuoteMeta(w)
|
|
}
|
|
pattern := strings.Join(quoted, `\s+`)
|
|
re, err := regexp.Compile(pattern)
|
|
if err != nil {
|
|
return nil, 0
|
|
}
|
|
|
|
var found *html.Node
|
|
foundRuneOffset := 0
|
|
|
|
var walk func(*html.Node) bool
|
|
walk = func(n *html.Node) bool {
|
|
if n.Type == html.TextNode {
|
|
loc := re.FindStringIndex(n.Data)
|
|
if loc != nil {
|
|
found = n
|
|
foundRuneOffset = utf8.RuneCountInString(n.Data[:loc[0]])
|
|
return true
|
|
}
|
|
}
|
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
if walk(c) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
walk(root)
|
|
if found != nil {
|
|
return found, foundRuneOffset
|
|
}
|
|
|
|
return findTextAcrossInlineElements(root, re)
|
|
}
|
|
|
|
func findTextAcrossInlineElements(root *html.Node, re *regexp.Regexp) (*html.Node, int) {
|
|
var blockElements []*html.Node
|
|
collectBlockElements(root, &blockElements)
|
|
|
|
for _, block := range blockElements {
|
|
segments := collectInlineText(block)
|
|
if len(segments) == 0 {
|
|
continue
|
|
}
|
|
|
|
var allRunes []rune
|
|
for _, seg := range segments {
|
|
allRunes = append(allRunes, seg.runes...)
|
|
}
|
|
|
|
flattened := string(allRunes)
|
|
loc := re.FindStringIndex(flattened)
|
|
if loc == nil {
|
|
continue
|
|
}
|
|
|
|
matchStart := utf8.RuneCountInString(flattened[:loc[0]])
|
|
charPos := 0
|
|
for _, seg := range segments {
|
|
if charPos+len(seg.runes) > matchStart {
|
|
localOffset := matchStart - charPos
|
|
return seg.node, localOffset
|
|
}
|
|
charPos += len(seg.runes)
|
|
}
|
|
}
|
|
|
|
return nil, 0
|
|
}
|
|
|
|
func collectBlockElements(n *html.Node, result *[]*html.Node) {
|
|
if n.Type == html.ElementNode && !inlineFormattingElements[n.Data] {
|
|
hasText := false
|
|
var checkText func(*html.Node)
|
|
checkText = func(c *html.Node) {
|
|
if c.Type == html.TextNode && strings.TrimSpace(c.Data) != "" {
|
|
hasText = true
|
|
return
|
|
}
|
|
for child := c.FirstChild; child != nil; child = child.NextSibling {
|
|
checkText(child)
|
|
}
|
|
}
|
|
checkText(n)
|
|
if hasText {
|
|
*result = append(*result, n)
|
|
}
|
|
}
|
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
collectBlockElements(c, result)
|
|
}
|
|
}
|
|
|
|
func normalizeWhitespace(s string) string {
|
|
s = strings.Join(strings.Fields(s), " ")
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
func (c *CFIConverter) convertByPercentageOffset(body *html.Node, xp *CREXPointer, spine *spineCache, href string, storedPercentage float64) (*ConversionResult, error) {
|
|
totalBookChars := 0
|
|
charCounts := make([]int, len(spine.items))
|
|
for i := range spine.items {
|
|
doc, _, docErr := c.getContentDoc(i + 1)
|
|
if docErr != nil {
|
|
continue
|
|
}
|
|
b := findBody(doc)
|
|
if b != nil {
|
|
charCounts[i] = countTextChars(b)
|
|
totalBookChars += charCounts[i]
|
|
}
|
|
}
|
|
|
|
if totalBookChars <= 0 {
|
|
return &ConversionResult{
|
|
Percentage: storedPercentage,
|
|
Precision: "percentage",
|
|
}, nil
|
|
}
|
|
|
|
bookWideOffset := int(storedPercentage * float64(totalBookChars))
|
|
if bookWideOffset >= totalBookChars {
|
|
bookWideOffset = totalBookChars - 1
|
|
}
|
|
|
|
charsBefore := 0
|
|
for i := 0; i < xp.FragmentIndex-1 && i < len(spine.items); i++ {
|
|
charsBefore += charCounts[i]
|
|
}
|
|
|
|
localOffset := bookWideOffset - charsBefore
|
|
totalChars := countTextChars(body)
|
|
if localOffset < 0 {
|
|
localOffset = 0
|
|
}
|
|
if localOffset >= totalChars {
|
|
localOffset = totalChars - 1
|
|
}
|
|
|
|
target, foundOffset := findNodeAtCharOffset(body, localOffset)
|
|
|
|
if target != nil {
|
|
spineIndex := xp.FragmentIndex - 1
|
|
cfi, err := buildCFI(spineIndex, target, localOffset-foundOffset)
|
|
if err == nil && cfi != "" {
|
|
return &ConversionResult{
|
|
EPUBCFI: cfi,
|
|
Href: href,
|
|
Percentage: storedPercentage,
|
|
// This is a char-count estimate, not an exact landing: say
|
|
// so. Callers gate storage on precision, and a guess must
|
|
// never masquerade as an exact anchor.
|
|
Precision: "percentage",
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
idHref := findNearestIDHref(body, target, href)
|
|
if idHref != "" {
|
|
return &ConversionResult{
|
|
Href: idHref,
|
|
Percentage: storedPercentage,
|
|
Precision: "element",
|
|
}, nil
|
|
}
|
|
|
|
return &ConversionResult{
|
|
Href: href,
|
|
Percentage: storedPercentage,
|
|
Precision: "section",
|
|
}, nil
|
|
}
|
|
|
|
func findNodeAtCharOffset(root *html.Node, targetOffset int) (*html.Node, int) {
|
|
currentOffset := 0
|
|
var found *html.Node
|
|
|
|
var walk func(*html.Node) bool
|
|
walk = func(n *html.Node) bool {
|
|
if n.Type == html.TextNode {
|
|
textLen := utf8.RuneCountInString(n.Data)
|
|
if currentOffset+textLen > targetOffset {
|
|
found = n
|
|
return true
|
|
}
|
|
currentOffset += textLen
|
|
return false
|
|
}
|
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
if walk(c) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
walk(root)
|
|
return found, currentOffset
|
|
}
|
|
|
|
func (c *CFIConverter) computePercentage(xp *CREXPointer, target *html.Node, remainingOffset int) float64 {
|
|
spine, err := c.loadSpine()
|
|
if err != nil {
|
|
return -1
|
|
}
|
|
|
|
charOffset := 0
|
|
for i := 1; i < xp.FragmentIndex; i++ {
|
|
doc, _, err := c.getContentDoc(i)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
body := findBody(doc)
|
|
if body != nil {
|
|
charOffset += countTextChars(body)
|
|
}
|
|
}
|
|
|
|
charOffset += countTextCharsBefore(target)
|
|
charOffset += remainingOffset
|
|
|
|
totalChars := int64(0)
|
|
for i := range spine.items {
|
|
doc, _, err := c.getContentDoc(i + 1)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
body := findBody(doc)
|
|
if body != nil {
|
|
totalChars += int64(countTextChars(body))
|
|
}
|
|
}
|
|
|
|
if totalChars <= 0 {
|
|
return -1
|
|
}
|
|
return float64(charOffset) / float64(totalChars)
|
|
}
|
|
|
|
type indexedNode struct {
|
|
node *html.Node
|
|
textChunk []*html.Node
|
|
virtual string
|
|
isNull bool
|
|
}
|
|
|
|
func (n indexedNode) isElement() bool {
|
|
return n.node != nil && n.node.Type == html.ElementNode
|
|
}
|
|
|
|
func (n indexedNode) isTextChunk() bool {
|
|
return len(n.textChunk) > 0
|
|
}
|
|
|
|
func indexChildNodes(parent *html.Node) []indexedNode {
|
|
var children []*html.Node
|
|
for c := parent.FirstChild; c != nil; c = c.NextSibling {
|
|
if c.Type == html.TextNode || c.Type == html.ElementNode {
|
|
children = append(children, c)
|
|
}
|
|
}
|
|
|
|
var nodes []indexedNode
|
|
for _, child := range children {
|
|
if len(nodes) == 0 {
|
|
if child.Type == html.TextNode {
|
|
nodes = append(nodes, indexedNode{textChunk: []*html.Node{child}})
|
|
} else {
|
|
nodes = append(nodes, indexedNode{node: child})
|
|
}
|
|
continue
|
|
}
|
|
|
|
last := nodes[len(nodes)-1]
|
|
if child.Type == html.TextNode {
|
|
if last.isTextChunk() {
|
|
nodes[len(nodes)-1].textChunk = append(last.textChunk, child)
|
|
} else {
|
|
nodes = append(nodes, indexedNode{textChunk: []*html.Node{child}})
|
|
}
|
|
} else {
|
|
if last.isElement() {
|
|
nodes = append(nodes, indexedNode{isNull: true}, indexedNode{node: child})
|
|
} else {
|
|
nodes = append(nodes, indexedNode{node: child})
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(nodes) > 0 && nodes[0].isElement() {
|
|
nodes = append([]indexedNode{{virtual: "first"}}, nodes...)
|
|
}
|
|
if len(nodes) > 0 && nodes[len(nodes)-1].isElement() {
|
|
nodes = append(nodes, indexedNode{virtual: "last"})
|
|
}
|
|
nodes = append([]indexedNode{{virtual: "before"}}, nodes...)
|
|
nodes = append(nodes, indexedNode{virtual: "after"})
|
|
|
|
return nodes
|
|
}
|
|
|
|
// Character-offset currency policy: the EPUB CFI spec and crengine both
|
|
// count UTF-16 code units (JavaScript `.length` semantics — what foliate,
|
|
// readium, KOReader and Kobo clients all observe), so every offset that
|
|
// CROSSES the wire — CFI terminals, CRE text() offsets, the served
|
|
// char_offset handle — is UTF-16. Internal arithmetic (book-level
|
|
// character_offset, percentage fractions) stays rune-based, consistent
|
|
// with TotalCharacters. These helpers convert at the boundaries; for
|
|
// all-BMP text the two currencies are identical, so ASCII books are
|
|
// unaffected.
|
|
|
|
// utf16Len returns the UTF-16 code-unit length of s.
|
|
func utf16Len(s string) int {
|
|
n := 0
|
|
for _, r := range s {
|
|
if r >= 0x10000 {
|
|
n += 2
|
|
} else {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// utf16ToRuneIndex converts a UTF-16 code-unit offset within s to a rune
|
|
// index (clamped to len(runes)).
|
|
func utf16ToRuneIndex(s string, u16 int) int {
|
|
if u16 <= 0 {
|
|
return 0
|
|
}
|
|
units := 0
|
|
i := 0
|
|
for _, r := range s {
|
|
if units >= u16 {
|
|
return i
|
|
}
|
|
if r >= 0x10000 {
|
|
units += 2
|
|
} else {
|
|
units++
|
|
}
|
|
i++
|
|
}
|
|
return i
|
|
}
|
|
|
|
// runeToUTF16Index converts a rune index within s to a UTF-16 code-unit
|
|
// offset (clamped to the string's unit length).
|
|
func runeToUTF16Index(s string, runeIdx int) int {
|
|
if runeIdx <= 0 {
|
|
return 0
|
|
}
|
|
units := 0
|
|
i := 0
|
|
for _, r := range s {
|
|
if i >= runeIdx {
|
|
return units
|
|
}
|
|
if r >= 0x10000 {
|
|
units += 2
|
|
} else {
|
|
units++
|
|
}
|
|
i++
|
|
}
|
|
return units
|
|
}
|
|
|
|
// findTextChunk locates the indexed text chunk containing textNode and
|
|
// returns its chunk index, the rune offset of the node within the chunk,
|
|
// and the chunk text up to and including the node (for UTF-16 conversion
|
|
// of chunk-relative offsets).
|
|
func findTextChunk(parent *html.Node, textNode *html.Node) (int, int, string) {
|
|
indexed := indexChildNodes(parent)
|
|
for i, node := range indexed {
|
|
if node.isTextChunk() {
|
|
var sb strings.Builder
|
|
chunkOffset := 0
|
|
for _, tn := range node.textChunk {
|
|
if tn == textNode {
|
|
return i, chunkOffset, sb.String() + textNode.Data
|
|
}
|
|
chunkOffset += utf8.RuneCountInString(tn.Data)
|
|
sb.WriteString(tn.Data)
|
|
}
|
|
}
|
|
}
|
|
return -1, 0, ""
|
|
}
|
|
|
|
|
|
func findElementCFIIndex(parent *html.Node, element *html.Node) int {
|
|
indexed := indexChildNodes(parent)
|
|
for i, node := range indexed {
|
|
if node.isElement() && node.node == element {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func escapeCFI(s string) string {
|
|
r := strings.NewReplacer(
|
|
"^", "^^",
|
|
"[", "^[",
|
|
"]", "^]",
|
|
"(", "^(",
|
|
")", "^)",
|
|
",", "^,",
|
|
";", "^;",
|
|
"=", "^=",
|
|
)
|
|
return r.Replace(s)
|
|
}
|
|
|
|
func buildCFI(spineIndex int, textNode *html.Node, charOffset int) (string, error) {
|
|
if textNode == nil || textNode.Type != html.TextNode {
|
|
return "", fmt.Errorf("buildCFI requires a text node")
|
|
}
|
|
|
|
parent := textNode.Parent
|
|
if parent == nil {
|
|
return "", fmt.Errorf("text node has no parent")
|
|
}
|
|
|
|
chunkIdx, chunkOffset, chunkText := findTextChunk(parent, textNode)
|
|
if chunkIdx == -1 {
|
|
return "", fmt.Errorf("text node not found in parent's indexed children")
|
|
}
|
|
|
|
// The CFI terminal offset is UTF-16 code units (spec currency); the
|
|
// internal charOffset is runes. Convert over the chunk text.
|
|
totalOffset := chunkOffset + charOffset
|
|
|
|
var parts []string
|
|
parts = append(parts, fmt.Sprintf("/%d:%d", chunkIdx, runeToUTF16Index(chunkText, totalOffset)))
|
|
|
|
current := parent
|
|
for current != nil {
|
|
if current.Type != html.ElementNode {
|
|
current = current.Parent
|
|
continue
|
|
}
|
|
|
|
p := current.Parent
|
|
if p == nil {
|
|
break
|
|
}
|
|
|
|
elemIdx := findElementCFIIndex(p, current)
|
|
if elemIdx == -1 {
|
|
return "", fmt.Errorf("element not found in parent's indexed children")
|
|
}
|
|
|
|
id := getAttr(current, "id")
|
|
step := fmt.Sprintf("/%d", elemIdx)
|
|
if id != "" {
|
|
step = fmt.Sprintf("/%d[%s]", elemIdx, escapeCFI(id))
|
|
}
|
|
parts = append([]string{step}, parts...)
|
|
|
|
body := findBodyFromNode(p)
|
|
if p == body || body == nil {
|
|
break
|
|
}
|
|
current = p
|
|
}
|
|
|
|
parts = append([]string{"/4"}, parts...)
|
|
|
|
spineStep := (spineIndex + 1) * 2
|
|
localPath := strings.Join(parts, "")
|
|
|
|
return fmt.Sprintf("epubcfi(/6/%d!%s)", spineStep, localPath), nil
|
|
}
|
|
|
|
func findNearestIDHref(body *html.Node, target *html.Node, baseHref string) string {
|
|
if target == nil {
|
|
return ""
|
|
}
|
|
|
|
for current := target; current != nil; current = current.Parent {
|
|
if current.Type != html.ElementNode {
|
|
continue
|
|
}
|
|
if id := getAttr(current, "id"); id != "" {
|
|
if strings.Contains(baseHref, "#") {
|
|
return baseHref + id
|
|
}
|
|
return baseHref + "#" + id
|
|
}
|
|
bodyNode := findBodyFromNode(current)
|
|
if current == bodyNode || bodyNode == nil {
|
|
break
|
|
}
|
|
}
|
|
|
|
return baseHref
|
|
}
|
|
|
|
func findHTMLElement(doc *html.Node) *html.Node {
|
|
var find func(*html.Node) *html.Node
|
|
find = func(n *html.Node) *html.Node {
|
|
if n.Type == html.ElementNode && strings.EqualFold(n.Data, "html") {
|
|
return n
|
|
}
|
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
if found := find(c); found != nil {
|
|
return found
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
return find(doc)
|
|
}
|
|
|
|
func findBody(doc *html.Node) *html.Node {
|
|
var find func(*html.Node) *html.Node
|
|
find = func(n *html.Node) *html.Node {
|
|
if n.Type == html.ElementNode && strings.EqualFold(n.Data, "body") {
|
|
return n
|
|
}
|
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
if found := find(c); found != nil {
|
|
return found
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
return find(doc)
|
|
}
|
|
|
|
func findBodyFromNode(n *html.Node) *html.Node {
|
|
for n != nil {
|
|
if n.Type == html.ElementNode && strings.EqualFold(n.Data, "body") {
|
|
return n
|
|
}
|
|
n = n.Parent
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func getAttr(n *html.Node, key string) string {
|
|
for _, a := range n.Attr {
|
|
if strings.EqualFold(a.Key, key) {
|
|
return a.Val
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func countTextChars(n *html.Node) int {
|
|
count := 0
|
|
if n.Type == html.TextNode {
|
|
count += utf8.RuneCountInString(n.Data)
|
|
}
|
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
count += countTextChars(c)
|
|
}
|
|
return count
|
|
}
|
|
|
|
// countTextCharsBefore counts the text characters preceding target within
|
|
// its document: preceding siblings at each ancestor level, strictly
|
|
// EXCLUDING target's own subtree (callers add the in-node offset
|
|
// separately). Starting the walk at target itself — or recursing after
|
|
// counting the current node's subtree — would re-count the accumulated
|
|
// document once per ancestor level and multiply the result by the node's
|
|
// depth.
|
|
func countTextCharsBefore(target *html.Node) int {
|
|
count := 0
|
|
for c := target.PrevSibling; c != nil; c = c.PrevSibling {
|
|
count += countTextChars(c)
|
|
}
|
|
if target.Parent != nil {
|
|
count += countTextCharsBefore(target.Parent)
|
|
}
|
|
return count
|
|
}
|
|
|
|
func resolveZipPath(opfDir, href string) string {
|
|
if opfDir == "" {
|
|
return href
|
|
}
|
|
return path.Join(opfDir, href)
|
|
}
|
|
|
|
func readZipFile(zr *zip.Reader, name string) ([]byte, error) {
|
|
for _, f := range zr.File {
|
|
if f.Name == name {
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rc.Close()
|
|
return io.ReadAll(rc)
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("file not found: %s", name)
|
|
}
|
|
|
|
type opfContainer struct {
|
|
XMLName xml.Name `xml:"container"`
|
|
RootFiles []opfRoot `xml:"rootfiles>rootfile"`
|
|
}
|
|
|
|
type opfRoot struct {
|
|
FullPath string `xml:"full-path,attr"`
|
|
}
|
|
|
|
func extractOPFPath(data []byte) (string, error) {
|
|
var c opfContainer
|
|
if err := xml.Unmarshal(data, &c); err != nil {
|
|
return "", err
|
|
}
|
|
for _, rf := range c.RootFiles {
|
|
if rf.FullPath != "" {
|
|
return rf.FullPath, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("no rootfile found in container.xml")
|
|
}
|
|
|
|
type xmlPackage struct {
|
|
XMLName xml.Name `xml:"package"`
|
|
Spine xmlSpine `xml:"spine"`
|
|
Manifest xmlManifest `xml:"manifest"`
|
|
}
|
|
|
|
type xmlSpine struct {
|
|
ItemRefs []xmlItemRef `xml:"itemref"`
|
|
}
|
|
|
|
type xmlItemRef struct {
|
|
IDRef string `xml:"idref,attr"`
|
|
}
|
|
|
|
type xmlManifest struct {
|
|
Items []xmlItem `xml:"item"`
|
|
}
|
|
|
|
type xmlItem struct {
|
|
ID string `xml:"id,attr"`
|
|
Href string `xml:"href,attr"`
|
|
MediaType string `xml:"media-type,attr"`
|
|
}
|
|
|
|
func parseOPFSpine(data []byte) ([]spineItem, error) {
|
|
var pkg xmlPackage
|
|
if err := xml.Unmarshal(data, &pkg); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
manifest := make(map[string]spineItem)
|
|
for _, item := range pkg.Manifest.Items {
|
|
manifest[item.ID] = spineItem{
|
|
href: item.Href,
|
|
mediaType: item.MediaType,
|
|
}
|
|
}
|
|
|
|
var items []spineItem
|
|
for _, ref := range pkg.Spine.ItemRefs {
|
|
if item, ok := manifest[ref.IDRef]; ok {
|
|
items = append(items, item)
|
|
}
|
|
}
|
|
|
|
return items, nil
|
|
}
|
|
|
|
func IsCREXPointer(s string) bool {
|
|
return strings.HasPrefix(s, "/body/DocFragment[") || strings.HasPrefix(s, "#_doc_fragment_")
|
|
}
|
|
|
|
func IsCREFragmentID(s string) bool {
|
|
return strings.HasPrefix(s, "#_doc_fragment_")
|
|
}
|
|
|
|
func IsStandardEPUBCFI(s string) bool {
|
|
return strings.HasPrefix(s, "epubcfi(") && strings.Contains(s, ")")
|
|
}
|
|
|
|
var voidElements = map[string]bool{
|
|
"area": true, "base": true, "br": true, "col": true, "embed": true,
|
|
"hr": true, "img": true, "input": true, "link": true, "meta": true,
|
|
"param": true, "source": true, "track": true, "wbr": true,
|
|
}
|
|
|
|
var inlineFormattingElements = map[string]bool{
|
|
"a": true, "abbr": true, "b": true, "bdo": true, "cite": true,
|
|
"code": true, "del": true, "dfn": true, "em": true, "i": true,
|
|
"ins": true, "kbd": true, "mark": true, "q": true, "samp": true,
|
|
"small": true, "span": true, "strong": true, "sub": true, "sup": true,
|
|
"u": true, "var": true,
|
|
}
|
|
|
|
func isInlineFormatting(n *html.Node) bool {
|
|
return n != nil && n.Type == html.ElementNode && inlineFormattingElements[n.Data]
|
|
}
|
|
|
|
type textSegment struct {
|
|
node *html.Node
|
|
runes []rune
|
|
offset int
|
|
}
|
|
|
|
func collectInlineText(blockParent *html.Node) []textSegment {
|
|
var segments []textSegment
|
|
var walk func(*html.Node)
|
|
walk = func(n *html.Node) {
|
|
if n.Type == html.TextNode {
|
|
runes := []rune(n.Data)
|
|
segments = append(segments, textSegment{node: n, runes: runes})
|
|
} else if isInlineFormatting(n) {
|
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
walk(c)
|
|
}
|
|
}
|
|
}
|
|
for c := blockParent.FirstChild; c != nil; c = c.NextSibling {
|
|
walk(c)
|
|
}
|
|
return segments
|
|
}
|
|
|
|
func findBlockParent(textNode *html.Node) *html.Node {
|
|
current := textNode.Parent
|
|
for current != nil {
|
|
if current.Type == html.ElementNode && !inlineFormattingElements[current.Data] {
|
|
return current
|
|
}
|
|
current = current.Parent
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var selfClosingRe = regexp.MustCompile(`<([a-zA-Z][a-zA-Z0-9]*)([^>]*?)/\s*>`)
|
|
|
|
func preprocessXHTML(input string) string {
|
|
return selfClosingRe.ReplaceAllStringFunc(input, func(match string) string {
|
|
sub := selfClosingRe.FindStringSubmatch(match)
|
|
if len(sub) < 3 {
|
|
return match
|
|
}
|
|
tag := strings.ToLower(sub[1])
|
|
if voidElements[tag] {
|
|
return match
|
|
}
|
|
return fmt.Sprintf("<%s%s></%s>", sub[1], sub[2], sub[1])
|
|
})
|
|
}
|
|
|
|
type cfiStep struct {
|
|
Index int
|
|
ID string
|
|
Offset int
|
|
HasOffset bool
|
|
}
|
|
|
|
var cfiStepRe = regexp.MustCompile(`/(\d+)(?:\[([^\]]*)\])?(?::(\d+))?`)
|
|
|
|
func parseCFISteps(s string) []cfiStep {
|
|
var steps []cfiStep
|
|
matches := cfiStepRe.FindAllStringSubmatch(s, -1)
|
|
for _, m := range matches {
|
|
idx, _ := strconv.Atoi(m[1])
|
|
step := cfiStep{Index: idx}
|
|
if m[2] != "" {
|
|
step.ID = m[2]
|
|
}
|
|
if m[3] != "" {
|
|
step.Offset, _ = strconv.Atoi(m[3])
|
|
step.HasOffset = true
|
|
}
|
|
steps = append(steps, step)
|
|
}
|
|
return steps
|
|
}
|
|
|
|
func parseEPUBCFI(cfi string) (spineIndex int, localSteps []cfiStep, err error) {
|
|
inner := strings.TrimPrefix(cfi, "epubcfi(")
|
|
inner = strings.TrimSuffix(inner, ")")
|
|
|
|
parts := strings.SplitN(inner, "!", 2)
|
|
if len(parts) < 2 {
|
|
return 0, nil, fmt.Errorf("no step indirection in CFI")
|
|
}
|
|
|
|
spineSteps := parseCFISteps(parts[0])
|
|
if len(spineSteps) < 2 {
|
|
return 0, nil, fmt.Errorf("no spine step in CFI")
|
|
}
|
|
spineIndex = spineSteps[len(spineSteps)-1].Index/2 - 1
|
|
|
|
localPart := parts[1]
|
|
if strings.Contains(localPart, ",") {
|
|
commaParts := strings.SplitN(localPart, ",", 3)
|
|
if len(commaParts) < 2 {
|
|
return 0, nil, fmt.Errorf("invalid range CFI")
|
|
}
|
|
parentSteps := parseCFISteps(commaParts[0])
|
|
startSteps := parseCFISteps(commaParts[1])
|
|
localSteps = append(parentSteps, startSteps...)
|
|
} else {
|
|
localSteps = parseCFISteps(localPart)
|
|
}
|
|
|
|
if len(localSteps) < 1 {
|
|
return 0, nil, fmt.Errorf("no local steps in CFI")
|
|
}
|
|
return spineIndex, localSteps, nil
|
|
}
|
|
|
|
func resolveCFIToNode(doc *html.Node, steps []cfiStep) (*html.Node, int, error) {
|
|
current := findHTMLElement(doc)
|
|
if current == nil {
|
|
return nil, 0, fmt.Errorf("no <html> element found in document")
|
|
}
|
|
for i := 0; i < len(steps)-1; i++ {
|
|
step := steps[i]
|
|
indexed := indexChildNodes(current)
|
|
if step.Index >= len(indexed) {
|
|
return nil, 0, fmt.Errorf("step %d index %d out of range (max %d)", i, step.Index, len(indexed)-1)
|
|
}
|
|
entry := indexed[step.Index]
|
|
if entry.isElement() {
|
|
current = entry.node
|
|
} else if entry.isTextChunk() {
|
|
current = entry.textChunk[0]
|
|
} else {
|
|
return nil, 0, fmt.Errorf("step %d hit virtual/null node", i)
|
|
}
|
|
}
|
|
|
|
lastStep := steps[len(steps)-1]
|
|
indexed := indexChildNodes(current)
|
|
if lastStep.Index >= len(indexed) {
|
|
return nil, 0, fmt.Errorf("last step index %d out of range (max %d)", lastStep.Index, len(indexed)-1)
|
|
}
|
|
entry := indexed[lastStep.Index]
|
|
|
|
if entry.isTextChunk() {
|
|
// The CFI terminal offset arrives in UTF-16 code units (spec
|
|
// currency — foliate/readium/KOReader/Kobo all emit UTF-16).
|
|
// Walk the chunk in UTF-16 units, then convert the hit position
|
|
// to the internal rune offset.
|
|
u16Remaining := 0
|
|
if lastStep.HasOffset {
|
|
u16Remaining = lastStep.Offset
|
|
}
|
|
|
|
var targetNode *html.Node
|
|
runeIntoTarget := 0
|
|
for _, tn := range entry.textChunk {
|
|
units := utf16Len(tn.Data)
|
|
if u16Remaining < units || (u16Remaining == units && targetNode == nil) {
|
|
targetNode = tn
|
|
runeIntoTarget = utf16ToRuneIndex(tn.Data, u16Remaining)
|
|
break
|
|
}
|
|
u16Remaining -= units
|
|
targetNode = tn
|
|
runeIntoTarget = utf8.RuneCountInString(tn.Data)
|
|
}
|
|
if targetNode == nil && len(entry.textChunk) > 0 {
|
|
targetNode = entry.textChunk[len(entry.textChunk)-1]
|
|
runeIntoTarget = utf8.RuneCountInString(targetNode.Data)
|
|
}
|
|
|
|
parent := targetNode.Parent
|
|
totalOffset := 0
|
|
for c := parent.FirstChild; c != nil; c = c.NextSibling {
|
|
if c == targetNode {
|
|
break
|
|
}
|
|
totalOffset += countTextChars(c)
|
|
}
|
|
totalOffset += runeIntoTarget
|
|
|
|
return targetNode, totalOffset, nil
|
|
}
|
|
|
|
if entry.isElement() {
|
|
return entry.node, 0, nil
|
|
}
|
|
|
|
return nil, 0, fmt.Errorf("last step hit virtual/null node")
|
|
}
|
|
|
|
func buildCREXPointer(spineIndex int, node *html.Node, charOffset int) (string, error) {
|
|
current := node
|
|
if current.Type == html.TextNode {
|
|
current = current.Parent
|
|
}
|
|
if current == nil || current.Type != html.ElementNode {
|
|
return "", fmt.Errorf("no element node to build XPointer from")
|
|
}
|
|
|
|
var parts []string
|
|
for current != nil {
|
|
if current.Type != html.ElementNode {
|
|
current = current.Parent
|
|
continue
|
|
}
|
|
|
|
parent := current.Parent
|
|
if parent == nil {
|
|
break
|
|
}
|
|
|
|
if strings.EqualFold(current.Data, "body") {
|
|
break
|
|
}
|
|
|
|
tagCount := 0
|
|
for c := parent.FirstChild; c != nil; c = c.NextSibling {
|
|
if c.Type == html.ElementNode && strings.EqualFold(c.Data, current.Data) {
|
|
tagCount++
|
|
if c == current {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
parts = append([]string{fmt.Sprintf("/%s[%d]", current.Data, tagCount)}, parts...)
|
|
|
|
if parent.Type == html.ElementNode && strings.EqualFold(parent.Data, "body") {
|
|
break
|
|
}
|
|
current = parent
|
|
}
|
|
|
|
fragIndex := spineIndex + 1
|
|
xpointer := fmt.Sprintf("/body/DocFragment[%d]/body%s", fragIndex, strings.Join(parts, ""))
|
|
|
|
if charOffset > 0 || (node.Type == html.TextNode) {
|
|
// crengine counts UTF-16 code units; charOffset is internal runes.
|
|
xpointer += fmt.Sprintf("/text().%d", runeToUTF16Index(node.Data, charOffset))
|
|
}
|
|
|
|
return xpointer, nil
|
|
}
|
|
|
|
type ReverseConversionResult struct {
|
|
XPointer string
|
|
Precision string
|
|
Percentage float64
|
|
}
|
|
|
|
func (c *CFIConverter) ConvertStandardToCRE(epubcfi string, storedPercentage float64, contextText string) (*ReverseConversionResult, error) {
|
|
spineIndex, localSteps, err := parseEPUBCFI(epubcfi)
|
|
if err != nil {
|
|
log.Printf("Bookhoard: CFI→CRE parse failed: %v", err)
|
|
return c.reverseByTextSearch(epubcfi, storedPercentage, contextText)
|
|
}
|
|
|
|
doc, _, docErr := c.getContentDoc(spineIndex + 1)
|
|
if docErr != nil {
|
|
log.Printf("Bookhoard: CFI→CRE content doc load failed: %v", docErr)
|
|
return c.reverseByTextSearch(epubcfi, storedPercentage, contextText)
|
|
}
|
|
|
|
textNode, charOffset, resolveErr := resolveCFIToNode(doc, localSteps)
|
|
if resolveErr != nil {
|
|
log.Printf("Bookhoard: CFI→CRE resolution failed: %v", resolveErr)
|
|
return c.reverseByTextSearch(epubcfi, storedPercentage, contextText)
|
|
}
|
|
|
|
xpointer, buildErr := buildCREXPointer(spineIndex, textNode, charOffset)
|
|
if buildErr != nil {
|
|
log.Printf("Bookhoard: CFI→CRE build failed: %v", buildErr)
|
|
return c.reverseByTextSearch(epubcfi, storedPercentage, contextText)
|
|
}
|
|
|
|
log.Printf("Bookhoard: CFI→CRE converted %s → %s", epubcfi, xpointer)
|
|
return &ReverseConversionResult{
|
|
XPointer: xpointer,
|
|
Precision: "exact",
|
|
Percentage: storedPercentage,
|
|
}, nil
|
|
}
|
|
|
|
func (c *CFIConverter) reverseByTextSearch(epubcfi string, storedPercentage float64, contextText string) (*ReverseConversionResult, error) {
|
|
usable, normalizedCtx := usableContextText(contextText)
|
|
if !usable {
|
|
if normalizeWhitespace(contextText) != "" {
|
|
log.Printf("Bookhoard: CFI→CRE ignoring too-short context %q", contextText)
|
|
}
|
|
return &ReverseConversionResult{
|
|
Precision: "percentage",
|
|
Percentage: storedPercentage,
|
|
}, nil
|
|
}
|
|
|
|
spineIndex, _, parseErr := parseEPUBCFI(epubcfi)
|
|
if parseErr != nil {
|
|
spine, err := c.loadSpine()
|
|
if err != nil {
|
|
return &ReverseConversionResult{
|
|
Precision: "percentage",
|
|
Percentage: storedPercentage,
|
|
}, nil
|
|
}
|
|
|
|
bookWideOffset := int(storedPercentage * float64(totalBookChars(c, spine)))
|
|
spineIndex = 0
|
|
charsBefore := 0
|
|
for i := range spine.items {
|
|
doc, _, docErr := c.getContentDoc(i + 1)
|
|
if docErr != nil {
|
|
continue
|
|
}
|
|
body := findBody(doc)
|
|
if body == nil {
|
|
continue
|
|
}
|
|
cc := countTextChars(body)
|
|
if charsBefore+cc > bookWideOffset {
|
|
spineIndex = i
|
|
break
|
|
}
|
|
charsBefore += cc
|
|
}
|
|
}
|
|
|
|
doc, _, docErr := c.getContentDoc(spineIndex + 1)
|
|
if docErr != nil {
|
|
return &ReverseConversionResult{
|
|
Precision: "percentage",
|
|
Percentage: storedPercentage,
|
|
}, nil
|
|
}
|
|
|
|
body := findBody(doc)
|
|
if body == nil {
|
|
return &ReverseConversionResult{
|
|
Precision: "percentage",
|
|
Percentage: storedPercentage,
|
|
}, nil
|
|
}
|
|
|
|
match, matchOffset := findTextInNode(body, normalizedCtx)
|
|
if match == nil {
|
|
log.Printf("Bookhoard: CFI→CRE text search no match for %q", normalizedCtx)
|
|
return &ReverseConversionResult{
|
|
Precision: "percentage",
|
|
Percentage: storedPercentage,
|
|
}, nil
|
|
}
|
|
|
|
parent := match.Parent
|
|
totalOffset := matchOffset
|
|
for c := parent.FirstChild; c != nil; c = c.NextSibling {
|
|
if c == match {
|
|
break
|
|
}
|
|
totalOffset += countTextChars(c)
|
|
}
|
|
|
|
xpointer, buildErr := buildCREXPointer(spineIndex, match, totalOffset)
|
|
if buildErr != nil {
|
|
log.Printf("Bookhoard: CFI→CRE text search build failed: %v", buildErr)
|
|
return &ReverseConversionResult{
|
|
Precision: "percentage",
|
|
Percentage: storedPercentage,
|
|
}, nil
|
|
}
|
|
|
|
log.Printf("Bookhoard: CFI→CRE text search matched → %s", xpointer)
|
|
return &ReverseConversionResult{
|
|
XPointer: xpointer,
|
|
Precision: "exact",
|
|
Percentage: storedPercentage,
|
|
}, nil
|
|
}
|
|
|
|
func totalBookChars(c *CFIConverter, spine *spineCache) int {
|
|
total := 0
|
|
for i := range spine.items {
|
|
doc, _, err := c.getContentDoc(i + 1)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
body := findBody(doc)
|
|
if body != nil {
|
|
total += countTextChars(body)
|
|
}
|
|
}
|
|
return total
|
|
}
|