package sync import ( "archive/zip" "encoding/xml" "fmt" "io" "log" "net/url" "path" "regexp" "strconv" "strings" "unicode/utf8" "golang.org/x/net/html" ) type CFIConverter struct { epubPath string cache *spineCache } 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) { 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 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 } 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>", 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 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 }