feat(sync): transparently cross inline formatting elements in text search

When finding or extracting text in EPUB/KEPUB DOM trees, inline
formatting elements like <em>, <strong>, <i>, <b>, <span>, etc. should
not break text continuity. A reader sees 'Vokalia and Consonantia' as
one phrase regardless of the <em> wrappers around each word.

Add inline formatting element set and helper functions:
- isInlineFormatting: checks if an element is an inline phrasing element
- collectInlineText: flattens text across formatting elements within
  a block-level parent, returning segments that map back to original
  text nodes
- findBlockParent: walks up from a text node to find the nearest
  block-level ancestor (used to scope text collection)
- findTextAcrossInlineElements: fallback for findTextInNode that
  concatenates text within each block element (transparently crossing
  formatting elements) and maps match positions back to actual nodes
- collectBlockElements: gathers all block-level elements containing text

The key invariant: text collection NEVER crosses block-level element
boundaries (<p>, <div>, <h1>-<h6>, <li>, etc.) to avoid concatenating
text from different paragraphs.

The findTextInNode function now tries single-text-node matching first
(fast path, unchanged), then falls back to cross-element matching only
when needed. This preserves performance for the common case.
This commit is contained in:
2026-06-03 20:00:26 -04:00
parent 3c2a504747
commit 3a54dda5c5
+111 -1
View File
@@ -394,7 +394,69 @@ func findTextInNode(root *html.Node, searchText string) (*html.Node, int) {
} }
walk(root) walk(root)
return found, foundRuneOffset 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 { func normalizeWhitespace(s string) string {
@@ -909,6 +971,54 @@ var voidElements = map[string]bool{
"param": true, "source": true, "track": true, "wbr": 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*>`) var selfClosingRe = regexp.MustCompile(`<([a-zA-Z][a-zA-Z0-9]*)([^>]*?)/\s*>`)
func preprocessXHTML(input string) string { func preprocessXHTML(input string) string {