2026-05-12 00:44:44 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
|
|
|
|
|
|
|
TABLE_ROW_RE = re.compile(r"^\|(.+)\|$")
|
2026-05-13 16:10:03 +08:00
|
|
|
|
TABLE_SEP_RE = re.compile(r"^\|[\s\-:\|]+\|$")
|
2026-05-12 00:44:44 +08:00
|
|
|
|
DIRECTIVE_RE = re.compile(r"<\s*/?directive[^>]*>", re.IGNORECASE)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def convert_markdown_tables(text: str) -> str:
|
|
|
|
|
|
"""将 Markdown 表格转换为可读的 bullets 格式。
|
|
|
|
|
|
|
|
|
|
|
|
iMessage 不支持 Markdown 表格渲染,此函数将表格行转为可读的列表。
|
|
|
|
|
|
"""
|
|
|
|
|
|
lines = text.split("\n")
|
|
|
|
|
|
result: list[str] = []
|
|
|
|
|
|
i = 0
|
|
|
|
|
|
while i < len(lines):
|
|
|
|
|
|
line = lines[i]
|
|
|
|
|
|
if i + 2 < len(lines) and TABLE_ROW_RE.match(line) and TABLE_SEP_RE.match(lines[i + 1]):
|
|
|
|
|
|
header = line
|
|
|
|
|
|
sep = lines[i + 1]
|
|
|
|
|
|
body_rows: list[str] = []
|
|
|
|
|
|
j = i + 2
|
|
|
|
|
|
while j < len(lines) and TABLE_ROW_RE.match(lines[j]):
|
|
|
|
|
|
body_rows.append(lines[j])
|
|
|
|
|
|
j += 1
|
|
|
|
|
|
|
|
|
|
|
|
converted = _table_to_bullets(header, sep, body_rows)
|
|
|
|
|
|
result.extend(converted)
|
|
|
|
|
|
i = j
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
result.append(line)
|
|
|
|
|
|
i += 1
|
|
|
|
|
|
|
|
|
|
|
|
return "\n".join(result)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _table_to_bullets(header: str, sep: str, body_rows: list[str]) -> list[str]:
|
|
|
|
|
|
header_cells = _parse_cells(header)
|
|
|
|
|
|
sep_cells = _parse_cells(sep)
|
|
|
|
|
|
|
|
|
|
|
|
if not header_cells:
|
|
|
|
|
|
return [header, sep] + body_rows
|
|
|
|
|
|
|
|
|
|
|
|
alignments = _detect_alignments(sep_cells, len(header_cells))
|
|
|
|
|
|
body_cells = [_parse_cells(row) for row in body_rows]
|
|
|
|
|
|
|
|
|
|
|
|
output: list[str] = []
|
|
|
|
|
|
for row_idx, cells in enumerate([header_cells] + body_cells):
|
|
|
|
|
|
if row_idx == 0:
|
|
|
|
|
|
output.append(f"• {', '.join(cells)}")
|
|
|
|
|
|
elif len(cells) <= 2:
|
|
|
|
|
|
output.append(" " + ": ".join(cells))
|
|
|
|
|
|
else:
|
|
|
|
|
|
parts: list[str] = []
|
|
|
|
|
|
for ci, (cell, hdr, align) in enumerate(zip(cells, header_cells, alignments)):
|
|
|
|
|
|
prefix = f" {hdr}: " if ci == 0 or align != "skip" else f" {hdr}: "
|
|
|
|
|
|
parts.append(f"{prefix}{cell}")
|
|
|
|
|
|
output.append("")
|
|
|
|
|
|
output.extend(parts)
|
|
|
|
|
|
|
|
|
|
|
|
return output
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_cells(row: str) -> list[str]:
|
|
|
|
|
|
return [cell.strip() for cell in row.strip("|").split("|")]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _detect_alignments(sep_cells: list[str], col_count: int) -> list[str]:
|
|
|
|
|
|
aligns: list[str] = []
|
|
|
|
|
|
for i in range(col_count):
|
|
|
|
|
|
if i < len(sep_cells):
|
|
|
|
|
|
cell = sep_cells[i]
|
|
|
|
|
|
left = cell.startswith(":")
|
|
|
|
|
|
right = cell.endswith(":")
|
|
|
|
|
|
if left and right:
|
|
|
|
|
|
aligns.append("center")
|
|
|
|
|
|
elif right:
|
|
|
|
|
|
aligns.append("right")
|
|
|
|
|
|
else:
|
|
|
|
|
|
aligns.append("left")
|
|
|
|
|
|
else:
|
|
|
|
|
|
aligns.append("left")
|
|
|
|
|
|
return aligns
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def sanitize_imessage_text(text: str) -> str:
|
|
|
|
|
|
"""净化出站文本,移除 iMessage 中不支持的格式。
|
|
|
|
|
|
|
|
|
|
|
|
- 去除 \\r 字符(换行标准化)
|
|
|
|
|
|
- 去除 <directive> 内联指令标签
|
|
|
|
|
|
- 去除长度前缀损坏(如 \"123\\nThis is message\")
|
|
|
|
|
|
- 过滤控制字符
|
|
|
|
|
|
- 合并多余空白行
|
|
|
|
|
|
"""
|
|
|
|
|
|
text = text.replace("\r", "")
|
|
|
|
|
|
|
|
|
|
|
|
text = DIRECTIVE_RE.sub("", text)
|
|
|
|
|
|
|
|
|
|
|
|
text = _strip_length_prefix(text)
|
|
|
|
|
|
|
|
|
|
|
|
text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]", "", text)
|
|
|
|
|
|
|
|
|
|
|
|
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
|
|
|
|
text = text.strip()
|
|
|
|
|
|
|
|
|
|
|
|
return text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _strip_length_prefix(text: str) -> str:
|
|
|
|
|
|
"""去除 iMessage 长度前缀损坏格式。
|
|
|
|
|
|
|
|
|
|
|
|
BlueBubbles 可能在某些消息前附加长度前缀如 \"42\\nHello world\",
|
|
|
|
|
|
去除数字换行前缀。
|
|
|
|
|
|
"""
|
|
|
|
|
|
match = re.match(r"^(\d+)\n", text)
|
|
|
|
|
|
if match:
|
|
|
|
|
|
prefix_len = int(match.group(1))
|
|
|
|
|
|
remaining = text[match.end() :]
|
|
|
|
|
|
if abs(len(remaining) - prefix_len) <= 5:
|
|
|
|
|
|
return remaining
|
|
|
|
|
|
return text
|