From 54aa20d4f8027d868382b0c7a05c04c835612dc0 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Thu, 14 May 2026 01:33:26 +0800 Subject: [PATCH] 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 `1, 2, ...` 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) --- .../utils/footnote-heuristics.test.ts | 77 +++++++++++++++++++ .../app/reader/components/FootnotePopup.tsx | 3 +- .../app/reader/utils/footnoteHeuristics.ts | 40 ++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 apps/readest-app/src/__tests__/utils/footnote-heuristics.test.ts create mode 100644 apps/readest-app/src/app/reader/utils/footnoteHeuristics.ts diff --git a/apps/readest-app/src/__tests__/utils/footnote-heuristics.test.ts b/apps/readest-app/src/__tests__/utils/footnote-heuristics.test.ts new file mode 100644 index 00000000..f1de907e --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/footnote-heuristics.test.ts @@ -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('

Body textgo here more text

'); + 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( + '

The quick brown fox1 jumps over the lazy dog.

', + ); + 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(` +

+ 1, + 2, + 3, + 4, + 5 +

+ `); + 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: 1 | 2 | ... | 34 + const body = parseHtml(` +

+ 1 | + 2 | + 3 | + 4 | + 32 +

+ `); + 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( + '

First reference1 and a second sentence2 finishes.

', + ); + const a = getAnchorByText(body, '1'); + expect(shouldCheckAsFootnote(a)).toBe(true); + }); + + it('returns false when 2+ sibling numeric links share the container', () => { + const body = parseHtml('

Refs: 1 2 3

'); + const a = getAnchorByText(body, '1'); + expect(shouldCheckAsFootnote(a)).toBe(false); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/FootnotePopup.tsx b/apps/readest-app/src/app/reader/components/FootnotePopup.tsx index 3b21b65d..2a57e6cc 100644 --- a/apps/readest-app/src/app/reader/components/FootnotePopup.tsx +++ b/apps/readest-app/src/app/reader/components/FootnotePopup.tsx @@ -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 = ({ 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 }; diff --git a/apps/readest-app/src/app/reader/utils/footnoteHeuristics.ts b/apps/readest-app/src/app/reader/utils/footnoteHeuristics.ts new file mode 100644 index 00000000..f5b4edc3 --- /dev/null +++ b/apps/readest-app/src/app/reader/utils/footnoteHeuristics.ts @@ -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: `1, 2, ...`. 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; +};