From 5301123f68c0c6898a0afc652802ed2931a1b5ce Mon Sep 17 00:00:00 2001 From: HyperCriSiS Date: Tue, 4 Aug 2026 16:14:35 +0200 Subject: [PATCH 1/4] Add optional subtitle processing --- .github/workflows/ci.yml | 5 +- CHANGELOG.md | 14 +++ README.md | 16 ++- docs/ARCHITECTURE.md | 12 ++- docs/BETA-TEST.md | 12 ++- docs/SETTINGS-BACKUP-FORMAT.md | 8 +- docs/STORE-LISTING-DE.md | 2 + src/content.ts | 1 + src/core/dom-processor.ts | 177 ++++++++++++++++++++++++++++++-- src/core/subtitles.ts | 96 +++++++++++++++++ src/options.ts | 6 +- src/settings/defaults.ts | 8 +- src/settings/settings-backup.ts | 10 ++ src/settings/storage.ts | 6 +- static/options/options.html | 11 +- tests/dom-performance.test.ts | 82 +++++++++++++++ tests/dom-processor.test.ts | 65 ++++++++++++ tests/settings-backup.test.ts | 21 +++- tests/settings-storage.test.ts | 21 ++++ tests/settings.test.ts | 5 +- 20 files changed, 552 insertions(+), 26 deletions(-) create mode 100644 src/core/subtitles.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 320331f..8d1b2c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, dev] pull_request: - branches: [main] + branches: [main, dev] permissions: contents: read @@ -65,6 +65,7 @@ jobs: grep -q 'id="custom-replacement-feedback"' static/options/options.html grep -q 'id="custom-preview-input"' static/options/options.html grep -q 'id="custom-preview-output"' static/options/options.html + grep -q 'id="process-subtitles"' static/options/options.html grep -q 'id="export-personal-rules"' static/options/options.html grep -q 'id="import-personal-rules"' static/options/options.html grep -q 'id="sync-categories"' static/options/options.html diff --git a/CHANGELOG.md b/CHANGELOG.md index b5e2c81..cbc45cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ Alle wesentlichen Änderungen an Sprachverstand werden in dieser Datei dokumentiert. +## Unveröffentlicht + +### Hinzugefügt + +- Untertitel-Overlays werden zentral erkannt und standardmäßig von der + Verarbeitung ausgenommen. Eine neue, standardmäßig deaktivierte Option + erlaubt ihre gezielte Korrektur. + +### Leistung + +- Schnell wechselnde Untertitel werden pro Bild auf den zuletzt sichtbaren + Textstand reduziert. Identische Zeilen verwenden einen begrenzten Cache; + der normale Webseitenpfad bleibt unverändert. + ## 0.6.6 (2026-08-02) ### Hinzugefügt diff --git a/README.md b/README.md index bfe5b9e..1a9359f 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,8 @@ mehrdeutige Formen bleiben deshalb unangetastet. - optionale Browser-Synchronisierung, für jede Datenkategorie einzeln wählbar und standardmäßig vollständig deaktiviert - Ausschluss einzelner Domains - optionaler Schutz von Texten in Anführungszeichen +- optionale Untertitelkorrektur; standardmäßig werden erkannte + Untertitel-Overlays vollständig übersprungen - optionale Verarbeitung zugänglicher Attribute wie `alt`, `aria-label`, `aria-description` und `title` - Schutz von Eingabefeldern, Editoren, Quellcode, URLs und technischen Daten @@ -135,8 +137,18 @@ lassen sich die Regelgruppen einzeln steuern, beispielsweise: - optionale Geschlechtszusätze in Stellenanzeigen wie `(m/w/d)` In den erweiterten Einstellungen stehen persönliche Ausnahmen, eigene -literale Ersetzungen, Domain-Ausschlüsse, der Schutz zitierter Schreibweisen und -die Verarbeitung zugänglicher Attribute zur Verfügung. Eigene Ersetzungen sind case-sensitive, werden genau einmal ausgeführt und standardmäßig ausschließlich lokal gespeichert. Ausnahmen haben Vorrang. In einem eigenen Bereich kann für Aktivierungsstatus, Regelgruppen, Domain-Ausschlüsse, Textoptionen, Ausnahmen und eigene Ersetzungen jeweils getrennt gewählt werden, ob die Kategorie zusätzlich über den Synchronisierungsdienst des Browsers übertragen wird. Die Auswahl ist standardmäßig vollständig deaktiviert. Sobald mindestens eine Kategorie aktiviert wird, synchronisiert der Browser auch diese Auswahl, damit dieselben Kategorien auf weiteren angemeldeten Geräten geladen werden können. +literale Ersetzungen, Domain-Ausschlüsse, der Schutz zitierter Schreibweisen, +die optionale Untertitelkorrektur und die Verarbeitung zugänglicher Attribute +zur Verfügung. Die Untertitelkorrektur ist standardmäßig deaktiviert. Eigene +Ersetzungen sind case-sensitive, werden genau einmal ausgeführt und +standardmäßig ausschließlich lokal gespeichert. Ausnahmen haben Vorrang. In +einem eigenen Bereich kann für Aktivierungsstatus, Regelgruppen, +Domain-Ausschlüsse, Textoptionen, Ausnahmen und eigene Ersetzungen jeweils +getrennt gewählt werden, ob die Kategorie zusätzlich über den +Synchronisierungsdienst des Browsers übertragen wird. Die Auswahl ist +standardmäßig vollständig deaktiviert. Sobald mindestens eine Kategorie +aktiviert wird, synchronisiert der Browser auch diese Auswahl, damit dieselben +Kategorien auf weiteren angemeldeten Geräten geladen werden können. **Speichern** und **Zurücksetzen** befinden sich oben in derselben Werkzeugleiste wie **Alle öffnen** und **Alle schließen**. Alle Einstellungsbereiche lassen sich diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 55435e3..5d9f0ed 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -12,6 +12,8 @@ 8. **Lokal als Standard:** Der vollständige Einstellungsstand liegt immer in `storage.local`. 9. **Synchronisierung nur nach Auswahl:** Jede synchronisierbare Datenkategorie wird einzeln gewählt; standardmäßig ist keine ausgewählt. 10. **Importdateien sind Daten, kein Code:** Regex, Skripte, unbekannte Felder und zukünftige Schema- oder Einstellungsrevisionen werden abgewiesen. +11. **Untertitel sind opt-in:** Erkannte Untertitel-Overlays werden + standardmäßig bereits vor der Texttransformation verworfen. ## Datenfluss @@ -36,6 +38,13 @@ verarbeitet. Danach beobachtet der `MutationObserver` neue Unterbäume, geänderte Textknoten und ausschließlich freigegebene Attribute. Eingaben, Editoren, Quellcode, URLs, IDs und technische Daten bleiben ausgeschlossen. +Untertitel-Overlays werden zentral anhand konservativer Player-Merkmale erkannt. +Ist ihre Verarbeitung deaktiviert, gelangen ihre Knoten nicht in die reguläre +Warteschlange. Bei Aktivierung sammelt ein eigener Pfad schnelle Textänderungen +bis zum nächsten Bild, verwirft überholte oder entfernte Knoten und verwendet +einen begrenzten Ergebnis-Cache. Normale Webseitenknoten behalten den bisherigen +Microtask-Pfad. + ## Einstellungen und Speicherorte `src/settings/storage.ts` speichert den vollständigen normalisierten Stand unter @@ -68,7 +77,8 @@ Stand bewusst keine Migration aus dem früheren experimentellen Speicherschema. Die Vorschau verwendet dieselbe Funktion `transformText` wie Webseiten und berücksichtigt die im Formular gewählten Regelgruppen, Ausnahmen, Ersetzungen und -die Zitatoption. +die Zitatoption. Die Untertiteloption betrifft ausschließlich die +DOM-Verarbeitung auf Webseiten. `src/settings/personal-rules.ts` enthält Parser, Konflikthinweise und die deterministische Zusammenführung persönlicher Listen. diff --git a/docs/BETA-TEST.md b/docs/BETA-TEST.md index 7f402e4..f6026d8 100644 --- a/docs/BETA-TEST.md +++ b/docs/BETA-TEST.md @@ -72,6 +72,7 @@ Prüfen: - Alle anderen Bereiche sind zunächst eingeklappt. - **Alle öffnen** öffnet sämtliche Bereiche. - **Alle schließen** schließt sämtliche Bereiche. +- **Untertitel korrigieren** ist standardmäßig deaktiviert. - Die Reihenfolge lautet: 1. Allgemein 2. Was soll korrigiert werden? @@ -197,13 +198,19 @@ DriveIn Paarungen unterschiedlicher Begriffe müssen unverändert bleiben, etwa `Nutzerinnen und Kunden` oder `Mütter und Väter`. -## Wiederherstellung, Zitate und Attribute +## Wiederherstellung, Zitate, Untertitel und Attribute - Jede Regelgruppe einzeln ausschalten. Nur ihre eigenen Änderungen dürfen zurückgesetzt werden. - Beim erneuten Einschalten müssen Texte ohne Neuladen wieder verarbeitet werden. - Beim globalen Ausschalten müssen alle Änderungen ohne Neuladen verschwinden. - Nach Deaktivierung der Zitatoption bleibt `„Mitarbeiter/-innen“` geschützt, während derselbe Ausdruck außerhalb des Zitats korrigiert wird. - Bei deaktivierter Attributoption dürfen `alt`, `aria-label`, `aria-description` und `title` nicht verändert werden; sichtbarer Text muss weiter funktionieren. +- Auf YouTube ein Video mit Untertiteln abspielen. Bei deaktivierter + Untertiteloption müssen Untertitel unverändert bleiben und das Video flüssig + laufen, während Beschreibung und Kommentare weiter verarbeitet werden. +- Die Untertiteloption aktivieren und eine Zeile mit `Nutzer:innen` testen. Die + Korrektur muss ohne sichtbares Stocken erfolgen. Schnelle wortweise Updates + dürfen nur den zuletzt sichtbaren Stand verarbeiten. - Eingabefelder, Editoren, Quellcode, URLs, IDs und technische Daten bleiben unberührt. ## README-Direkttest @@ -225,7 +232,8 @@ npx web-ext run \ ``` Auf dem Gerät Touch-Bedienung, Scrollbarkeit, Safe Areas, Bildschirmtastatur, -Popup, Optionsseite, eigene Ersetzungen, Synchronisierung und die lokale +Popup, Optionsseite, Untertitel bei ein- und ausgeschalteter Option, eigene +Ersetzungen, Synchronisierung und die lokale Rechtshinweisseite prüfen. ## Lokale Testseite diff --git a/docs/SETTINGS-BACKUP-FORMAT.md b/docs/SETTINGS-BACKUP-FORMAT.md index 8a4d2b4..296d368 100644 --- a/docs/SETTINGS-BACKUP-FORMAT.md +++ b/docs/SETTINGS-BACKUP-FORMAT.md @@ -8,7 +8,7 @@ versionierte JSON-Datei und kann ihn lokal wieder einlesen. - Aktivierungsstatus - aktive Regelgruppen - Domain-Ausschlüsse -- Verarbeitung von Zitaten und zugänglichen Attributen +- Verarbeitung von Zitaten, Untertiteln und zugänglichen Attributen - persönliche Ausnahmen - eigene Ersetzungen - Auswahl der optionalen Browser-Synchronisierung @@ -32,7 +32,7 @@ Erst nach **Speichern** werden ausdrücklich ausgewählte Kategorien zusätzlich "version": 2, "exportedAt": "2026-07-28T18:30:00.000Z", "settings": { - "settingsRevision": 6, + "settingsRevision": 7, "enabled": true, "excludedDomains": [ "example.org" @@ -52,6 +52,7 @@ Erst nach **Speichern** werden ausdrücklich ausgewählte Kategorien zusätzlich ], "processAccessibleAttributes": true, "processQuotedText": true, + "processSubtitles": false, "syncCategoryIds": [ "rule-groups" ] @@ -71,6 +72,9 @@ Zulässig sind ausschließlich: - `custom-replacements` Eine leere Liste bedeutet, dass alle Daten ausschließlich lokal bleiben. +Die Kategorie `text-options` umfasst Zitate, Untertitel und zugängliche +Attribute. Sicherungen aus Schema 2 ohne `processSubtitles` bleiben kompatibel; +beim Import gilt dafür der sichere Standard `false`. ## Strikte Prüfung diff --git a/docs/STORE-LISTING-DE.md b/docs/STORE-LISTING-DE.md index d984924..f4f0e0a 100644 --- a/docs/STORE-LISTING-DE.md +++ b/docs/STORE-LISTING-DE.md @@ -69,6 +69,7 @@ Funktionen: - drei wählbare Strategien für persönliche Listen beim Import - Ausschluss einzelner Domains - optionaler Schutz von Texten in Anführungszeichen +- optionale Korrektur erkannter Untertitel-Overlays; standardmäßig deaktiviert - optionale Verarbeitung von `alt`, `aria-label`, `aria-description` und `title` - Schutz von Eingabefeldern, Editoren, Quellcode, URLs und technischen Daten @@ -147,6 +148,7 @@ Wichtige Funktionen: - ausdrücklich wählbare Strategie für persönliche Listen beim Import - Ausschluss bestimmter Domains - optionaler Schutz zitierter Schreibweisen +- optionale Korrektur erkannter Untertitel-Overlays - optionale Verarbeitung zugänglicher Textattribute - Schutz von Eingabefeldern, Editoren, URLs und Quellcode diff --git a/src/content.ts b/src/content.ts index 3da8e6f..68c7904 100644 --- a/src/content.ts +++ b/src/content.ts @@ -46,6 +46,7 @@ function applySettings(settings: Settings): void { customReplacements: settings.customReplacements, processAccessibleAttributes: settings.processAccessibleAttributes, processQuotedText: settings.processQuotedText, + processSubtitles: settings.processSubtitles, onReplacementCountChange: reportReplacementCount }; diff --git a/src/core/dom-processor.ts b/src/core/dom-processor.ts index d484989..0b41f42 100644 --- a/src/core/dom-processor.ts +++ b/src/core/dom-processor.ts @@ -5,6 +5,10 @@ import { shouldProcessTextNode } from "./text-safety"; import type { CustomReplacement } from "../settings/defaults"; +import { + isSubtitleContainer, + isSubtitleContent +} from "./subtitles"; import { transformText } from "./transform-text"; export interface DomProcessorOptions { @@ -15,6 +19,7 @@ export interface DomProcessorOptions { readonly customReplacements?: readonly CustomReplacement[]; readonly processAccessibleAttributes?: boolean; readonly processQuotedText?: boolean; + readonly processSubtitles?: boolean; readonly onReplacementCountChange?: (count: number) => void; } @@ -23,6 +28,7 @@ export interface StopOptions { } const leadingContextLimit = 120; +const maximumSubtitleTransformCacheEntries = 256; const blockBoundaryTags = new Set([ "ADDRESS", "ARTICLE", @@ -65,6 +71,7 @@ interface ChangeRecord { export class DomProcessor { private observer: MutationObserver | undefined; private readonly pendingNodes = new Set(); + private readonly pendingSubtitleTextNodes = new Set(); private readonly pendingAttributes = new Map>(); private readonly textChanges = new Map(); private readonly attributeChanges = new Map< @@ -72,6 +79,12 @@ export class DomProcessor { Map >(); private flushScheduled = false; + private subtitleFlushHandle: number | undefined; + private subtitleFlushUsesAnimationFrame = false; + private readonly subtitleTransformCache = new Map< + string, + ReturnType + >(); private countNotificationScheduled = false; private running = false; private replacementCount = 0; @@ -141,8 +154,11 @@ export class DomProcessor { this.observer?.disconnect(); this.observer = undefined; this.pendingNodes.clear(); + this.pendingSubtitleTextNodes.clear(); this.pendingAttributes.clear(); this.flushScheduled = false; + this.cancelSubtitleFlush(); + this.subtitleTransformCache.clear(); if (options.restore) { this.restoreAll(); @@ -159,6 +175,7 @@ export class DomProcessor { } this.options = options; + this.subtitleTransformCache.clear(); if (wasRunning) { this.start(); @@ -192,6 +209,12 @@ export class DomProcessor { } public flush(): void { + this.flushRegularNodes(); + this.cancelSubtitleFlush(); + this.flushSubtitleNodes(); + } + + private flushRegularNodes(): void { if (!this.running) { return; } @@ -213,13 +236,33 @@ export class DomProcessor { } } + private flushSubtitleNodes(): void { + if (!this.running) { + return; + } + + const nodes = [...this.pendingSubtitleTextNodes]; + this.pendingSubtitleTextNodes.clear(); + + for (const node of nodes) { + if (node.isConnected && isSubtitleContent(node)) { + this.processTextNode(node, true); + } + } + } + public processRoot(root: Node): void { if (!this.running) { return; } + const skipSubtitles = this.options.processSubtitles !== true; + if (skipSubtitles && isSubtitleContent(root)) { + return; + } + if (root.nodeType === Node.TEXT_NODE) { - this.processTextNode(root as Text); + this.processTextNode(root as Text, skipSubtitles ? false : undefined); return; } @@ -233,13 +276,24 @@ export class DomProcessor { const nodeFilter = this.document.defaultView?.NodeFilter ?? NodeFilter; const walker = this.document.createTreeWalker( root, - nodeFilter.SHOW_TEXT | nodeFilter.SHOW_ELEMENT + nodeFilter.SHOW_TEXT | nodeFilter.SHOW_ELEMENT, + skipSubtitles + ? { + acceptNode: (node) => + node instanceof Element && isSubtitleContainer(node) + ? nodeFilter.FILTER_REJECT + : nodeFilter.FILTER_ACCEPT + } + : null ); let currentNode = walker.nextNode(); while (currentNode) { if (currentNode.nodeType === Node.TEXT_NODE) { - this.processTextNode(currentNode as Text); + this.processTextNode( + currentNode as Text, + skipSubtitles ? false : undefined + ); } else if ( currentNode instanceof Element && this.options.processAccessibleAttributes !== false @@ -256,10 +310,44 @@ export class DomProcessor { return; } + if (isSubtitleContent(node)) { + if (node.nodeType === Node.TEXT_NODE) { + const tracked = this.textChanges.get(node as Text); + if (tracked && (node as Text).data === tracked.transformed) { + return; + } + } + + if (this.options.processSubtitles === true) { + this.queueSubtitleTextNodes(node); + } + return; + } + this.pendingNodes.add(node); this.scheduleFlush(); } + private queueSubtitleTextNodes(root: Node): void { + if (root.nodeType === Node.TEXT_NODE) { + this.pendingSubtitleTextNodes.add(root as Text); + this.scheduleSubtitleFlush(); + return; + } + + const nodeFilter = this.document.defaultView?.NodeFilter ?? NodeFilter; + const walker = this.document.createTreeWalker(root, nodeFilter.SHOW_TEXT); + let currentNode = walker.nextNode(); + while (currentNode) { + this.pendingSubtitleTextNodes.add(currentNode as Text); + currentNode = walker.nextNode(); + } + + if (this.pendingSubtitleTextNodes.size > 0) { + this.scheduleSubtitleFlush(); + } + } + private queueAttribute(element: Element, attributeName: string): void { if (!this.running) { return; @@ -278,10 +366,52 @@ export class DomProcessor { } this.flushScheduled = true; - queueMicrotask(() => this.flush()); + queueMicrotask(() => this.flushRegularNodes()); + } + + private scheduleSubtitleFlush(): void { + if (this.subtitleFlushHandle !== undefined) { + return; + } + + const view = this.document.defaultView; + if (view && typeof view.requestAnimationFrame === "function") { + this.subtitleFlushUsesAnimationFrame = true; + this.subtitleFlushHandle = view.requestAnimationFrame(() => { + this.subtitleFlushHandle = undefined; + this.subtitleFlushUsesAnimationFrame = false; + this.flushSubtitleNodes(); + }); + return; + } + + this.subtitleFlushUsesAnimationFrame = false; + this.subtitleFlushHandle = view?.setTimeout(() => { + this.subtitleFlushHandle = undefined; + this.flushSubtitleNodes(); + }, 16) ?? window.setTimeout(() => { + this.subtitleFlushHandle = undefined; + this.flushSubtitleNodes(); + }, 16); } - private processTextNode(node: Text): void { + private cancelSubtitleFlush(): void { + if (this.subtitleFlushHandle === undefined) { + return; + } + + const view = this.document.defaultView; + if (this.subtitleFlushUsesAnimationFrame) { + view?.cancelAnimationFrame(this.subtitleFlushHandle); + } else { + view?.clearTimeout(this.subtitleFlushHandle); + } + + this.subtitleFlushHandle = undefined; + this.subtitleFlushUsesAnimationFrame = false; + } + + private processTextNode(node: Text, subtitleOverride?: boolean): void { const tracked = this.textChanges.get(node); if (tracked) { if (node.data === tracked.transformed) { @@ -295,11 +425,18 @@ export class DomProcessor { return; } + const subtitle = subtitleOverride ?? isSubtitleContent(node); + if (subtitle && this.options.processSubtitles !== true) { + return; + } + const original = node.data; - const leadingContext = this.needsLeadingContext(original) + const leadingContext = !subtitle && this.needsLeadingContext(original) ? this.collectLeadingContext(node) : undefined; - const result = this.transformValue(original, leadingContext); + const result = subtitle + ? this.transformSubtitleValue(original) + : this.transformValue(original, leadingContext); if (result.replacements === 0 || result.text === original) { return; } @@ -323,8 +460,12 @@ export class DomProcessor { element: Element, attributeName: string ): void { - const tracked = this.attributeChanges.get(element)?.get(attributeName); const value = element.getAttribute(attributeName); + if (value === null || isSubtitleContent(element)) { + return; + } + + const tracked = this.attributeChanges.get(element)?.get(attributeName); if (tracked) { if (value === tracked.transformed) { @@ -335,7 +476,6 @@ export class DomProcessor { } if ( - value === null || !shouldProcessAccessibleAttribute(element, attributeName, value) ) { return; @@ -377,6 +517,25 @@ export class DomProcessor { return transformText(input, this.options.rules, transformOptions); } + private transformSubtitleValue(input: string) { + const cached = this.subtitleTransformCache.get(input); + if (cached) { + return cached; + } + + const result = this.transformValue(input); + if ( + this.subtitleTransformCache.size >= maximumSubtitleTransformCacheEntries + ) { + const oldestKey = this.subtitleTransformCache.keys().next().value; + if (oldestKey !== undefined) { + this.subtitleTransformCache.delete(oldestKey); + } + } + this.subtitleTransformCache.set(input, result); + return result; + } + private needsLeadingContext(input: string): boolean { for (const rule of this.options.rules) { if ( diff --git a/src/core/subtitles.ts b/src/core/subtitles.ts new file mode 100644 index 0000000..b0f3e33 --- /dev/null +++ b/src/core/subtitles.ts @@ -0,0 +1,96 @@ +const subtitleIdentifierMarkers = [ + "ytp-caption", + "caption-window", + "caption-segment", + "captions-container", + "captions-overlay", + "captions-text", + "subtitle-container", + "subtitle-overlay", + "subtitle-renderer", + "subtitle-cue", + "timedtext", + "text-track-container", + "text-track-display", + "cue-window", + "atvwebplayersdk-captions", + "dss-subtitle", + "jw-text-track", + "plyr__captions", + "shaka-text-container", + "vp-captions" +] as const; + +const subtitleDataAttributeNames = [ + "data-purpose", + "data-testid", + "data-uia" +] as const; + +const subtitleDataMarker = /(?:caption|timedtext|text-track|cue-(?:text|window)|(?:player|video)[-_ ]*subtitle|subtitle[-_ ]*(?:cue|overlay|container|renderer))/iu; +const subtitleAriaMarker = /(?:untertitel|subtitles?|closed captions?)/iu; +const maximumAncestorDepth = 12; +const knownSubtitleElements = new WeakSet(); +const escapedIdentifierMarkers = subtitleIdentifierMarkers.map((marker) => + marker.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&") +); +const subtitleIdentifierMarker = new RegExp( + `(?:${escapedIdentifierMarkers.join("|")})`, + "u" +); + +function identifierText(element: Element): string { + return `${element.id} ${element.getAttribute("class") ?? ""}`.toLowerCase(); +} + +export function isSubtitleContainer(element: Element): boolean { + if (knownSubtitleElements.has(element)) { + return true; + } + + const identifiers = identifierText(element); + if (subtitleIdentifierMarker.test(identifiers)) { + knownSubtitleElements.add(element); + return true; + } + + for (const attributeName of subtitleDataAttributeNames) { + const value = element.getAttribute(attributeName); + if (value && subtitleDataMarker.test(value)) { + knownSubtitleElements.add(element); + return true; + } + } + + const ariaLabel = element.getAttribute("aria-label"); + const subtitle = Boolean(ariaLabel && subtitleAriaMarker.test(ariaLabel)); + if (subtitle) { + knownSubtitleElements.add(element); + } + return subtitle; +} + +/** + * Erkennt bewusst nur typische Untertitel-Overlays. Allgemeine Klassen wie + * `subtitle` oder `caption` werden nicht allein ausgewertet, weil sie auf + * Nachrichtenseiten häufig normale Unterzeilen und Bildunterschriften meinen. + */ +export function isSubtitleContent(node: Node): boolean { + let element = + node.nodeType === Node.ELEMENT_NODE + ? (node as Element) + : node.parentElement; + + for (let depth = 0; element && depth < maximumAncestorDepth; depth += 1) { + if (isSubtitleContainer(element)) { + return true; + } + + if (element.tagName === "BODY" || element.tagName === "HTML") { + break; + } + element = element.parentElement; + } + + return false; +} diff --git a/src/options.ts b/src/options.ts index 1e4af20..348a5d7 100644 --- a/src/options.ts +++ b/src/options.ts @@ -57,6 +57,8 @@ const processAccessibleAttributesInput = requiredElement("#process-accessible-attributes"); const processQuotedTextInput = requiredElement("#process-quoted-text"); +const processSubtitlesInput = + requiredElement("#process-subtitles"); const ruleGroupsContainer = requiredElement("#rule-groups"); const protectedTermsInput = requiredElement("#protected-terms"); @@ -192,6 +194,7 @@ function render(settings: Settings): void { processAccessibleAttributesInput.checked = settings.processAccessibleAttributes; processQuotedTextInput.checked = settings.processQuotedText; + processSubtitlesInput.checked = settings.processSubtitles; protectedTermsInput.value = formatProtectedTermsText(settings.protectedTerms); customReplacementsInput.value = formatCustomReplacementsText( settings.customReplacements @@ -256,6 +259,7 @@ function readForm(): Settings { ).replacements, processAccessibleAttributes: processAccessibleAttributesInput.checked, processQuotedText: processQuotedTextInput.checked, + processSubtitles: processSubtitlesInput.checked, syncCategoryIds: syncCategoryIdsFromForm() }; } @@ -490,7 +494,7 @@ function selectedImportMode(): SettingsBackupImportMode { function renderImportSummary(result: SettingsImportResult): void { const generalSettings = document.createElement("p"); generalSettings.textContent = - "Aktivierungsstatus, Regelgruppen, Domain-Ausschlüsse, Zitat- und Attributoptionen sowie die Auswahl der optionalen Browser-Synchronisierung wurden aus der Sicherung übernommen."; + "Aktivierungsstatus, Regelgruppen, Domain-Ausschlüsse, Zitat-, Untertitel- und Attributoptionen sowie die Auswahl der optionalen Browser-Synchronisierung wurden aus der Sicherung übernommen."; const summary = document.createElement("p"); summary.textContent = [ diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index a49008f..e58e682 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -12,7 +12,7 @@ export const maximumCustomReplacementSourceLength = 120; export const maximumCustomReplacementTargetLength = 200; export const maximumExcludedDomains = 100; export const maximumExcludedDomainLength = 253; -export const currentSettingsRevision = 6; +export const currentSettingsRevision = 7; export const syncCategoryIds = [ "activation", @@ -47,6 +47,7 @@ export interface Settings { readonly customReplacements: readonly CustomReplacement[]; readonly processAccessibleAttributes: boolean; readonly processQuotedText: boolean; + readonly processSubtitles: boolean; readonly syncCategoryIds: readonly SyncCategoryId[]; } @@ -59,6 +60,7 @@ export const defaultSettings: Settings = { customReplacements: [], processAccessibleAttributes: true, processQuotedText: true, + processSubtitles: false, syncCategoryIds: [] }; @@ -255,6 +257,10 @@ export function normalizeSettings(value: unknown): Settings { typeof input.processQuotedText === "boolean" ? input.processQuotedText : defaultSettings.processQuotedText, + processSubtitles: + typeof input.processSubtitles === "boolean" + ? input.processSubtitles + : defaultSettings.processSubtitles, syncCategoryIds: syncCategoryArray(input.syncCategoryIds) }; } diff --git a/src/settings/settings-backup.ts b/src/settings/settings-backup.ts index 57e381a..ec3906b 100644 --- a/src/settings/settings-backup.ts +++ b/src/settings/settings-backup.ts @@ -1,6 +1,7 @@ import { ruleGroupDefinitions } from "../rules/catalog"; import { currentSettingsRevision, + defaultSettings, isValidDomainPattern, maximumCustomReplacementSourceLength, maximumCustomReplacementTargetLength, @@ -273,6 +274,7 @@ function validateSettingsObject(value: unknown): Settings { "customReplacements", "processAccessibleAttributes", "processQuotedText", + "processSubtitles", "syncCategoryIds" ], "Die Einstellungssicherung" @@ -300,6 +302,12 @@ function validateSettingsObject(value: unknown): Settings { input.processQuotedText, "Die Einstellung für Anführungszeichen" ); + if (input.processSubtitles !== undefined) { + assertBoolean( + input.processSubtitles, + "Die Einstellung für Untertitel" + ); + } return normalizeSettings({ settingsRevision: input.settingsRevision, @@ -310,6 +318,8 @@ function validateSettingsObject(value: unknown): Settings { customReplacements: validateCustomReplacements(input.customReplacements), processAccessibleAttributes: input.processAccessibleAttributes, processQuotedText: input.processQuotedText, + processSubtitles: + input.processSubtitles ?? defaultSettings.processSubtitles, syncCategoryIds: validateSyncCategoryIds(input.syncCategoryIds) }); } diff --git a/src/settings/storage.ts b/src/settings/storage.ts index 72c0dab..95f3cd3 100644 --- a/src/settings/storage.ts +++ b/src/settings/storage.ts @@ -38,7 +38,8 @@ function synchronizedValue( case "text-options": return { processAccessibleAttributes: settings.processAccessibleAttributes, - processQuotedText: settings.processQuotedText + processQuotedText: settings.processQuotedText, + processSubtitles: settings.processSubtitles }; case "protected-terms": return [...settings.protectedTerms]; @@ -73,7 +74,8 @@ function withSynchronizedValue( return normalizeSettings({ ...settings, processAccessibleAttributes: input.processAccessibleAttributes, - processQuotedText: input.processQuotedText + processQuotedText: input.processQuotedText, + processSubtitles: input.processSubtitles }); } case "protected-terms": diff --git a/static/options/options.html b/static/options/options.html index 6f659f8..3993b2b 100644 --- a/static/options/options.html +++ b/static/options/options.html @@ -81,6 +81,13 @@

Sprachverstand

Standardmäßig aktiv. Ausschalten schützt direkt zitierte Schreibweisen innerhalb desselben Textabschnitts. + @@ -177,7 +184,7 @@

Vorschau

Exportiert den vollständigen aktuellen Einstellungsstand: Aktivierung, - Regelgruppen, Domain-Ausschlüsse, Zitat- und Attributoptionen, + Regelgruppen, Domain-Ausschlüsse, Zitat-, Untertitel- und Attributoptionen, persönliche Ausnahmen, eigene Ersetzungen und die Auswahl der optionalen Browser-Synchronisierung.

@@ -226,7 +233,7 @@

Vorschau