/
deka
/
buffer
Обзор
Документация
Войти
/
deka
/
buffer
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
dev
src/converters/wiki-table.js
333 строки
12 KB
Maksim Ratnikov
fix: в Excel уезжала одна таблица из десяти
10 авг 2026, 16:45
10 авг 2026, 16:45
698d06e
Код
Авторство
О чём код?
import { escapeHtml, cleanTableText } from "../util/format.js"; export function expandTableCells(cells) { const values = []; const rowspans = []; for (const cell of cells) { const colspan = Number.parseInt(cell.getAttribute("colspan") || "1", 10); const rowspan = Number.parseInt(cell.getAttribute("rowspan") || "1", 10); for (let offset = 0; offset < colspan; offset += 1) { values.push(offset === 0 ? cleanTableText(cell.innerText || cell.textContent || "") : ""); rowspans.push(rowspan); } } return { values, rowspans }; } export function nextOpenSlots(rowspans, headerWidth) { return Array.from({ length: headerWidth }, (_, index) => index) .filter((index) => (rowspans[index] || 1) <= 1); } export function appendTableCell(row, index, value) { const clean = cleanTableText(value); if (!clean) return; row[index] = row[index] ? `${row[index]}\n${clean}` : clean; } export function appendTablePayloadCell(row, index, text) { const clean = cleanTableText(text); if (!clean) return; if (!row[index]) { row[index] = { text: "", style: "" }; } row[index].text = row[index].text ? `${row[index].text}\n${clean}` : clean; } export function mergeContinuationRow(logicalRow, cells, openSlots, headerWidth) { let slotIndex = 0; let lastTarget = openSlots.at(-1) ?? headerWidth - 1; for (const cell of cells) { const colspan = Number.parseInt(cell.getAttribute("colspan") || "1", 10); for (let offset = 0; offset < colspan; offset += 1) { const text = offset === 0 ? cell.innerText || cell.textContent || "" : ""; const target = slotIndex < openSlots.length ? openSlots[slotIndex] : lastTarget; slotIndex += 1; lastTarget = target; appendTableCell(logicalRow, target, text); } } } // Таблица, вложенная в ячейку другой, не должна отдавать свои строки наружу: // querySelectorAll("tr") ищет по всему поддереву, поэтому строки отбираются по // ближайшей таблице-владельцу. export function ownRowCells(table) { return Array.from(table.querySelectorAll("tr")) .filter((row) => row.closest("table") === table) .map((row) => Array.from(row.children).filter((cell) => /^(TD|TH)$/.test(cell.tagName))); } // Ширина таблицы берётся из её первой строки, поэтому пустые строки перед // шапкой надо отбросить: иначе ширина окажется нулевой и все данные таблицы // молча исчезнут. function rowsFromHeader(table) { const rows = ownRowCells(table); const headerIndex = rows.findIndex((cells) => cells.length); return headerIndex === -1 ? [] : rows.slice(headerIndex); } export function topLevelTables(root) { return Array.from(root.querySelectorAll("table")) .filter((table) => !table.parentElement?.closest("table")); } // Ссылка на CSS-переменную вне исходной страницы не разрешается ни во что: // wiki вешает backgroundcolor="var(--editor-highlight-bgc)" на шапки таблиц, а // табличному редактору такое значение отдавать нельзя. function usableStyleValue(value) { return value && !/var\s*\(/i.test(value) ? value : ""; } export function cloneCellStyle(cell) { const allowed = new Set([ "background-color", "color", "font-weight", "font-style", "text-align", "vertical-align", "border", "border-top", "border-right", "border-bottom", "border-left", "white-space" ]); const style = []; for (const property of allowed) { const value = usableStyleValue(cell.style?.getPropertyValue(property)); if (value) { style.push(`${property}: ${value}`); } } for (const attribute of ["backgroundcolor", "data-bgc", "bgcolor"]) { const value = usableStyleValue(cell.getAttribute(attribute)); if (value && !style.some((item) => item.startsWith("background-color:"))) { style.push(`background-color: ${value}`); } } if (cell.tagName === "TH" && !style.some((item) => item.startsWith("font-weight:"))) { style.push("font-weight: 700"); } return style.join("; "); } export function expandTablePayloadCells(cells) { const values = []; const rowspans = []; for (const cell of cells) { const colspan = Number.parseInt(cell.getAttribute("colspan") || "1", 10); const rowspan = Number.parseInt(cell.getAttribute("rowspan") || "1", 10); for (let offset = 0; offset < colspan; offset += 1) { values.push({ text: offset === 0 ? cleanTableText(cell.innerText || cell.textContent || "") : "", style: offset === 0 ? cloneCellStyle(cell) : "" }); rowspans.push(rowspan); } } return { values, rowspans }; } export function mergeContinuationPayloadRow(logicalRow, cells, openSlots, headerWidth) { let slotIndex = 0; let lastTarget = openSlots.at(-1) ?? headerWidth - 1; for (const cell of cells) { const colspan = Number.parseInt(cell.getAttribute("colspan") || "1", 10); for (let offset = 0; offset < colspan; offset += 1) { const text = offset === 0 ? cell.innerText || cell.textContent || "" : ""; const target = slotIndex < openSlots.length ? openSlots[slotIndex] : lastTarget; slotIndex += 1; lastTarget = target; appendTablePayloadCell(logicalRow, target, text); } } } export function detectNumberedTableMode(domRows) { let numericFirstCells = 0; let hasRowspan = false; for (const cells of domRows.slice(1)) { const firstText = cleanTableText(cells[0]?.innerText || cells[0]?.textContent || ""); if (/^\d+$/.test(firstText)) { numericFirstCells += 1; } for (const cell of cells) { if (Number.parseInt(cell.getAttribute("rowspan") || "1", 10) > 1) { hasRowspan = true; } } } return numericFirstCells >= 2 && hasRowspan; } export function normalizeTableToRows(table) { const domRows = rowsFromHeader(table); if (!domRows.length) return []; const header = expandTableCells(domRows[0]).values; const headerWidth = header.length; const output = [header]; if (!detectNumberedTableMode(domRows)) { for (const cells of domRows.slice(1)) { const expanded = expandTableCells(cells); const row = Array.from({ length: headerWidth }, (_, index) => expanded.values[index] || ""); output.push(row); } return output; } let currentLogicalRow = null; let openSlots = []; for (const cells of domRows.slice(1)) { const firstText = cleanTableText(cells[0]?.innerText || cells[0]?.textContent || ""); if (/^\d+$/.test(firstText)) { const expanded = expandTableCells(cells); currentLogicalRow = Array.from({ length: headerWidth }, (_, index) => expanded.values[index] || ""); openSlots = nextOpenSlots(expanded.rowspans, headerWidth); output.push(currentLogicalRow); } else if (currentLogicalRow) { mergeContinuationRow(currentLogicalRow, cells, openSlots, headerWidth); } } return output; } // Шапку метит нормализация, а не сериализатор: при склейке нескольких таблиц // заголовочная строка второй и последующих уже не имеет индекса 0. function markHeaderRow(row) { return row.map((cell) => { const style = cell.style || ""; if (/font-weight/i.test(style)) return cell; return { ...cell, style: style ? `${style}; font-weight: 700` : "font-weight: 700" }; }); } function excelRowsFromTable(table) { const domRows = rowsFromHeader(table); if (!domRows.length) return []; const header = expandTablePayloadCells(domRows[0]).values; const headerWidth = header.length; const output = [header]; if (!detectNumberedTableMode(domRows)) { for (const cells of domRows.slice(1)) { const expanded = expandTablePayloadCells(cells); const row = Array.from({ length: headerWidth }, (_, index) => { return expanded.values[index] || { text: "", style: "" }; }); output.push(row); } return [markHeaderRow(output[0]), ...output.slice(1)]; } let currentLogicalRow = null; let openSlots = []; for (const cells of domRows.slice(1)) { const firstText = cleanTableText(cells[0]?.innerText || cells[0]?.textContent || ""); if (/^\d+$/.test(firstText)) { const expanded = expandTablePayloadCells(cells); currentLogicalRow = Array.from({ length: headerWidth }, (_, index) => { return expanded.values[index] || { text: "", style: "" }; }); openSlots = nextOpenSlots(expanded.rowspans, headerWidth); output.push(currentLogicalRow); } else if (currentLogicalRow) { mergeContinuationPayloadRow(currentLogicalRow, cells, openSlots, headerWidth); } } return [markHeaderRow(output[0]), ...output.slice(1)]; } export function normalizeTableToExcelPayload(table) { const rows = excelRowsFromTable(table); return { rows, html: excelHtmlFromRows(rows) }; } // Из Markdown-протокола приезжает документ с десятком таблиц. Все они // складываются в один лист: таблица за таблицей, между ними пустая строка. // Ширина выравнивается по самой широкой таблице, чтобы строгий парсер // (Р7/OnlyOffice) получил прямоугольную сетку без неоднозначных строк. export function normalizeTablesToExcelPayload(tables) { const blocks = []; for (const table of tables) { // Сериализация здесь не нужна: HTML собирается один раз, из склейки. const rows = excelRowsFromTable(table); if (rows.some((row) => row.length)) { blocks.push(rows); } } if (!blocks.length) return { rows: [], html: "", tables: 0, contentRows: 0, width: 0 }; const stacked = []; for (const rows of blocks) { if (stacked.length) stacked.push([]); stacked.push(...rows); } const width = Math.max(...stacked.map((row) => row.length)); const rows = stacked.map((row) => { return Array.from({ length: width }, (_, index) => row[index] || { text: "", style: "" }); }); return { rows, html: excelHtmlFromRows(rows), tables: blocks.length, contentRows: blocks.reduce((sum, block) => sum + block.length, 0), width }; } export function excelTsvFromRows(rows) { return rows.map((row) => row.map((cell) => { const value = typeof cell === "object" && cell !== null ? cell.text : cell; return String(value ?? "") .replace(/\r\n?/g, "\n") .replace(/\n+/g, "; ") .replace(/\t/g, " ") .trim(); }).join("\t")).join("\n"); } export function excelHtmlFromRows(rows) { // Spreadsheet editors (Excel, Google Sheets, Р7/OnlyOffice) are picky about // clipboard HTML: they expect a complete document, td-only cells and the // mso-data-placement hint so <br> stays a line break inside one cell. // Жирность шапки проставляет нормализация — здесь только сериализация. const body = rows.map((row) => { return `<tr>${row.map((cell) => { const value = typeof cell === "object" && cell !== null ? cell.text : cell; const style = typeof cell === "object" && cell !== null && cell.style ? cell.style : ""; const styleAttribute = style ? ` style="${escapeHtml(style)}"` : ""; return `<td${styleAttribute}>${escapeHtml(value).replace(/\n/g, "<br>")}</td>`; }).join("")}</tr>`; }).join(""); return `<html><head><meta charset="utf-8"><style>br { mso-data-placement: same-cell; }</style></head>` + `<body><table border="0" cellpadding="0" cellspacing="0" style="border-collapse: collapse;"><tbody>${body}</tbody></table></body></html>`; }