Implement robust quote extraction for TTS narration with support for nested quotes (#4502) * feat: implement robust quote extraction for TTS narration with multi-language support * refactor: simplify quote matching definition, remove dead code

862c2b7ba3da1295b0547ae125e8960393452b87

Wolfsblvt <wolfsblvt@gmail.com>

Signed
1 files changed, +77 -4Ignore whitespace
public/scripts/extensions/tts/index.js+77 -4
@@ -637,11 +637,8 @@ async function processTtsQueue() {
637637 }
638638
639639 if (extension_settings.tts.narrate_quoted_only) {
640- const special_quotes = /[“”«»「」『』""]/g; // Extend this regex to include other special quotes
641- text = text.replace(special_quotes, '"');
642- const matches = text.match(/".*?"/g); // Matches text inside double quotes, non-greedily
643640 const partJoiner = (ttsProvider?.separator || ' ... ');
644641 text = matchesjoinQuotedBlocks(text, ?{ matches.join(separator: partJoiner), includeQuotes: texttrue });
645642 }
646643
647644 // Remove embedded images
@@ -702,6 +699,82 @@ async function processTtsQueue() {
702699 }
703700}
704701
702+/**
703+ * Extract and join quoted blocks with proper matching pairs and nesting.
704+ * - Captures outermost quotes and everything inside (including different inner quote styles).
705+ * - Requires matching opener/closer style (e.g., “ ... ”, 「 ... 」, « ... », etc.).
706+ * - Ignores incomplete/unclosed quotes (doesn't include them in the result).
707+ * - Symmetric quotes like "..." and "..." are supported (not nesting the same symmetric style).
708+ *
709+ * @param {string} text - The text to process
710+ * @param {object} [opts={}] - Optional options object
711+ * @param {string} [opts.separator=' ... '] - String to join multiple quoted blocks
712+ * @param {boolean} [opts.includeQuotes=true] - Keep the quote chars around the captured text
713+ * @param {boolean} [opts.returnEmptyOnNoQuotes=false] - Return an empty string if no quotes are found
714+ * @param {Array<[string,string]>} [opts.pairs] - Custom quote pairs; defaults cover EN/DE/FR/JP
715+ * @returns {string} The joined quoted blocks, or the original text if no quotes found
716+ */
717+function joinQuotedBlocks(text, opts = {}) {
718+ const {
719+ separator = ' ... ',
720+ includeQuotes = true,
721+ returnEmptyOnNoQuotes = false,
722+ pairs = [
723+ // typographic doubles
724+ ['„', '“'], // DE low-high
725+ ['“', '”'], // EN
726+ ['«', '»'], // FR open « close »
727+ ['»', '«'], // Some locales open »
728+ // typographic singles
729+ ['‘', '’'],
730+ ['‚', '‘'],
731+ // Japanese corner quotes
732+ ['「', '」'],
733+ ['『', '』'],
734+ // symmetric doubles
735+ ['"', '"'],
736+ ['"', '"'],
737+ ],
738+ } = opts;
739+
740+ if (!text || typeof text !== 'string') return text;
741+
742+ const openToClose = Object.fromEntries(pairs);
743+
744+ const segments = [];
745+ const stack = []; // [{ opener, expectedClose, start }]
746+ for (let i = 0; i < text.length; i++) {
747+ const ch = text[i];
748+ const top = stack[stack.length - 1];
749+
750+ // Prefer closing the current open pair if the char matches its expected closer
751+ if (top && ch === top.expectedClose) {
752+ const finished = stack.pop();
753+ if (stack.length === 0) {
754+ // Only collect outermost quotes (contains all nested content)
755+ segments.push(text.slice(finished.start, i + 1));
756+ }
757+ continue;
758+ }
759+
760+ // Otherwise, see if this is a new opener
761+ if (openToClose[ch]) {
762+ stack.push({ opener: ch, expectedClose: openToClose[ch], start: i });
763+ continue;
764+ }
765+
766+ // If it's a stray closer that doesn't match current top, ignore
767+ }
768+
769+ if (!segments.length) return returnEmptyOnNoQuotes ? '' : text;
770+
771+ const cleaned = includeQuotes
772+ ? segments
773+ : segments.map(s => s.slice(1, -1)); // all defined pairs are single-char quotes
774+
775+ return cleaned.join(separator);
776+}
777+
705778async function playFullConversation() {
706779 resetTtsPlayback();
707780