/
deka
/
buffer
Обзор
Документация
Войти
/
deka
/
buffer
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
dev
src/converters/html-to-markdown.js
647 строк
20 KB
Maksim Ratnikov
fix: таблица «поле/значение» с вложенными таблицами переживает html to markdown
04 авг 2026, 14:14
04 авг 2026, 14:14
0a06bef
Код
Авторство
О чём код?
import { formatHtml } from "../util/format.js"; export function normalizeMarkdownText(value) { return String(value ?? "") .replace(/\u00a0/g, " ") .replace(/[ \t\f\v\r\n]+/g, " ") .trim(); } export function escapeMarkdownText(value) { return normalizeMarkdownText(value) .replace(/\\/g, "\\\\") .replace(/\*/g, "\\*") .replace(/_/g, "\\_") .replace(/`/g, "\\`") .replace(/\[/g, "\\[") .replace(/\]/g, "\\]"); } export function escapeMarkdownInlineText(value) { return String(value ?? "") .replace(/\u00a0/g, " ") .replace(/[ \t\f\v\r\n]+/g, " ") .replace(/\\/g, "\\\\") .replace(/\*/g, "\\*") .replace(/_/g, "\\_") .replace(/`/g, "\\`") .replace(/\[/g, "\\[") .replace(/\]/g, "\\]"); } // Input already went through escapeMarkdownInlineText, so backslashes must not be escaped again. export function escapeMarkdownTableCell(value) { return String(value ?? "") .replace(/\u00a0/g, " ") .replace(/\r\n?/g, "\n") .replace(/[ \t\f\v]+/g, " ") .replace(/ ?\n ?/g, "\n") .replace(/\|/g, "\\|") .trim() .replace(/\n+/g, "<br>"); } export function markdownInlineFromNode(node) { if (node.nodeType === Node.TEXT_NODE) { return escapeMarkdownInlineText(node.textContent); } if (node.nodeType !== Node.ELEMENT_NODE) { return ""; } if (isIgnoredMarkdownElement(node)) { return ""; } const tag = node.tagName.toLowerCase(); switch (tag) { case "br": return " \n"; case "strong": case "b": return `**${markdownInlineChildren(node)}**`; case "em": case "i": return `_${markdownInlineChildren(node)}_`; case "del": case "s": case "strike": { const content = markdownInlineChildren(node); return content ? `~~${content}~~` : ""; } case "cite": case "dfn": case "var": { const content = markdownInlineChildren(node); return content ? `_${content}_` : ""; } case "code": return markdownInlineCode(node.textContent || ""); case "kbd": case "samp": return markdownInlineCode(node.textContent || ""); case "sub": case "sup": case "u": case "ins": { const content = markdownInlineChildren(node); return content ? `<${tag}>${content}</${tag}>` : ""; } case "q": { const content = markdownInlineChildren(node); return content ? `"${content}"` : ""; } case "abbr": { const content = markdownInlineChildren(node); const title = escapeMarkdownInlineText(node.getAttribute("title") || "").trim(); return content && title && title !== content ? `${content} (${title})` : content; } case "time": return markdownInlineChildren(node) || escapeMarkdownInlineText(node.getAttribute("datetime") || "").trim(); case "a": return markdownLinkFromElement(node); case "img": return markdownImageFromElement(node); case "audio": case "video": case "iframe": return markdownMediaFromElement(node); case "input": return node.getAttribute("type")?.toLowerCase() === "checkbox" ? `[${node.checked || node.hasAttribute("checked") ? "x" : " "}]` : ""; case "wbr": return ""; default: return markdownInlineChildren(node); } } export function joinMarkdownInlineParts(parts) { return parts.filter(Boolean).reduce((output, part) => { if (!output) return part; if (/\s$/.test(output) || /^\s/.test(part)) return `${output}${part}`; if (/^[,.;:!?%)\]}»]/.test(part)) return `${output}${part}`; if (/[({\[«]$/.test(output)) return `${output}${part}`; return `${output} ${part}`; }, ""); } export function markdownInlineChildren(node) { return joinMarkdownInlineParts(Array.from(node.childNodes).map(markdownInlineFromNode)) .replace(/[ \t]+/g, " ") .replace(/ ?\n ?/g, "\n") .replace(/\s+(<(?:sub|sup)>)/gi, "$1") .trim(); } export function markdownInlineCode(value) { const text = String(value ?? "").replace(/\s+/g, " ").trim(); if (!text) return ""; const fence = text.includes("`") ? "``" : "`"; return `${fence}${text}${fence}`; } export function markdownLinkFromElement(node) { const text = markdownInlineChildren(node) || normalizeMarkdownText(node.getAttribute("href") || ""); const href = normalizeMarkdownText(node.getAttribute("href") || ""); return href ? `[${text}](${href.replace(/\)/g, "%29")})` : text; } export function markdownImageFromElement(node) { const alt = escapeMarkdownText(node.getAttribute("alt") || ""); const src = normalizeMarkdownText(node.getAttribute("src") || ""); return src ? `/g, "%29")})` : ""; } export function markdownMediaFromElement(node) { const tag = node.tagName.toLowerCase(); const source = node.getAttribute("src") || node.querySelector("source[src]")?.getAttribute("src") || ""; const src = normalizeMarkdownText(source); const fallback = markdownInlineChildren(node); const label = fallback || escapeMarkdownText(node.getAttribute("title") || "") || ({ audio: "Audio", video: "Video", iframe: "Embedded content" }[tag] || "Media"); return src ? `[${label}](${src.replace(/\)/g, "%29")})` : fallback; } export function markdownBlocksFromChildren(node) { return Array.from(node.childNodes) .map(markdownBlockFromNode) .filter(Boolean) .join("\n\n") .replace(/\n{3,}/g, "\n\n") .trim(); } export function isIgnoredMarkdownElement(node) { if (node.nodeType !== Node.ELEMENT_NODE) return false; const tag = node.tagName.toLowerCase(); const style = node.getAttribute("style") || ""; return tag === "head" || tag === "style" || tag === "script" || tag === "template" || tag === "meta" || tag === "link" || tag === "title" || node.hidden || node.getAttribute("aria-hidden") === "true" || /display\s*:\s*none/i.test(style); } export function hasBlockMarkdownChildren(node) { const blockTags = new Set([ "address", "article", "aside", "blockquote", "body", "details", "dialog", "div", "dl", "fieldset", "figure", "figcaption", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hgroup", "hr", "html", "main", "menu", "nav", "ol", "p", "pre", "search", "section", "summary", "table", "ul" ]); return Array.from(node.children || []).some((child) => { return blockTags.has(child.tagName.toLowerCase()); }); } export function markdownParagraphFromElement(node) { const inline = markdownInlineChildren(node); return inline || markdownBlocksFromChildren(node); } export function markdownParagraphOrHeadingFromElement(node) { const inline = markdownInlineChildren(node); if (!inline) { return markdownBlocksFromChildren(node); } const style = node.getAttribute("style") || ""; const size = Number.parseFloat(style.match(/font-size\s*:\s*([\d.]+)px/i)?.[1] || ""); const bold = /font-weight\s*:\s*(bold|[6-9]00)/i.test(style); if (bold && size >= 24) { return `# ${inline}`; } if (bold && size >= 18) { return `## ${inline}`; } if (bold && size >= 16) { return `### ${inline}`; } return inline; } export function markdownPreFromElement(node) { const code = node.textContent.replace(/\n+$/g, ""); const fence = code.includes("```") ? "````" : "```"; return `${fence}\n${code}\n${fence}`; } export function markdownListFromElement(node, depth = 0) { const ordered = node.tagName.toLowerCase() === "ol"; const items = Array.from(node.children).filter((child) => child.tagName?.toLowerCase() === "li"); const reversed = ordered && node.hasAttribute("reversed"); const parsedStart = Number.parseInt(node.getAttribute("start") || "", 10); const start = Number.isFinite(parsedStart) ? parsedStart : (reversed ? items.length : 1); return items.map((item, index) => { const marker = ordered ? `${start + index * (reversed ? -1 : 1)}. ` : "- "; const nestedLists = Array.from(item.children).filter((child) => { const tag = child.tagName?.toLowerCase(); return tag === "ul" || tag === "ol" || tag === "menu"; }); const checkbox = Array.from(item.children).find((child) => { return child.tagName?.toLowerCase() === "input" && child.getAttribute("type")?.toLowerCase() === "checkbox"; }); const contentNodes = Array.from(item.childNodes).filter((child) => { return !nestedLists.includes(child) && child !== checkbox; }); const content = joinMarkdownInlineParts(contentNodes.map(markdownInlineFromNode)) .replace(/\s+/g, " ") .trim(); const task = checkbox ? `[${checkbox.checked || checkbox.hasAttribute("checked") ? "x" : " "}] ` : ""; const nested = nestedLists.map((list) => markdownListFromElement(list, depth + 1)).filter(Boolean).join("\n"); const indent = " ".repeat(depth); return `${indent}${marker}${task}${content}${nested ? `\n${nested}` : ""}`; }).join("\n"); } export function markdownDefinitionListFromElement(node) { const output = []; let terms = []; let termGroupUsed = false; for (const child of node.children) { const tag = child.tagName.toLowerCase(); if (tag === "dt") { if (termGroupUsed) { terms = []; termGroupUsed = false; } const term = markdownInlineChildren(child); if (term) terms.push(term); continue; } if (tag !== "dd") continue; const definition = markdownBlocksFromChildren(child) || markdownInlineChildren(child); if (!definition) continue; const indented = definition.replace(/\n/g, "\n "); const label = terms.length ? `**${terms.join(", ")}:** ` : ""; output.push(`- ${label}${indented}`); termGroupUsed = true; } if (terms.length && !termGroupUsed) { output.push(`- **${terms.join(", ")}**`); } return output.join("\n"); } export function markdownDetailsFromElement(node) { const summary = Array.from(node.children).find((child) => { return child.tagName?.toLowerCase() === "summary"; }); const summaryText = summary ? markdownInlineChildren(summary) : ""; const body = Array.from(node.childNodes) .filter((child) => child !== summary) .map(markdownBlockFromNode) .filter(Boolean) .join("\n\n") .replace(/\n{3,}/g, "\n\n") .trim(); return [summaryText ? `**${summaryText}**` : "", body].filter(Boolean).join("\n\n"); } export function markdownFigureFromElement(node) { const caption = Array.from(node.children).find((child) => { return child.tagName?.toLowerCase() === "figcaption"; }); const body = Array.from(node.childNodes) .filter((child) => child !== caption) .map(markdownBlockFromNode) .filter(Boolean) .join("\n\n") .trim(); const captionText = caption ? markdownInlineChildren(caption) : ""; return [body, captionText ? `_${captionText}_` : ""].filter(Boolean).join("\n\n"); } export function markdownBlockquoteFromElement(node) { const content = markdownBlocksFromChildren(node) || markdownInlineChildren(node); return content.split("\n").map((line) => `> ${line}`).join("\n"); } export function directTableRows(table) { const rows = []; for (const child of table.children) { const tag = child.tagName.toLowerCase(); if (tag === "tr") { rows.push(child); } else if (tag === "thead" || tag === "tbody" || tag === "tfoot") { rows.push(...Array.from(child.children).filter((row) => row.tagName?.toLowerCase() === "tr")); } } return rows; } export function directRowCells(row) { return Array.from(row.children).filter((cell) => /^(TD|TH)$/.test(cell.tagName)); } export function isMarkdownDataTable(table, rows) { if (table.classList.contains("page-content") || table.getAttribute("role") === "presentation") { return false; } const cellRows = rows.map(directRowCells).filter((row) => row.length); if (cellRows.length < 2) { return false; } if (cellRows.some((row) => row.some((cell) => cell.querySelector("table")))) { return false; } const maxColumns = Math.max(...cellRows.map((row) => row.length)); if (maxColumns < 2) { return false; } return cellRows.some((row) => row.some((cell) => cell.tagName === "TH")) || cellRows[0].length === maxColumns || /border-collapse\s*:\s*collapse|table-layout\s*:\s*fixed/i.test(table.getAttribute("style") || ""); } export function isRawHtmlFallbackTable(table) { const className = table.className || ""; if (/(infobox|navbox|metadata|ambox|mbox|vertical-navbox)/i.test(String(className))) { return false; } return true; } export function markdownLayoutTableFromElement(table, rows) { return rows.map((row) => { return directRowCells(row) .map((cell) => markdownBlocksFromChildren(cell) || markdownInlineChildren(cell)) .filter(Boolean) .join("\n\n"); }).filter(Boolean).join("\n\n"); } export function tableCellSpans(cell) { return Number.parseInt(cell.getAttribute("colspan") || "1", 10) !== 1 || Number.parseInt(cell.getAttribute("rowspan") || "1", 10) !== 1; } export function isPropertySheetTable(table, rows) { if (table.classList.contains("page-content") || table.getAttribute("role") === "presentation") { return false; } const cellRows = rows.map(directRowCells).filter((row) => row.length); if (cellRows.length < 2) return false; if (!cellRows.every((row) => row.length === 2 && row[0].tagName === "TH")) return false; if (cellRows.some((row) => row.some(tableCellSpans))) return false; return cellRows.some((row) => markdownBlocksFromChildren(row[1]).includes("\n")); } export function propertySheetDataRows(cellRows) { const headerRow = cellRows[0].every((cell) => cell.tagName === "TH") && cellRows.slice(1).some((row) => row.some((cell) => cell.tagName === "TD")); return headerRow ? cellRows.slice(1) : cellRows; } export function markdownPropertySheetFromElement(table, rows) { const cellRows = rows.map(directRowCells).filter((row) => row.length); return propertySheetDataRows(cellRows).map(([labelCell, valueCell]) => { const label = markdownInlineChildren(labelCell); const value = markdownBlocksFromChildren(valueCell) || markdownInlineChildren(valueCell); return [label ? `## ${label}` : "", value].filter(Boolean).join("\n\n"); }).filter(Boolean).join("\n\n"); } export function markdownTableWithCaption(table, markdown) { const caption = Array.from(table.children).find((child) => { return child.tagName?.toLowerCase() === "caption"; }); const captionText = caption ? markdownInlineChildren(caption) : ""; return [captionText ? `_${captionText}_` : "", markdown].filter(Boolean).join("\n\n"); } export function markdownTableFromElement(table) { const directRows = directTableRows(table); if (isPropertySheetTable(table, directRows)) { return markdownTableWithCaption(table, markdownPropertySheetFromElement(table, directRows)); } if (!isMarkdownDataTable(table, directRows)) { return markdownTableWithCaption(table, markdownLayoutTableFromElement(table, directRows)); } const rows = directRows.map((row) => { return directRowCells(row) .map((cell) => ({ text: markdownInlineChildren(cell), header: cell.tagName === "TH", colspan: Number.parseInt(cell.getAttribute("colspan") || "1", 10), rowspan: Number.parseInt(cell.getAttribute("rowspan") || "1", 10) })); }).filter((row) => row.length); if (!rows.length) return markdownTableWithCaption(table, ""); const simple = rows.every((row) => row.every((cell) => cell.colspan === 1 && cell.rowspan === 1)); if (!simple) { return isRawHtmlFallbackTable(table) ? formatHtml(table.outerHTML) : markdownTableWithCaption(table, markdownLayoutTableFromElement(table, directRows)); } const width = Math.max(...rows.map((row) => row.length)); const normalizedRows = rows.map((row) => Array.from({ length: width }, (_, index) => row[index]?.text ?? "")); const firstRowIsHeader = rows[0].some((cell) => cell.header) || rows.length > 1 && rows[0].length === width && normalizedRows[0].every(Boolean); const header = firstRowIsHeader ? normalizedRows[0] : normalizedRows[0].map((_, index) => `Column ${index + 1}`); const body = firstRowIsHeader ? normalizedRows.slice(1) : normalizedRows; const line = (cells) => `| ${cells.map(escapeMarkdownTableCell).join(" | ")} |`; const markdown = [ line(header), line(header.map(() => "---")), ...body.map(line) ].join("\n"); return markdownTableWithCaption(table, markdown); } export function markdownContentRoot(fragment) { const selectors = [ { selector: "#mw-content-text .mw-parser-output", minRatio: 0 }, { selector: ".body[role='main']", minRatio: 0 }, { selector: ".document", minRatio: 0 }, { selector: "#content .document", minRatio: 0 }, { selector: "#content article", minRatio: 0 }, { selector: "#content main", minRatio: 0 }, { selector: "article", minRatio: 0.2 }, { selector: "[role='main']", minRatio: 0.2 }, { selector: "main", minRatio: 0.35 }, { selector: "#content", minRatio: 0.35 }, { selector: ".content", minRatio: 0.35 } ]; const totalLength = normalizeMarkdownText(fragment.textContent || "").length; for (const { selector, minRatio } of selectors) { let best = null; let bestLength = 0; for (const candidate of fragment.querySelectorAll(selector)) { if (isIgnoredMarkdownElement(candidate)) continue; const length = normalizeMarkdownText(candidate.textContent || "").length; if (totalLength && length < totalLength * minRatio) continue; if (length > bestLength) { best = candidate; bestLength = length; } } if (best) return best; } return fragment; } export function markdownBlockFromNode(node) { if (node.nodeType === Node.TEXT_NODE) { return escapeMarkdownText(node.textContent); } if (node.nodeType !== Node.ELEMENT_NODE) { return ""; } if (isIgnoredMarkdownElement(node)) { return ""; } const tag = node.tagName.toLowerCase(); switch (tag) { case "h1": case "h2": case "h3": case "h4": case "h5": case "h6": { const level = Number.parseInt(tag.slice(1), 10); const text = markdownInlineChildren(node); return text ? `${"#".repeat(level)} ${text}` : ""; } case "p": return markdownParagraphOrHeadingFromElement(node); case "figcaption": case "caption": { const content = markdownInlineChildren(node); return content ? `_${content}_` : ""; } case "summary": case "legend": { const content = markdownInlineChildren(node); return content ? `**${content}**` : ""; } case "br": return ""; case "hr": return "---"; case "pre": return markdownPreFromElement(node); case "blockquote": return markdownBlockquoteFromElement(node); case "ul": case "ol": case "menu": return markdownListFromElement(node); case "dl": return markdownDefinitionListFromElement(node); case "details": return markdownDetailsFromElement(node); case "figure": return markdownFigureFromElement(node); case "table": return markdownTableFromElement(node); case "audio": case "video": case "iframe": return markdownMediaFromElement(node); case "html": case "body": case "main": case "article": case "section": case "div": case "header": case "footer": case "address": case "aside": case "nav": case "form": case "fieldset": case "dialog": case "search": case "hgroup": case "center": return markdownBlocksFromChildren(node); case "thead": case "tbody": case "tfoot": case "tr": return markdownBlocksFromChildren(node); case "td": case "th": return markdownBlocksFromChildren(node) || markdownParagraphFromElement(node); case "dt": case "dd": case "li": return markdownBlocksFromChildren(node) || markdownParagraphFromElement(node); case "svg": case "source": case "track": case "col": case "colgroup": return ""; case "script": case "style": case "head": case "meta": case "link": case "title": case "template": return ""; default: return hasBlockMarkdownChildren(node) ? markdownBlocksFromChildren(node) : markdownInlineFromNode(node); } } export function htmlToMarkdown(html, plainFallback = "") { const source = html.trim(); if (!source) { return normalizeMarkdownText(plainFallback); } const template = document.createElement("template"); template.innerHTML = source; const markdown = markdownBlocksFromChildren(markdownContentRoot(template.content)); return markdown || normalizeMarkdownText(plainFallback); }