Files
bookhoard/internal/sync/cfi_converter.go
T
john-okeefe 1585aa1073 perf(sync): share parsed EPUBs across conversions, make converters concurrency-safe
ConvertToCanonical/ConvertFromCanonical built a fresh CFIConverter
per call, and each annotation converts twice (pos0+pos1) — a book
with 200 highlights re-opened and re-parsed the EPUB 400+ times per
sync, and again per metadata pull. A bounded 8-entry cache keyed by
path now shares converters (the parsing work belongs on the server;
clients stay thin). CFIConverter gained a mutex around its lazily
built spine/doc caches since instances are now shared between
concurrent requests.

Adds CFIConverter.SectionPercentage: book-wide percentage for a CRE
xpointer from the spine char distribution (midpoint of its document)
— the server-side counterpart to dropping per-annotation
getPageFromXPointer lookups from the plugin.
2026-08-18 19:13:51 -04:00

1413 lines
32 KiB
Go

package sync
import (
"archive/zip"
"encoding/xml"
"fmt"
"io"
"log"
"net/url"
"path"
"regexp"
"strconv"
"strings"
"sync"
"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
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
}
if contextText != "" {
result := c.convertByTextSearch(body, xp, spine, href, storedPercentage, contextText)
if result != nil {
return result, nil
}
}
return c.convertByPercentageOffset(body, xp, spine, href, storedPercentage)
}
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
}
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, matchOffset := findTextInNode(body, normalizedCtx)
if match == nil {
log.Printf("Bookhoard: text search no match for %q in %s", normalizedCtx, href)
return nil
}
spineIndex := xp.FragmentIndex - 1
cfi, err := buildCFI(spineIndex, match, matchOffset)
if err != nil || cfi == "" {
log.Printf("Bookhoard: text search found match but buildCFI failed: %v", err)
return nil
}
log.Printf("Bookhoard: text search matched %q → %s (precision: exact)", normalizedCtx, cfi)
return &ConversionResult{
EPUBCFI: cfi,
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,
Precision: "exact",
}, 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
}
func findTextChunkIndex(parent *html.Node, textNode *html.Node) (int, int) {
indexed := indexChildNodes(parent)
for i, node := range indexed {
if node.isTextChunk() {
for j, tn := range node.textChunk {
if tn == textNode {
chunkOffset := 0
for k := 0; k < j; k++ {
chunkOffset += utf8.RuneCountInString(node.textChunk[k].Data)
}
return i, chunkOffset
}
}
}
}
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 := findTextChunkIndex(parent, textNode)
if chunkIdx == -1 {
return "", fmt.Errorf("text node not found in parent's indexed children")
}
totalOffset := chunkOffset + charOffset
var parts []string
parts = append(parts, fmt.Sprintf("/%d:%d", chunkIdx, 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
}
func countTextCharsBefore(target *html.Node) int {
count := 0
for c := target; 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() {
textOffset := 0
if lastStep.HasOffset {
textOffset = lastStep.Offset
}
var targetNode *html.Node
remainingOffset := textOffset
for _, tn := range entry.textChunk {
textLen := utf8.RuneCountInString(tn.Data)
if remainingOffset < textLen || (remainingOffset == textLen && targetNode == nil) {
targetNode = tn
break
}
remainingOffset -= textLen
targetNode = tn
}
if targetNode == nil && len(entry.textChunk) > 0 {
targetNode = entry.textChunk[len(entry.textChunk)-1]
}
parent := targetNode.Parent
totalOffset := 0
for c := parent.FirstChild; c != nil; c = c.NextSibling {
if c == targetNode {
break
}
totalOffset += countTextChars(c)
}
totalOffset += remainingOffset
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) {
xpointer += fmt.Sprintf("/text().%d", 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) {
normalizedCtx := normalizeWhitespace(contextText)
if normalizedCtx == "" {
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
}