fix(desktop): read table headers aloud instead of dropping tables silently

Read Aloud removed Markdown tables with no spoken trace (#86602). Speak the
header row in their place ("Model, Price.") and keep skipping the body rows,
so the listener hears that a table is on screen and what it compares
without cell-by-cell data. The header is the reply's own text, so it is
always in the voice's language; a localized "table omitted" notice would
follow the UI locale instead and reintroduce the mismatch when the two
differ. A table whose header cells are all empty stays silent.

Also keep the sentence punctuation after an inline MEDIA: token on both
the desktop and backend paths ("see MEDIA:/x.py. Then" no longer loses
its full stop), per review on #89367.
This commit is contained in:
brooklyn!
2026-09-23 17:35:42 -05:00
parent 25f7bac318
commit 529d27e5be
4 changed files with 95 additions and 19 deletions

View File

@@ -16,7 +16,7 @@ describe('sanitizeTextForSpeech', () => {
expect(sanitizeTextForSpeech('Use `git status` after the change.')).toBe('Use git status after the change.')
})
it('skips markdown table data while preserving surrounding human text', () => {
it('reads the table header, not its data, between surrounding human text', () => {
const text = `Here is the quick takeaway: the totals remain unchanged.
| Item | Value | Notes |
@@ -27,10 +27,45 @@ describe('sanitizeTextForSpeech', () => {
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.'
'Here is the quick takeaway: the totals remain unchanged. Item, Value, Notes. Full detail stays visible on screen.'
)
})
it('speaks a non-English reply with no English words injected (#86602)', () => {
// Placeholders used to follow the code, not the reply's language: a
// Chinese voice read "code block omitted" / "link" and skipped tables
// entirely. Code and URLs are silence; the table header is read in the
// reply's own language.
const text = `对比如下:
| 模型 | 价格 |
| --- | ---: |
| 甲 | 10 |
代码:
\`\`\`py
print(1)
\`\`\`
详情见 https://example.com/docs`
const spoken = sanitizeTextForSpeech(text)
expect(spoken).toContain('模型, 价格')
expect(spoken).not.toMatch(/[A-Za-z]/)
})
it('stays silent for a table whose header cells are all empty', () => {
const text = `Before the table.
| | |
| --- | --- |
| a | b |
After the table.`
expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.')
})
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.'
@@ -53,6 +88,10 @@ Second sentence.`
expect(sanitizeTextForSpeech(text)).toBe('The files are below. Bye.')
})
it('keeps the sentence break after an inline MEDIA token', () => {
expect(sanitizeTextForSpeech('See MEDIA:/tmp/report-2026-q3.xlsx. Then reply.')).toBe('See. Then reply.')
})
it('does not speak a placeholder word for URLs', () => {
// Used to say the English word "link" (#86602); URLs are silence now.
expect(sanitizeTextForSpeech('See https://example.com/a-huge-page for details')).toBe(
@@ -90,7 +129,7 @@ Second sentence.`
expect(sanitizeTextForSpeech(text)).toBe(expected)
})
it('skips markdown tables without leading and trailing pipes', () => {
it('reads only the header of markdown tables without leading and trailing pipes', () => {
const text = `Main takeaway: total is unchanged.
Item | Value
@@ -100,10 +139,10 @@ Example B | 20
Done.`
expect(sanitizeTextForSpeech(text)).toBe('Main takeaway: total is unchanged. Done.')
expect(sanitizeTextForSpeech(text)).toBe('Main takeaway: total is unchanged. Item, Value. Done.')
})
it('skips markdown tables nested inside blockquotes', () => {
it('reads only the header of markdown tables nested inside blockquotes', () => {
const text = `Before the table.
> | Item | Value |
@@ -113,7 +152,7 @@ Done.`
After the table.`
expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.')
expect(sanitizeTextForSpeech(text)).toBe('Before the table. Item, Value. After the table.')
})
it('allows marker padding plus three spaces in blockquoted tables', () => {
@@ -125,10 +164,10 @@ After the table.`
After the table.`
expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.')
expect(sanitizeTextForSpeech(text)).toBe('Before the table. Item, Value. After the table.')
})
it('skips explicit single-column markdown tables', () => {
it('reads only the header of explicit single-column markdown tables', () => {
const text = `Before the table.
| Item |
@@ -137,7 +176,7 @@ After the table.`
After the table.`
expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.')
expect(sanitizeTextForSpeech(text)).toBe('Before the table. Item. After the table.')
})
it('preserves rows outside a table blockquote', () => {
@@ -146,7 +185,7 @@ After the table.`
> | Example A | 10 |
Outside | prose`
expect(sanitizeTextForSpeech(text)).toBe('Outside | prose')
expect(sanitizeTextForSpeech(text)).toBe('Item, Value. Outside | prose')
})
it('preserves malformed tables with mismatched column counts', () => {
@@ -167,10 +206,10 @@ Keep this prose.`
After the table.`
expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.')
expect(sanitizeTextForSpeech(text)).toBe('Before the table. Item, Value. After the table.')
})
it('skips tables containing escaped pipe characters', () => {
it('reads headers containing escaped pipe characters', () => {
const text = `Before the table.
| Item \\| detail | Value |
@@ -179,7 +218,7 @@ After the table.`
After the table.`
expect(sanitizeTextForSpeech(text)).toBe('Before the table. After the table.')
expect(sanitizeTextForSpeech(text)).toBe('Before the table. Item detail, Value. After the table.')
})
it('preserves indented code that resembles a table', () => {

View File

@@ -7,7 +7,10 @@ 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 MEDIA_PATH_RE = /MEDIA:\S+/g
// A file-link token ("MEDIA:/path/to/report.xlsx") renders as a chip on
// screen; spoken, its hyphenated slug makes voices loop. It is silence, but a
// sentence-final period/comma after it is kept ("see MEDIA:/x.py. Then").
const MEDIA_PATH_RE = /[ \t]*MEDIA:\S+?(?=[.,;:!?)\]]*(?:\s|$))/g
const LINE_FINAL_COLON_RE = /:\s*$/gm
const THINKING_PREFIX_RE =
@@ -101,9 +104,23 @@ function parseMarkdownTableRow(line: string): MarkdownTableRow | null {
return { blockquoteDepth, cells }
}
function stripMarkdownTables(text: string): string {
// The header row is spoken in place of the table ("Model, Price, Context."):
// the listener learns a table is on screen and what it compares, in the reply's
// own language, without the body data being read cell by cell (#86602). A
// table with an empty header stays silent — there is nothing to announce.
function speakableTableHeader(cells: string[]): string {
const header = cells
.map(cell => cell.replace(/\\\|/g, ' ').trim())
.filter(Boolean)
.join(', ')
return header && !/[.!?:]$/.test(header) ? `${header}.` : header
}
function summarizeMarkdownTables(text: string): string {
const lines = text.replace(/\r\n?/g, '\n').split('\n')
const tableLines = new Set<number>()
const headers = new Map<number, string>()
let index = 1
@@ -125,6 +142,7 @@ function stripMarkdownTables(text: string): string {
tableLines.add(index - 1)
tableLines.add(index)
headers.set(index - 1, speakableTableHeader(headerRow.cells))
let rowIndex = index + 1
@@ -141,7 +159,17 @@ function stripMarkdownTables(text: string): string {
index = rowIndex
}
return lines.filter((_, index) => !tableLines.has(index)).join('\n')
return lines
.flatMap((line, index) => {
if (!tableLines.has(index)) {
return [line]
}
const header = headers.get(index)
return header ? [header] : []
})
.join('\n')
}
function normalizeLineBreaks(text: string): string {
@@ -156,13 +184,15 @@ function normalizeLineBreaks(text: string): string {
export function sanitizeTextForSpeech(text: string): string {
// Tables first: their right-align marker is a trailing colon (":-"), and
// closing colons before the table detector runs would mangle it.
const withoutTables = stripMarkdownTables(String(text))
const withoutTables = summarizeMarkdownTables(String(text))
// Close line-final colons BEFORE newlines are flattened: "the regex list:"
// followed by a code block keeps its colon if this runs after the flatten,
// and the voice hangs on it. Closing early turns it into "the regex list.".
const pre = withoutTables.replace(LINE_FINAL_COLON_RE, '.')
// Unspeakable tokens are silence, never a placeholder word: an English
// "code block omitted" / "link" is wrong for every non-English voice.
return normalizeLineBreaks(pre)
.replace(FENCED_CODE_RE, '')
.replace(THINKING_PREFIX_RE, ' ')

View File

@@ -61,6 +61,12 @@ def test_prepare_spoken_text_strips_media_file_links():
assert "Bye" in spoken
def test_prepare_spoken_text_keeps_sentence_break_after_inline_media_link():
spoken = prepare_spoken_text("See MEDIA:/tmp/report-2026-q3.xlsx. Then reply.")
assert "report" not in spoken
assert spoken == "See. Then reply."
def test_prepare_spoken_text_closes_trailing_colons():
# "the regex list:" + a now-removed raw token would leave the voice hanging
# on an open colon-pause (the "aaaa" stutter). Close it with a period.

View File

@@ -32,8 +32,9 @@ _MD_TABLE_PIPE_RE = re.compile(r"\s*\|\s*")
_URL_RE = re.compile(r"https?://\S+")
# Local file links ("MEDIA:/Users/me/file.xlsx") are click targets on screen, not
# speech: voices loop on the hyphenated slug ("eeeeee"). The token is silence; the
# assistant's prose already says "the files are below".
_MEDIA_PATH_RE = re.compile(r"MEDIA:\S+")
# assistant's prose already says "the files are below". Trailing sentence
# punctuation is left in place so "see MEDIA:/x.py. Then" keeps its full stop.
_MEDIA_PATH_RE = re.compile(r"MEDIA:\S+?(?=[.,;:!?)\]]*(?:\s|$))")
_DEGREE_UNITS = (("C", "Celsius"), ("F", "Fahrenheit"))
# Unit suffix (regex, after a digit) -> spoken word; km/h variants before the bare "m".