From c95f04501d9d00c98afe1321075bee59235a1fa0 Mon Sep 17 00:00:00 2001 From: Tom Brautlacht <309523882+LordNikon1983@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:26:45 -0700 Subject: [PATCH] fix(desktop): skip markdown tables in speech output --- apps/desktop/src/lib/speech-text.test.ts | 137 +++++++++++++++++++++++ apps/desktop/src/lib/speech-text.ts | 133 +++++++++++++++++++++- 2 files changed, 269 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/lib/speech-text.test.ts b/apps/desktop/src/lib/speech-text.test.ts index 6c1717a3f84..b09ee117513 100644 --- a/apps/desktop/src/lib/speech-text.test.ts +++ b/apps/desktop/src/lib/speech-text.test.ts @@ -14,4 +14,141 @@ describe('sanitizeTextForSpeech', () => { 'Use git status after the change.' ) }) + + it('skips markdown table data while preserving surrounding human text', () => { + const text = `Here is the quick takeaway: the totals remain unchanged. + +| Item | Value | Notes | +| --- | ---: | --- | +| Example A | 10 | first row | +| Example B | 20 | second row | + +Full detail stays visible on screen.` + + expect(sanitizeTextForSpeech(text)).toBe( + 'Here is the quick takeaway: the totals remain unchanged. Full detail stays visible on screen.' + ) + }) + + it('does not strip prose that merely contains a pipe character', () => { + const text = 'Use the summary first | keep the table on screen when it matters.' + + expect(sanitizeTextForSpeech(text)).toBe('Use the summary first | keep the table on screen when it matters.') + }) + + it('does not duplicate punctuation across paragraph breaks', () => { + const text = `First sentence. + +Second sentence.` + + expect(sanitizeTextForSpeech(text)).toBe('First sentence. Second sentence.') + }) + + it.each([ + ['markdown emphasis', '**First sentence.**\n\nSecond sentence.', 'First sentence. Second sentence.'], + ['a closing quote', '“First sentence.”\n\nSecond sentence.', '“First sentence.” Second sentence.'], + ['a closing parenthesis', '(First sentence.)\n\nSecond sentence.', '(First sentence.) Second sentence.'] + ])('does not duplicate punctuation after %s', (_label, text, expected) => { + expect(sanitizeTextForSpeech(text)).toBe(expected) + }) + + it('skips markdown tables without leading and trailing pipes', () => { + const text = `Main takeaway: total is unchanged. + +Item | Value +--- | ---: +Example A | 10 +Example B | 20 + +Done.` + + expect(sanitizeTextForSpeech(text)).toBe('Main takeaway: total is unchanged. Done.') + }) + + it('skips markdown tables nested inside blockquotes', () => { + const text = `Before the table. + +> | Item | Value | +> | --- | ---: | +> | Example A | 10 | +> | Example B | 20 | + +After the table.` + + expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.') + }) + + it('allows marker padding plus three spaces in blockquoted tables', () => { + const text = `Before the table. + +> | Item | Value | +> | --- | ---: | +> | Example A | 10 | + +After the table.` + + expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.') + }) + + it('skips explicit single-column markdown tables', () => { + const text = `Before the table. + +| Item | +| --- | +| Example A | + +After the table.` + + expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.') + }) + + it('preserves rows outside a table blockquote', () => { + const text = `> | Item | Value | +> | --- | ---: | +> | Example A | 10 | +Outside | prose` + + expect(sanitizeTextForSpeech(text)).toBe('Outside | prose') + }) + + it('preserves malformed tables with mismatched column counts', () => { + const text = `Heading | Detail +--- | --- | --- +Keep this prose.` + + expect(sanitizeTextForSpeech(text)).toContain('Heading | Detail') + }) + + it('skips GFM body rows whose cell counts differ from the header', () => { + const text = `Before the table. + +| Item | Value | +| --- | ---: | +| Example A | +| Example B | 20 | ignored | + +After the table.` + + expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.') + }) + + it('skips tables containing escaped pipe characters', () => { + const text = `Before the table. + +| Item \\| detail | Value | +| --- | ---: | +| Example A | 10 | + +After the table.` + + expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.') + }) + + it('preserves indented code that resembles a table', () => { + const text = ` Item | Value + --- | --- + Example A | 10` + + expect(sanitizeTextForSpeech(text)).toContain('Item | Value') + }) }) diff --git a/apps/desktop/src/lib/speech-text.ts b/apps/desktop/src/lib/speech-text.ts index d62ef0f6698..fa3e7a5ebf7 100644 --- a/apps/desktop/src/lib/speech-text.ts +++ b/apps/desktop/src/lib/speech-text.ts @@ -5,6 +5,7 @@ const CODE_BLOCK_SUMMARY = ' code block omitted ' const INLINE_CODE_RE = /`([^`]+)`/g const MARKDOWN_LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g const PARAGRAPH_BREAK_RE = /[ \t]*\n{2,}[ \t]*/g +const PUNCTUATED_PARAGRAPH_BREAK_RE = /([.!?])([*_~`>"'’”)}\]]*)[ \t]*\n{2,}[ \t]*/g const SOFT_BREAK_RE = /[ \t]*\n[ \t]*/g const THINKING_PREFIX_RE = @@ -12,16 +13,146 @@ const THINKING_PREFIX_RE = const URL_RE = /\bhttps?:\/\/\S+/gi +const MARKDOWN_TABLE_DELIMITER_CELL_RE = /^:?-{3,}:?$/ + +interface MarkdownTableRow { + blockquoteDepth: number + cells: string[] +} + +function isUnescapedPipe(row: string, index: number): boolean { + let backslashes = 0 + + for (let cursor = index - 1; cursor >= 0 && row[cursor] === '\\'; cursor -= 1) { + backslashes += 1 + } + + return backslashes % 2 === 0 +} + +function splitMarkdownTableCells(row: string): string[] { + const cells: string[] = [] + let cellStart = 0 + + for (let index = 0; index < row.length; index += 1) { + if (row[index] === '|' && isUnescapedPipe(row, index)) { + cells.push(row.slice(cellStart, index).trim()) + cellStart = index + 1 + } + } + + cells.push(row.slice(cellStart).trim()) + + return cells +} + +function parseMarkdownTableRow(line: string): MarkdownTableRow | null { + let row = line + let blockquoteDepth = 0 + + while (true) { + const indentation = row.match(/^[ \t]*/)?.[0] ?? '' + + if (indentation.includes('\t') || indentation.length > 3) { + return null + } + + row = row.slice(indentation.length) + + if (!row.startsWith('>')) { + break + } + + blockquoteDepth += 1 + row = row.slice(1) + + if (row.startsWith(' ')) { + row = row.slice(1) + } + } + + row = row.trimEnd() + + const pipeIndexes = [...row.matchAll(/\|/g)].map(match => match.index).filter(index => isUnescapedPipe(row, index)) + + if (pipeIndexes.length === 0) { + return null + } + + const hasLeadingPipe = pipeIndexes[0] === 0 + const hasTrailingPipe = pipeIndexes.at(-1) === row.length - 1 + + if (hasLeadingPipe) { + row = row.slice(1) + } + + if (hasTrailingPipe) { + row = row.slice(0, -1) + } + + const cells = splitMarkdownTableCells(row) + + if (cells.length < 2 && !(hasLeadingPipe && hasTrailingPipe && cells.length === 1)) { + return null + } + + return { blockquoteDepth, cells } +} + +function stripMarkdownTables(text: string): string { + const lines = text.replace(/\r\n?/g, '\n').split('\n') + const tableLines = new Set() + + let index = 1 + + while (index < lines.length) { + const delimiterRow = parseMarkdownTableRow(lines[index]) + const headerRow = parseMarkdownTableRow(lines[index - 1]) + + if ( + !delimiterRow || + !headerRow || + !delimiterRow.cells.every(cell => MARKDOWN_TABLE_DELIMITER_CELL_RE.test(cell)) || + headerRow.cells.length !== delimiterRow.cells.length || + headerRow.blockquoteDepth !== delimiterRow.blockquoteDepth + ) { + index += 1 + + continue + } + + tableLines.add(index - 1) + tableLines.add(index) + + let rowIndex = index + 1 + + for (; rowIndex < lines.length; rowIndex += 1) { + const bodyRow = parseMarkdownTableRow(lines[rowIndex]) + + if (!bodyRow || bodyRow.blockquoteDepth !== delimiterRow.blockquoteDepth) { + break + } + + tableLines.add(rowIndex) + } + + index = rowIndex + } + + return lines.filter((_, index) => !tableLines.has(index)).join('\n') +} + function normalizeLineBreaks(text: string): string { return text .replace(/\r\n?/g, '\n') .replace(/(\p{L})-\n(\p{L})/gu, '$1$2') + .replace(PUNCTUATED_PARAGRAPH_BREAK_RE, '$1$2 ') .replace(PARAGRAPH_BREAK_RE, '. ') .replace(SOFT_BREAK_RE, ' ') } export function sanitizeTextForSpeech(text: string): string { - return normalizeLineBreaks(text) + return normalizeLineBreaks(stripMarkdownTables(text)) .replace(FENCED_CODE_RE, CODE_BLOCK_SUMMARY) .replace(THINKING_PREFIX_RE, ' ') .replace(MARKDOWN_LINK_RE, '$1')