tts: skip text to speech for rubys and footnote anchors, closes #1334 (#1608)

This commit is contained in:
Huang Xin
2025-07-18 17:10:12 +08:00
committed by GitHub
parent abb727870e
commit 29b5a7cb88
7 changed files with 60 additions and 20 deletions
@@ -11,6 +11,7 @@ import { BookSearchConfig, BookSearchResult } from '@/types/book';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { debounce } from '@/utils/debounce';
import { isCJKStr } from '@/utils/lang';
import { createRejecttFilter } from '@/utils/node';
import Dropdown from '@/components/Dropdown';
import SearchOptions from './SearchOptions';
@@ -117,19 +118,6 @@ const SearchBar: React.FC<SearchBarProps> = ({
return searchTerm.length >= minLength;
};
const createAcceptNode = ({ withRT = true } = {}) => {
return (node: Node): number => {
if (node.nodeType === Node.ELEMENT_NODE) {
const name = (node as Element).tagName.toLowerCase();
if (name === 'script' || name === 'style' || (!withRT && name === 'rt')) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_SKIP;
}
return NodeFilter.FILTER_ACCEPT;
};
};
const handleSearch = useCallback(
async (term: string) => {
console.log('searching for:', term);
@@ -139,7 +127,9 @@ const SearchBar: React.FC<SearchBarProps> = ({
...searchConfig,
index,
query: term,
acceptNode: createAcceptNode({ withRT: !primaryLang.startsWith('ja') }),
acceptNode: createRejecttFilter({
tags: primaryLang.startsWith('ja') ? ['rt'] : [],
}),
});
const results: BookSearchResult[] = [];
let lastProgressLogTime = 0;
@@ -212,7 +212,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
await ttsController.initViewTTS();
const ssml = view.tts?.from(ttsFromRange);
if (ssml) {
let lang = parseSSMLLang(ssml, primaryLang) || 'en';
const lang = parseSSMLLang(ssml, primaryLang) || 'en';
setIsPlaying(true);
setTtsLang(lang);
@@ -3,6 +3,7 @@ import { AppService } from '@/types/system';
import { parseSSMLMarks } from '@/utils/ssml';
import { Overlayer } from 'foliate-js/overlayer.js';
import { TTSGranularity, TTSHighlightOptions, TTSMark, TTSVoice } from './types';
import { createRejecttFilter } from '@/utils/node';
import { WebSpeechClient } from './WebSpeechClient';
import { NativeTTSClient } from './NativeTTSClient';
import { EdgeTTSClient } from './EdgeTTSClient';
@@ -106,7 +107,14 @@ export class TTSController extends EventTarget {
granularity = supportedGranularities[0]!;
}
const highlightOptions: TTSHighlightOptions = { style: 'highlight', color: 'gray' };
await this.view.initTTS(granularity, this.#getHighlighter(highlightOptions));
await this.view.initTTS(
granularity,
createRejecttFilter({
tags: ['rt', 'sup'],
contents: [{ tag: 'a', content: /^\d+$/ }],
}),
this.#getHighlighter(highlightOptions),
);
}
async preloadSSML(ssml: string | undefined) {
+5 -1
View File
@@ -20,7 +20,11 @@ export interface FoliateView extends HTMLElement {
clearSearch: () => void;
select: (target: string | number | { fraction: number }) => void;
deselect: () => void;
initTTS: (granularity?: TTSGranularity, highlight?: (range: Range) => void) => Promise<void>;
initTTS: (
granularity?: TTSGranularity,
nodeFilter?: (node: Node) => number,
highlight?: (range: Range) => void,
) => Promise<void>;
book: BookDoc;
tts: TTS | null;
language: {
+38
View File
@@ -0,0 +1,38 @@
export const createRejecttFilter = ({
tags = [],
classes = [],
attributes = [],
contents = [],
}: {
tags?: string[];
classes?: string[];
attributes?: string[];
contents?: { tag: string; content: RegExp }[];
}) => {
return (node: Node): number => {
if (node.nodeType === Node.ELEMENT_NODE) {
const name = (node as Element).tagName.toLowerCase();
if (name === 'script' || name === 'style') {
return NodeFilter.FILTER_REJECT;
}
if (tags.includes(name)) {
return NodeFilter.FILTER_REJECT;
}
if (classes.some((cls) => (node as Element).classList.contains(cls))) {
return NodeFilter.FILTER_REJECT;
}
if (attributes.some((attr) => (node as Element).hasAttribute(attr))) {
return NodeFilter.FILTER_REJECT;
}
if (
contents.some(({ tag, content }) => {
return name === tag && content.test((node as Element).textContent || '');
})
) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_SKIP;
}
return NodeFilter.FILTER_ACCEPT;
};
};
+2 -2
View File
@@ -183,13 +183,13 @@ export const getPopupPosition = (
return { point: popupPoint, dir: position.dir } as Position;
};
export const getTextFromRange = (range: Range, rejects: string[] = []): string => {
export const getTextFromRange = (range: Range, rejectTags: string[] = []): string => {
const clonedRange = range.cloneRange();
const fragment = clonedRange.cloneContents();
const walker = document.createTreeWalker(fragment, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
const parent = node.parentElement;
if (rejects.includes(parent?.tagName.toLowerCase() || '')) {
if (rejectTags.includes(parent?.tagName.toLowerCase() || '')) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;