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.
1413 lines
32 KiB
Go
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
|
|
}
|