319 lines
9.4 KiB
TypeScript
319 lines
9.4 KiB
TypeScript
|
|
const BAIJIAHAO_TABLE_STYLE = [
|
||
|
|
"width: 100%",
|
||
|
|
"border-collapse: collapse",
|
||
|
|
"border-spacing: 0",
|
||
|
|
"margin: 12px 0",
|
||
|
|
"font-size: 15px",
|
||
|
|
"line-height: 1.6",
|
||
|
|
"color: #1f2937",
|
||
|
|
].join("; ");
|
||
|
|
|
||
|
|
const BAIJIAHAO_TABLE_CELL_STYLE = [
|
||
|
|
"border: 1px solid #d9d9d9",
|
||
|
|
"padding: 8px 10px",
|
||
|
|
"vertical-align: middle",
|
||
|
|
"word-break: break-word",
|
||
|
|
"min-width: 64px",
|
||
|
|
].join("; ");
|
||
|
|
|
||
|
|
const BAIJIAHAO_TABLE_HEADER_CELL_STYLE = [
|
||
|
|
BAIJIAHAO_TABLE_CELL_STYLE,
|
||
|
|
"background-color: #f5f7fa",
|
||
|
|
"font-weight: 600",
|
||
|
|
].join("; ");
|
||
|
|
|
||
|
|
const BAIJIAHAO_FAUX_TABLE_STYLE = [
|
||
|
|
"width: 100%",
|
||
|
|
"margin: 12px 0",
|
||
|
|
"border-top: 1px solid #d9d9d9",
|
||
|
|
"border-left: 1px solid #d9d9d9",
|
||
|
|
"overflow-x: auto",
|
||
|
|
].join("; ");
|
||
|
|
|
||
|
|
const BAIJIAHAO_FAUX_TABLE_ROW_STYLE = [
|
||
|
|
"display: flex",
|
||
|
|
"align-items: stretch",
|
||
|
|
"width: 100%",
|
||
|
|
].join("; ");
|
||
|
|
|
||
|
|
const BAIJIAHAO_FAUX_TABLE_CELL_STYLE = [
|
||
|
|
"flex: 1 1 0",
|
||
|
|
"min-width: 96px",
|
||
|
|
"box-sizing: border-box",
|
||
|
|
"border-right: 1px solid #d9d9d9",
|
||
|
|
"border-bottom: 1px solid #d9d9d9",
|
||
|
|
"padding: 8px 10px",
|
||
|
|
"vertical-align: middle",
|
||
|
|
"word-break: break-word",
|
||
|
|
"font-size: 15px",
|
||
|
|
"line-height: 1.6",
|
||
|
|
"color: #1f2937",
|
||
|
|
"background: #ffffff",
|
||
|
|
].join("; ");
|
||
|
|
|
||
|
|
const BAIJIAHAO_FAUX_TABLE_HEADER_CELL_STYLE = [
|
||
|
|
BAIJIAHAO_FAUX_TABLE_CELL_STYLE,
|
||
|
|
"background: #f5f7fa",
|
||
|
|
"font-weight: 600",
|
||
|
|
].join("; ");
|
||
|
|
|
||
|
|
const BAIJIAHAO_TABLE_ALLOWED_ATTRS = ["colspan", "rowspan", "align", "valign", "width"];
|
||
|
|
|
||
|
|
interface ParsedTableCell {
|
||
|
|
content: string;
|
||
|
|
header: boolean;
|
||
|
|
colSpan: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
type ParsedTableRow = ParsedTableCell[];
|
||
|
|
|
||
|
|
function hasUnescapedPipe(value: string): boolean {
|
||
|
|
let escaped = false;
|
||
|
|
for (const char of value) {
|
||
|
|
if (escaped) {
|
||
|
|
escaped = false;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if (char === "\\") {
|
||
|
|
escaped = true;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if (char === "|") {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
function splitMarkdownTableCells(value: string): string[] {
|
||
|
|
const cells: string[] = [];
|
||
|
|
let cell = "";
|
||
|
|
let escaped = false;
|
||
|
|
const trimmed = value.trim();
|
||
|
|
const source = trimmed.startsWith("|") ? trimmed.slice(1) : trimmed;
|
||
|
|
const content = source.endsWith("|") && !source.endsWith("\\|") ? source.slice(0, -1) : source;
|
||
|
|
|
||
|
|
for (const char of content) {
|
||
|
|
if (escaped) {
|
||
|
|
cell += char;
|
||
|
|
escaped = false;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if (char === "\\") {
|
||
|
|
escaped = true;
|
||
|
|
cell += char;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if (char === "|") {
|
||
|
|
cells.push(cell.trim());
|
||
|
|
cell = "";
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
cell += char;
|
||
|
|
}
|
||
|
|
|
||
|
|
cells.push(cell.trim());
|
||
|
|
return cells;
|
||
|
|
}
|
||
|
|
|
||
|
|
function isMarkdownTableRow(value: string): boolean {
|
||
|
|
const trimmed = value.trim();
|
||
|
|
return trimmed !== "" && hasUnescapedPipe(trimmed) && splitMarkdownTableCells(trimmed).length >= 2;
|
||
|
|
}
|
||
|
|
|
||
|
|
function isMarkdownTableSeparator(value: string): boolean {
|
||
|
|
const cells = splitMarkdownTableCells(value).filter(Boolean);
|
||
|
|
return cells.length >= 2 && cells.every((cell) => /^:?-{2,}:?$/.test(cell.trim()));
|
||
|
|
}
|
||
|
|
|
||
|
|
function isMarkdownTableStart(lines: string[], index: number): boolean {
|
||
|
|
return isMarkdownTableRow(lines[index] ?? "") && isMarkdownTableSeparator(lines[index + 1] ?? "");
|
||
|
|
}
|
||
|
|
|
||
|
|
function isFenceBoundary(value: string): boolean {
|
||
|
|
return /^\s*(```|~~~)/.test(value);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function prepareBaijiahaoMarkdown(markdown: string): string {
|
||
|
|
const source = markdown.replace(/\r\n?/g, "\n").trim();
|
||
|
|
if (!source) {
|
||
|
|
return "";
|
||
|
|
}
|
||
|
|
|
||
|
|
const lines = source.split("\n");
|
||
|
|
const output: string[] = [];
|
||
|
|
let inCodeFence = false;
|
||
|
|
|
||
|
|
for (let index = 0; index < lines.length; index += 1) {
|
||
|
|
const line = lines[index] ?? "";
|
||
|
|
if (isFenceBoundary(line)) {
|
||
|
|
inCodeFence = !inCodeFence;
|
||
|
|
output.push(line);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!inCodeFence && isMarkdownTableStart(lines, index)) {
|
||
|
|
while (output.length > 0 && output[output.length - 1]?.trim() === "") {
|
||
|
|
output.pop();
|
||
|
|
}
|
||
|
|
if (/^\s*#{1,6}\s*$/.test(output[output.length - 1] ?? "")) {
|
||
|
|
output.pop();
|
||
|
|
}
|
||
|
|
if (output.length > 0 && output[output.length - 1]?.trim() !== "") {
|
||
|
|
output.push("");
|
||
|
|
}
|
||
|
|
|
||
|
|
output.push(lines[index] ?? "");
|
||
|
|
output.push(lines[index + 1] ?? "");
|
||
|
|
index += 2;
|
||
|
|
|
||
|
|
while (index < lines.length && isMarkdownTableRow(lines[index] ?? "")) {
|
||
|
|
output.push(lines[index] ?? "");
|
||
|
|
index += 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (index < lines.length && lines[index]?.trim() !== "") {
|
||
|
|
output.push("");
|
||
|
|
}
|
||
|
|
index -= 1;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
output.push(line);
|
||
|
|
}
|
||
|
|
|
||
|
|
return output.join("\n").trim();
|
||
|
|
}
|
||
|
|
|
||
|
|
function escapeAttribute(value: string): string {
|
||
|
|
return value
|
||
|
|
.replace(/&/g, "&")
|
||
|
|
.replace(/"/g, """)
|
||
|
|
.replace(/</g, "<")
|
||
|
|
.replace(/>/g, ">");
|
||
|
|
}
|
||
|
|
|
||
|
|
function extractAttribute(sourceTag: string, attributeName: string): string | null {
|
||
|
|
const matcher = new RegExp(`\\s${attributeName}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, "i");
|
||
|
|
const match = matcher.exec(sourceTag);
|
||
|
|
const value = match?.[1] ?? match?.[2] ?? match?.[3] ?? "";
|
||
|
|
const trimmed = value.trim();
|
||
|
|
return trimmed ? trimmed : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function allowedTableAttributes(sourceTag: string): string {
|
||
|
|
return BAIJIAHAO_TABLE_ALLOWED_ATTRS
|
||
|
|
.map((attributeName) => {
|
||
|
|
const value = extractAttribute(sourceTag, attributeName);
|
||
|
|
return value ? `${attributeName}="${escapeAttribute(value)}"` : "";
|
||
|
|
})
|
||
|
|
.filter(Boolean)
|
||
|
|
.join(" ");
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeCellContent(value: string): string {
|
||
|
|
const trimmed = value.trim();
|
||
|
|
return trimmed ? trimmed : "<br/>";
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeSpanAttribute(sourceTag: string, attributeName: "colspan" | "rowspan"): number {
|
||
|
|
const value = extractAttribute(sourceTag, attributeName);
|
||
|
|
const parsed = value ? Number.parseInt(value, 10) : 1;
|
||
|
|
return Number.isFinite(parsed) && parsed > 1 ? Math.min(parsed, 12) : 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeTableCell(sourceTag: string, content: string, header: boolean): string {
|
||
|
|
const attrs = allowedTableAttributes(sourceTag);
|
||
|
|
const attrPrefix = attrs ? `${attrs} ` : "";
|
||
|
|
const style = header ? BAIJIAHAO_TABLE_HEADER_CELL_STYLE : BAIJIAHAO_TABLE_CELL_STYLE;
|
||
|
|
const normalizedContent = normalizeCellContent(content);
|
||
|
|
const cellContent = header ? `<strong>${normalizedContent}</strong>` : normalizedContent;
|
||
|
|
return `<td ${attrPrefix}style="${style}">${cellContent}</td>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeSingleTable(tableHtml: string): string {
|
||
|
|
let body = tableHtml
|
||
|
|
.replace(/^\s*<table\b[^>]*>/i, "")
|
||
|
|
.replace(/<\/table>\s*$/i, "")
|
||
|
|
.replace(/<caption\b[^>]*>[\s\S]*?<\/caption>/gi, "")
|
||
|
|
.replace(/<colgroup\b[^>]*>[\s\S]*?<\/colgroup>/gi, "")
|
||
|
|
.replace(/<col\b[^>]*\/?>/gi, "")
|
||
|
|
.replace(/<\/?(?:thead|tbody|tfoot)\b[^>]*>/gi, "");
|
||
|
|
|
||
|
|
body = body.replace(/<th\b([^>]*)>([\s\S]*?)<\/th>/gi, (_match, attrs: string, content: string) =>
|
||
|
|
normalizeTableCell(`<th${attrs}>`, content, true),
|
||
|
|
);
|
||
|
|
body = body.replace(/<td\b([^>]*)>([\s\S]*?)<\/td>/gi, (_match, attrs: string, content: string) =>
|
||
|
|
normalizeTableCell(`<td${attrs}>`, content, false),
|
||
|
|
);
|
||
|
|
|
||
|
|
let rowIndex = 0;
|
||
|
|
body = body.replace(/<tr\b[^>]*>/gi, () => {
|
||
|
|
rowIndex += 1;
|
||
|
|
return rowIndex === 1 ? `<tr class="firstRow">` : "<tr>";
|
||
|
|
});
|
||
|
|
|
||
|
|
return `<table data-sort="sortDisabled" border="1" cellpadding="0" cellspacing="0" style="${BAIJIAHAO_TABLE_STYLE}"><tbody>${body.trim()}</tbody></table>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function normalizeBaijiahaoTables(html: string): string {
|
||
|
|
if (!/<table\b/i.test(html)) {
|
||
|
|
return html;
|
||
|
|
}
|
||
|
|
return html.replace(/<table\b[^>]*>[\s\S]*?<\/table>/gi, (tableHtml) => normalizeSingleTable(tableHtml));
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseTableRows(tableHtml: string): ParsedTableRow[] {
|
||
|
|
const rows: ParsedTableRow[] = [];
|
||
|
|
for (const rowMatch of tableHtml.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)) {
|
||
|
|
const cells: ParsedTableRow = [];
|
||
|
|
const rowContent = rowMatch[1] ?? "";
|
||
|
|
for (const cellMatch of rowContent.matchAll(/<(th|td)\b([^>]*)>([\s\S]*?)<\/\1>/gi)) {
|
||
|
|
const sourceTag = `<${cellMatch[1]}${cellMatch[2] ?? ""}>`;
|
||
|
|
cells.push({
|
||
|
|
content: normalizeCellContent(cellMatch[3] ?? ""),
|
||
|
|
header: cellMatch[1]?.toLowerCase() === "th",
|
||
|
|
colSpan: normalizeSpanAttribute(sourceTag, "colspan"),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
if (cells.length > 0) {
|
||
|
|
rows.push(cells);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return rows;
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderFauxTableCell(cell: ParsedTableCell, rowIndex: number): string {
|
||
|
|
const header = cell.header || rowIndex === 0;
|
||
|
|
const baseStyle = header ? BAIJIAHAO_FAUX_TABLE_HEADER_CELL_STYLE : BAIJIAHAO_FAUX_TABLE_CELL_STYLE;
|
||
|
|
const style = `${baseStyle}; flex: ${cell.colSpan} ${cell.colSpan} 0`;
|
||
|
|
const content = header ? `<strong>${cell.content}</strong>` : cell.content;
|
||
|
|
return `<div style="${style}">${content}</div>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderSingleTableAsBlocks(tableHtml: string): string {
|
||
|
|
const rows = parseTableRows(tableHtml);
|
||
|
|
if (rows.length === 0) {
|
||
|
|
return "";
|
||
|
|
}
|
||
|
|
|
||
|
|
return `<div style="${BAIJIAHAO_FAUX_TABLE_STYLE}">${rows
|
||
|
|
.map((row, rowIndex) => `<div style="${BAIJIAHAO_FAUX_TABLE_ROW_STYLE}">${row
|
||
|
|
.map((cell) => renderFauxTableCell(cell, rowIndex))
|
||
|
|
.join("")}</div>`)
|
||
|
|
.join("")}</div>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function renderBaijiahaoTablesAsBlocks(html: string): string {
|
||
|
|
if (!/<table\b/i.test(html)) {
|
||
|
|
return html;
|
||
|
|
}
|
||
|
|
return html.replace(/<table\b[^>]*>[\s\S]*?<\/table>/gi, (tableHtml) => renderSingleTableAsBlocks(tableHtml));
|
||
|
|
}
|
||
|
|
|
||
|
|
export function prepareBaijiahaoArticleHtml(html: string): string {
|
||
|
|
// Baijiahao's preview/final article page does not consistently render native table tags,
|
||
|
|
// so publish uses ordinary div blocks to preserve visible tabular content.
|
||
|
|
return renderBaijiahaoTablesAsBlocks(html).trim();
|
||
|
|
}
|