310 lines
10 KiB
Python
310 lines
10 KiB
Python
"""CJK/wide-character-aware re-alignment of model-emitted markdown tables.
|
|
|
|
Models pad markdown tables assuming each character occupies one terminal
|
|
cell. CJK glyphs and most emoji render as two cells, so the model's
|
|
spacing collapses into drift the moment a table reaches a real terminal —
|
|
header pipes line up, every body row drifts right by N cells per CJK
|
|
char.
|
|
|
|
This module rebuilds row padding using ``wcwidth.wcswidth`` (display
|
|
columns), preserving the table's pipes and dashes so it still reads as a
|
|
plain-text table in ``strip`` / unrendered display modes. Standard Rich
|
|
markdown rendering already aligns CJK correctly inside a wide enough
|
|
panel; this helper is for the paths that print the model's text more or
|
|
less verbatim.
|
|
|
|
The helper is deliberately conservative:
|
|
|
|
* Only contiguous ``| ... |`` blocks with a divider line are rewritten.
|
|
* Anything that does not look like a table is passed through unchanged.
|
|
* Single-line / mid-stream fragments are left alone — callers buffer
|
|
table rows and flush them once the block is complete.
|
|
|
|
There is a small, intentional caveat: ``wcwidth`` returns ``-1`` for some
|
|
emoji-with-variation-selector sequences (e.g. ``⚠️``); we clamp those to
|
|
0 so they do not corrupt the column width math. The 1-cell drift on
|
|
those specific glyphs is preferable to silently widening every table
|
|
that contains one.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import List
|
|
|
|
from wcwidth import wcswidth
|
|
|
|
__all__ = [
|
|
"is_table_divider",
|
|
"looks_like_table_row",
|
|
"realign_markdown_tables",
|
|
"split_table_row",
|
|
]
|
|
|
|
|
|
_DIVIDER_CELL_RE = re.compile(r"^\s*:?-{3,}:?\s*$")
|
|
_MIN_COL_WIDTH = 3 # matches the divider's minimum dash run.
|
|
|
|
|
|
def _disp_width(s: str) -> int:
|
|
"""``wcswidth`` clamped to a non-negative integer.
|
|
|
|
``wcswidth`` returns ``-1`` when it encounters a control char or an
|
|
unknown sequence; treat those as zero-width rather than letting a
|
|
negative number flow into ``max`` and break the column-width math.
|
|
"""
|
|
|
|
w = wcswidth(s)
|
|
return w if w > 0 else 0
|
|
|
|
|
|
def _pad_to_width(s: str, target: int) -> str:
|
|
return s + " " * max(0, target - _disp_width(s))
|
|
|
|
|
|
def split_table_row(row: str) -> List[str]:
|
|
"""Split ``| a | b | c |`` into ``["a", "b", "c"]`` with trims."""
|
|
|
|
s = row.strip()
|
|
if s.startswith("|"):
|
|
s = s[1:]
|
|
if s.endswith("|"):
|
|
s = s[:-1]
|
|
return [c.strip() for c in s.split("|")]
|
|
|
|
|
|
def is_table_divider(row: str) -> bool:
|
|
"""True when ``row`` is a markdown table separator line."""
|
|
|
|
cells = split_table_row(row)
|
|
return len(cells) > 1 and all(_DIVIDER_CELL_RE.match(c) for c in cells)
|
|
|
|
|
|
def looks_like_table_row(row: str) -> bool:
|
|
"""True when ``row`` could plausibly be a markdown table row.
|
|
|
|
Used by streaming callers to decide whether to buffer an in-flight
|
|
line. We are intentionally permissive here — the realigner itself
|
|
only rewrites blocks that are accompanied by a divider, so a false
|
|
positive here at most delays the print of one line.
|
|
"""
|
|
|
|
if "|" not in row:
|
|
return False
|
|
stripped = row.strip()
|
|
if not stripped:
|
|
return False
|
|
# A leading pipe is the strongest signal; without it we still allow
|
|
# rows with at least two pipes so models that omit the leading pipe
|
|
# don't slip past us.
|
|
if stripped.startswith("|"):
|
|
return True
|
|
return stripped.count("|") >= 2
|
|
|
|
|
|
def _render_block(rows: List[List[str]], available_width: int | None = None) -> List[str]:
|
|
"""Render ``rows`` (header + body, divider implied) at uniform widths.
|
|
|
|
If ``available_width`` is given and the rebuilt horizontal table
|
|
would exceed it, fall back to a vertical key-value rendering so
|
|
rows do not soft-wrap mid-cell — terminal soft-wrap destroys
|
|
column alignment visually even when the underlying bytes are
|
|
perfectly padded, which is exactly the "tables look broken"
|
|
user report this code path is meant to address.
|
|
"""
|
|
|
|
ncols = max(len(r) for r in rows)
|
|
rows = [r + [""] * (ncols - len(r)) for r in rows]
|
|
|
|
widths = [
|
|
max(_MIN_COL_WIDTH, *(_disp_width(r[c]) for r in rows))
|
|
for c in range(ncols)
|
|
]
|
|
|
|
# Total horizontal width for the rendered row:
|
|
# `| ` + cell + ` ` for each column, plus the final closing `|`.
|
|
horizontal_width = sum(widths) + 3 * ncols + 1
|
|
|
|
if available_width is not None and horizontal_width > max(available_width, 20):
|
|
return _render_vertical(rows, ncols, available_width)
|
|
|
|
def _row(cells: List[str]) -> str:
|
|
return (
|
|
"| "
|
|
+ " | ".join(_pad_to_width(c, widths[k]) for k, c in enumerate(cells))
|
|
+ " |"
|
|
)
|
|
|
|
out = [_row(rows[0])]
|
|
out.append("|" + "|".join("-" * (w + 2) for w in widths) + "|")
|
|
for r in rows[1:]:
|
|
out.append(_row(r))
|
|
return out
|
|
|
|
|
|
def _wrap_to_width(text: str, width: int) -> List[str]:
|
|
"""Soft-wrap ``text`` at word boundaries to fit ``width`` display cells.
|
|
|
|
Falls back to hard-breaking the longest word if a single token is
|
|
wider than ``width``. Empty input yields a single empty string so
|
|
the caller's row count stays predictable.
|
|
"""
|
|
|
|
if width <= 0 or not text:
|
|
return [text]
|
|
|
|
words = text.split()
|
|
if not words:
|
|
return [""]
|
|
|
|
lines: List[str] = []
|
|
current = ""
|
|
current_w = 0
|
|
|
|
def _hard_break(word: str, w: int) -> List[str]:
|
|
out: List[str] = []
|
|
buf = ""
|
|
bw = 0
|
|
for ch in word:
|
|
cw = _disp_width(ch) or 1
|
|
if bw + cw > w and buf:
|
|
out.append(buf)
|
|
buf = ch
|
|
bw = cw
|
|
else:
|
|
buf += ch
|
|
bw += cw
|
|
if buf:
|
|
out.append(buf)
|
|
return out
|
|
|
|
for word in words:
|
|
ww = _disp_width(word)
|
|
if not current:
|
|
if ww <= width:
|
|
current = word
|
|
current_w = ww
|
|
else:
|
|
pieces = _hard_break(word, width)
|
|
lines.extend(pieces[:-1])
|
|
current = pieces[-1] if pieces else ""
|
|
current_w = _disp_width(current)
|
|
continue
|
|
if current_w + 1 + ww <= width:
|
|
current += " " + word
|
|
current_w += 1 + ww
|
|
else:
|
|
lines.append(current)
|
|
if ww <= width:
|
|
current = word
|
|
current_w = ww
|
|
else:
|
|
pieces = _hard_break(word, width)
|
|
lines.extend(pieces[:-1])
|
|
current = pieces[-1] if pieces else ""
|
|
current_w = _disp_width(current)
|
|
if current:
|
|
lines.append(current)
|
|
return lines or [""]
|
|
|
|
|
|
def _render_vertical(
|
|
rows: List[List[str]], ncols: int, available_width: int
|
|
) -> List[str]:
|
|
"""Render a too-wide table as vertical ``Header: value`` rows.
|
|
|
|
Mirrors Claude Code's narrow-terminal fallback in
|
|
``MarkdownTable.tsx``: each body row becomes a small block of
|
|
``Header: cell-value`` lines (continuation lines indented two
|
|
spaces) separated by a thin ``─`` divider between rows. Keeps
|
|
every line narrower than ``available_width`` so the terminal does
|
|
not soft-wrap mid-cell.
|
|
"""
|
|
|
|
if not rows:
|
|
return []
|
|
|
|
headers = rows[0] + [""] * (ncols - len(rows[0]))
|
|
body = rows[1:]
|
|
|
|
labels = [h or f"Column {i + 1}" for i, h in enumerate(headers)]
|
|
|
|
sep_width = max(20, min(40, available_width - 2)) if available_width else 30
|
|
separator = "─" * sep_width
|
|
indent = " "
|
|
indent_w = _disp_width(indent)
|
|
|
|
out: List[str] = []
|
|
for ri, row in enumerate(body):
|
|
if ri > 0:
|
|
out.append(separator)
|
|
for ci in range(ncols):
|
|
label = labels[ci]
|
|
value = row[ci] if ci < len(row) else ""
|
|
label_w = _disp_width(label)
|
|
first_budget = max(10, available_width - label_w - 2)
|
|
cont_budget = max(10, available_width - indent_w)
|
|
if not value:
|
|
out.append(f"{label}:")
|
|
continue
|
|
wrapped = _wrap_to_width(value, first_budget)
|
|
out.append(f"{label}: {wrapped[0]}")
|
|
if len(wrapped) > 1:
|
|
# Re-flow continuation text at the wider continuation
|
|
# budget — words split across the narrower first-line
|
|
# budget should re-pack greedily for the rest.
|
|
cont_text = " ".join(wrapped[1:])
|
|
for cl in _wrap_to_width(cont_text, cont_budget):
|
|
if cl.strip():
|
|
out.append(f"{indent}{cl}")
|
|
return out
|
|
|
|
|
|
def realign_markdown_tables(text: str, available_width: int | None = None) -> str:
|
|
"""Rewrite every ``| ... |`` + divider block with wcwidth-aware padding.
|
|
|
|
Lines that are not part of a recognised table are returned verbatim,
|
|
so this is safe to apply to arbitrary assistant prose.
|
|
|
|
If ``available_width`` is given (terminal cells available for the
|
|
rendered table), tables wider than that are rendered as vertical
|
|
key-value pairs instead of a horizontal pipe-bordered grid. This
|
|
avoids the terminal soft-wrapping mid-cell, which destroys column
|
|
alignment visually even when the bytes are perfectly padded.
|
|
"""
|
|
|
|
if "|" not in text:
|
|
return text
|
|
|
|
lines = text.split("\n")
|
|
out: List[str] = []
|
|
i = 0
|
|
n = len(lines)
|
|
|
|
while i < n:
|
|
line = lines[i]
|
|
# A table starts with a header row whose next line is a divider.
|
|
if (
|
|
"|" in line
|
|
and i + 1 < n
|
|
and is_table_divider(lines[i + 1])
|
|
):
|
|
header = split_table_row(line)
|
|
body: List[List[str]] = []
|
|
j = i + 2
|
|
while j < n and "|" in lines[j] and lines[j].strip():
|
|
if is_table_divider(lines[j]):
|
|
j += 1
|
|
continue
|
|
body.append(split_table_row(lines[j]))
|
|
j += 1
|
|
|
|
if any(c for c in header) or body:
|
|
out.extend(_render_block([header] + body, available_width))
|
|
i = j
|
|
continue
|
|
out.append(line)
|
|
i += 1
|
|
|
|
return "\n".join(out)
|