fix(desktop): skip markdown tables in speech output

This commit is contained in:
Tom Brautlacht 2026-07-26 19:26:45 -07:00 committed by Teknium
parent f02d41cb21
commit c95f04501d
2 changed files with 269 additions and 1 deletions

View file

@ -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')
})
})

View file

@ -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<number>()
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')