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:
@@ -394,7 +394,69 @@ func findTextInNode(root *html.Node, searchText string) (*html.Node, int) {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -909,6 +971,54 @@ var voidElements = map[string]bool{
|
||||
"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 {
|
||||
|
||||
Reference in New Issue
Block a user