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)
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user