fix(footnote): don't treat in-book numeric chapter/verse links as footnotes (#4152)

closes #4140

The bare-numeric-text heuristic added in #3894 to detect non-superscript
footnotes (`/^.{0,2}\d+$/` over `anchor.textContent`) was too permissive:
in-book TOCs that list chapter/verse links such as `<a>1</a>, <a>2</a>, ...`
all match the regex, so clicking them sets `check=true` and the footnote
handler renders the destination as a popup instead of letting the link
navigate. The OSB v2 verse-index and OSB v4 chapter-index from the bug
report both hit this.

Reject the `check` heuristic when the clicked link sits inside a numeric
link list (2+ sibling links with the same short-numeric pattern within
three ancestor levels). A real body paragraph with a couple of footnote
markers still passes; a flat TOC of numeric links does not.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-14 01:33:26 +08:00
committed by GitHub
parent 041af6859f
commit 54aa20d4f8
3 changed files with 119 additions and 1 deletions
@@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest';
import { shouldCheckAsFootnote } from '@/app/reader/utils/footnoteHeuristics';
const parseHtml = (html: string) => {
const doc = new DOMParser().parseFromString(html, 'text/html');
return doc.body;
};
const getAnchorByText = (root: Element, text: string) => {
for (const a of Array.from(root.querySelectorAll('a'))) {
if ((a.textContent || '').trim() === text) return a as HTMLAnchorElement;
}
throw new Error(`anchor with text "${text}" not found`);
};
describe('shouldCheckAsFootnote', () => {
it('returns false for non-numeric link text', () => {
const body = parseHtml('<p>Body text<a href="#x">go here</a> more text</p>');
const a = getAnchorByText(body, 'go here');
expect(shouldCheckAsFootnote(a)).toBe(false);
});
it('returns true for an isolated short-numeric link in body text', () => {
const body = parseHtml(
'<p>The quick brown fox<a href="#n1">1</a> jumps over the lazy dog.</p>',
);
const a = getAnchorByText(body, '1');
expect(shouldCheckAsFootnote(a)).toBe(true);
});
it('returns false when the link is part of an inline verse-number list (OSB v2 layout)', () => {
// Mirrors the in-book "Verses in Chapter 32" TOC layout: a flat list of
// numeric verse links wrapped in nested spans, separated by commas.
const body = parseHtml(`
<p class="calibre34"><span class="calibre6">
<span><a href="../split_067.html#filepos416575">1</a></span>,
<span><a href="../split_067.html#filepos416915">2</a></span>,
<span><a href="../split_067.html#filepos417015">3</a></span>,
<span><a href="../split_067.html#filepos417238">4</a></span>,
<span><a href="../split_067.html#filepos417377">5</a></span>
</span></p>
`);
const a = getAnchorByText(body, '1');
expect(shouldCheckAsFootnote(a)).toBe(false);
});
it('returns false when the link is part of a pipe-separated chapter list (OSB v4 layout)', () => {
// Mirrors the in-book chapter index: <a>1</a> | <a>2</a> | ... | <a>34</a>
const body = parseHtml(`
<p>
<a href="part0030.xhtml">1</a> |
<a href="part0030.xhtml#a2D14">2</a> |
<a href="part0030.xhtml#a2D15">3</a> |
<a href="part0030.xhtml#a1W36">4</a> |
<a href="part0030.xhtml#a2CPZ">32</a>
</p>
`);
const a = getAnchorByText(body, '32');
expect(shouldCheckAsFootnote(a)).toBe(false);
});
it('still recognises a real footnote marker even when one other footnote sits in the same paragraph', () => {
// A single body paragraph with two footnote markers is plausible; only
// 2+ *additional* numeric siblings should disqualify.
const body = parseHtml(
'<p>First reference<a href="#n1">1</a> and a second sentence<a href="#n2">2</a> finishes.</p>',
);
const a = getAnchorByText(body, '1');
expect(shouldCheckAsFootnote(a)).toBe(true);
});
it('returns false when 2+ sibling numeric links share the container', () => {
const body = parseHtml('<p>Refs: <a href="#a">1</a> <a href="#b">2</a> <a href="#c">3</a></p>');
const a = getAnchorByText(body, '1');
expect(shouldCheckAsFootnote(a)).toBe(false);
});
});
@@ -13,6 +13,7 @@ import { getPopupPosition, getPosition, Position } from '@/utils/sel';
import { FootnoteHandler } from 'foliate-js/footnotes.js';
import { mountAdditionalFonts, mountCustomFont } from '@/styles/fonts';
import { eventDispatcher } from '@/utils/event';
import { shouldCheckAsFootnote } from '../utils/footnoteHeuristics';
import { FoliateView } from '@/types/view';
import { isCJKLang } from '@/utils/lang';
import { Overlay } from '@/components/Overlay';
@@ -246,7 +247,7 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
if (footnoteClasses.some((cls) => anchor.classList.contains(cls))) {
detail['follow'] = true;
}
if (/^.{0,2}\d+$/.test(anchor.textContent || '')) {
if (shouldCheckAsFootnote(anchor)) {
detail['check'] = true;
}
historyRef.current = { items: [detail], index: 0 };
@@ -0,0 +1,40 @@
// Heuristically classify a link as a possible footnote marker based on its
// text content. The bare numeric pattern matches things like `1`, `12`, `a1`,
// `[1`, which are common when a book formats footnotes as plain digits.
const NUMERIC_MARKER_RE = /^.{0,2}\d+$/;
const isNumericMarkerText = (text: string | null | undefined) =>
text != null && NUMERIC_MARKER_RE.test(text.trim());
// Books with in-book navigation (TOC, chapter/verse indexes) often render a
// flat list of short numeric links: `<a>1</a>, <a>2</a>, ...`. Those should
// not be treated as footnote candidates. We detect that context by looking
// at the anchor's ancestors for sibling links that also match the numeric
// pattern. A single paragraph rarely has more than one or two real footnote
// markers, so a low threshold is safe.
const NAV_LIST_SIBLING_THRESHOLD = 2;
const MAX_ANCESTOR_DEPTH = 3;
const countNumericSiblings = (container: Element, anchor: HTMLAnchorElement) => {
let count = 0;
for (const link of Array.from(container.querySelectorAll('a'))) {
if (link === anchor) continue;
if (isNumericMarkerText(link.textContent)) {
count++;
if (count >= NAV_LIST_SIBLING_THRESHOLD) return count;
}
}
return count;
};
export const shouldCheckAsFootnote = (anchor: HTMLAnchorElement): boolean => {
if (!isNumericMarkerText(anchor.textContent)) return false;
let container: Element | null = anchor.parentElement;
for (let depth = 0; depth < MAX_ANCESTOR_DEPTH && container; depth++) {
if (countNumericSiblings(container, anchor) >= NAV_LIST_SIBLING_THRESHOLD) {
return false;
}
container = container.parentElement;
}
return true;
};