forked from akai/readest
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:
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user