UNPKG

@pdfme/converter

Version:

TypeScript base PDF generator and React base UI. Open source, developed by the community, and completely free to use under the MIT license!

498 lines (497 loc) 15.7 kB
import { normalizeLinkHref, pt2mm, resolvePageSize } from "@pdfme/common"; import remarkGfm from "remark-gfm"; import remarkParse from "remark-parse"; import { unified } from "unified"; //#region src/md2pdf.ts var DEFAULT_PAGE_MARGIN = [ 20, 15, 20, 15 ]; var DEFAULT_FONT_SIZE = 10; var DEFAULT_LINE_HEIGHT = 1.25; var DEFAULT_FONT_COLOR = "#111827"; var DEFAULT_HEADING_SCALE = { 1: 2, 2: 1.65, 3: 1.35, 4: 1.15, 5: 1, 6: 1 }; var BLOCK_GAP = 4; var LIST_ITEM_SPACING = 1.4; var TABLE_HEADER_HEIGHT = 8.5; var TABLE_ROW_HEIGHT = 7.5; var IMAGE_HEIGHT = 45; var CODE_BLOCK_BACKGROUND_COLOR = "#f6f8fa"; var CODE_BLOCK_BORDER_COLOR = "#d0d7de"; var CODE_BLOCK_BORDER_WIDTH = .1; var CODE_BLOCK_PADDING = { top: 2, right: 3, bottom: 2, left: 3 }; var BLOCKQUOTE_BACKGROUND_COLOR = "#f8fafc"; var BLOCKQUOTE_BORDER_COLOR = "#d0d7de"; var BLOCKQUOTE_BORDER_WIDTH = { top: 0, right: 0, bottom: 0, left: .8 }; var BLOCKQUOTE_PADDING = { top: 2, right: 3, bottom: 2, left: 3 }; var HORIZONTAL_RULE_COLOR = "#d0d7de"; var HORIZONTAL_RULE_HEIGHT = .25; var TABLE_BORDER_COLOR = "#d0d7de"; var TABLE_CELL_BORDER_WIDTH = .1; var TABLE_HEAD_BACKGROUND_COLOR = "#f6f8fa"; var TABLE_BODY_ALTERNATE_BACKGROUND_COLOR = "#f9fafb"; var TABLE_CELL_PADDING = 3; var MARKDOWN_ESCAPE_PATTERN = /[\\*~`[\]()]/g; var DATA_IMAGE_PATTERN = /^data:image\/(?:png|jpe?g);base64,/i; var RENDERABLE_BLOCK_TYPES = new Set([ "blockquote", "code", "heading", "html", "list", "paragraph", "table", "thematicBreak" ]); var markdownProcessor = unified().use(remarkParse).use(remarkGfm); var md2pdf = async (markdown, options = {}) => { const root = markdownProcessor.parse(markdown); const builder = createBuilder(options); root.children.forEach((node) => renderBlock(node, builder)); return { template: { basePdf: builder.basePdf, schemas: [builder.schemas] }, inputs: [{}] }; }; var createBuilder = (options) => { const basePdf = options.basePdf ?? createBlankPdf(options); const [top, right, bottom, left] = basePdf.padding; const headingScale = { ...DEFAULT_HEADING_SCALE, ...options.style?.headingScale }; return { basePdf, contentFrame: { x: left, y: top, width: Math.max(0, basePdf.width - left - right), height: Math.max(0, basePdf.height - top - bottom) }, cursorY: top, fontName: options.style?.fontName, fontSize: options.style?.fontSize ?? DEFAULT_FONT_SIZE, lineHeight: options.style?.lineHeight ?? DEFAULT_LINE_HEIGHT, fontColor: options.style?.fontColor ?? DEFAULT_FONT_COLOR, headingScale, nameCounters: {}, schemas: [], usedNames: /* @__PURE__ */ new Set() }; }; var createBlankPdf = (options) => { const pageSize = resolvePageSize(options.page?.size ?? "A4", options.page?.orientation ?? "portrait"); return { width: pageSize.width, height: pageSize.height, padding: resolveMargin(options.page?.margin ?? DEFAULT_PAGE_MARGIN) }; }; var resolveMargin = (margin) => { if (typeof margin === "number") return [ margin, margin, margin, margin ]; if (Array.isArray(margin)) return margin; const x = margin.x ?? 0; const y = margin.y ?? 0; return [ margin.top ?? y, margin.right ?? x, margin.bottom ?? y, margin.left ?? x ]; }; var renderBlock = (node, builder) => { switch (node.type) { case "heading": renderHeading(node, builder); return; case "paragraph": renderParagraph(node, builder); return; case "list": renderList(node, builder); return; case "code": renderCode(node, builder); return; case "blockquote": renderBlockquote(node, builder); return; case "table": renderTable(node, builder); return; case "thematicBreak": renderLine(builder); return; case "html": case "definition": case "footnoteDefinition": case "yaml": return; default: renderNestedBlocks(node, builder); } }; var renderNestedBlocks = (node, builder) => { if ("children" in node && Array.isArray(node.children)) node.children.forEach((child) => { if (isBlockContent(child)) renderBlock(child, builder); }); }; var renderHeading = (node, builder) => { const depth = Math.min(Math.max(node.depth, 1), 6); const content = renderInlineChildren(node.children); const fontSize = builder.fontSize * builder.headingScale[depth]; addTextSchema(builder, { name: resolveName(builder, slugify(toPlainText(node)) || `heading_${depth}`), content, fontSize, height: estimateTextHeight(content, fontSize, builder.lineHeight), gap: depth <= 1 ? 5 : depth === 2 ? 4.5 : BLOCK_GAP, textFormat: "inline-markdown" }); }; var renderParagraph = (node, builder) => { if (node.children.length === 1 && node.children[0]?.type === "image") { renderImage(node.children[0], builder); return; } const content = renderInlineChildren(node.children); if (!content.trim()) return; addTextSchema(builder, { content, textFormat: "inline-markdown" }); }; var renderList = (node, builder) => { const items = collectListItems(node); if (items.length === 0) return; const fontSize = builder.fontSize; const height = items.length * estimateTextHeight("", fontSize, builder.lineHeight) + Math.max(0, items.length - 1) * LIST_ITEM_SPACING; addSchema(builder, { name: resolveAutoName(builder, "list"), type: "list", content: JSON.stringify(items), position: { x: builder.contentFrame.x, y: builder.cursorY }, width: builder.contentFrame.width, height, readOnly: true, alignment: "left", verticalAlignment: "top", fontSize, fontName: builder.fontName, lineHeight: builder.lineHeight, characterSpacing: 0, fontColor: builder.fontColor, backgroundColor: "", listStyle: node.ordered ? "ordered" : "bullet", markerWidth: 6, markerGap: 2, indentSize: 6, itemSpacing: LIST_ITEM_SPACING, textFormat: "inline-markdown", overflow: "expand" }); }; var renderCode = (node, builder) => { const content = node.value; addTextSchema(builder, { content, backgroundColor: CODE_BLOCK_BACKGROUND_COLOR, borderColor: CODE_BLOCK_BORDER_COLOR, borderWidth: resolveBoxSides(CODE_BLOCK_BORDER_WIDTH), padding: CODE_BLOCK_PADDING, textFormat: "plain" }); }; var renderBlockquote = (node, builder) => { const content = node.children.map((child) => blockToMarkdown(child)).join("\n").trim(); if (!content.trim()) return; addTextSchema(builder, { content, backgroundColor: BLOCKQUOTE_BACKGROUND_COLOR, borderColor: BLOCKQUOTE_BORDER_COLOR, borderWidth: BLOCKQUOTE_BORDER_WIDTH, padding: BLOCKQUOTE_PADDING, textFormat: "inline-markdown" }); }; var renderTable = (node, builder) => { const rows = node.children.map(tableRowToStrings).filter((row) => row.length > 0); if (rows.length === 0) return; const columnCount = Math.max(...rows.map((row) => row.length)); const head = normalizeRowLength(rows[0], columnCount); const body = rows.slice(1).map((row) => normalizeRowLength(row, columnCount)); const height = TABLE_HEADER_HEIGHT + Math.max(1, body.length) * TABLE_ROW_HEIGHT; const columnWidths = Array.from({ length: columnCount }, () => 100 / columnCount); addSchema(builder, { name: resolveAutoName(builder, "table"), type: "table", content: JSON.stringify(body), position: { x: builder.contentFrame.x, y: builder.cursorY }, width: builder.contentFrame.width, height, readOnly: true, showHead: true, repeatHead: false, head, headWidthPercentages: columnWidths, tableStyles: { borderColor: TABLE_BORDER_COLOR, borderWidth: .2 }, headStyles: { ...defaultCellStyle(builder), backgroundColor: TABLE_HEAD_BACKGROUND_COLOR, borderColor: TABLE_BORDER_COLOR, borderWidth: resolveBoxSides(TABLE_CELL_BORDER_WIDTH) }, bodyStyles: { ...defaultCellStyle(builder), borderColor: TABLE_BORDER_COLOR, borderWidth: resolveBoxSides(TABLE_CELL_BORDER_WIDTH), alternateBackgroundColor: TABLE_BODY_ALTERNATE_BACKGROUND_COLOR }, columnStyles: {} }); }; var renderImage = (node, builder) => { if (!DATA_IMAGE_PATTERN.test(node.url)) { const link = normalizeLinkHref(node.url); const label = node.alt || node.title || node.url; addTextSchema(builder, { content: link ? `[${escapeInlineMarkdown(label)}](${escapeLinkDestination(link)})` : escapeInlineMarkdown(label), textFormat: "inline-markdown" }); return; } addSchema(builder, { name: resolveAutoName(builder, "image"), type: "image", content: node.url, position: { x: builder.contentFrame.x, y: builder.cursorY }, width: builder.contentFrame.width, height: IMAGE_HEIGHT, readOnly: true }); }; var renderLine = (builder) => { addSchema(builder, { name: resolveAutoName(builder, "line"), type: "line", position: { x: builder.contentFrame.x, y: builder.cursorY }, width: builder.contentFrame.width, height: HORIZONTAL_RULE_HEIGHT, readOnly: true, color: HORIZONTAL_RULE_COLOR }); }; var addTextSchema = (builder, options) => { const fontSize = options.fontSize ?? builder.fontSize; const content = options.content; const boxVerticalInset = getBoxVerticalInset(options); addSchema(builder, { name: options.name ?? resolveAutoName(builder, "text"), type: "text", content, position: { x: options.x ?? builder.contentFrame.x, y: builder.cursorY }, width: options.width ?? builder.contentFrame.width, height: (options.height ?? estimateTextHeight(content, fontSize, builder.lineHeight)) + boxVerticalInset, readOnly: true, alignment: "left", verticalAlignment: "top", fontSize, fontName: builder.fontName, lineHeight: builder.lineHeight, characterSpacing: 0, fontColor: builder.fontColor, backgroundColor: options.backgroundColor ?? "", borderColor: options.borderColor, borderWidth: options.borderWidth, padding: options.padding, textFormat: options.textFormat ?? "plain", overflow: "expand" }, options.gap); }; var addSchema = (builder, schema, gap = BLOCK_GAP) => { builder.schemas.push(schema); builder.cursorY += schema.height + gap; }; var collectListItems = (node, level = 0) => node.children.flatMap((item) => { const prefix = typeof item.checked === "boolean" ? `[${item.checked ? "x" : " "}] ` : ""; const text = item.children.filter((child) => child.type !== "list").map((child) => blockToMarkdown(child)).join(" ").trim(); return [`${" ".repeat(level)}${prefix}${text}`, ...item.children.filter(isList).flatMap((child) => collectListItems(child, level + 1))]; }); var blockToMarkdown = (node) => { switch (node.type) { case "paragraph": case "heading": return renderInlineChildren(node.children); case "code": return node.value; case "list": return collectListItems(node).join("\n"); case "blockquote": return node.children.map(blockToMarkdown).join("\n"); case "table": return node.children.map((row) => tableRowToStrings(row).join(" | ")).join("\n"); case "thematicBreak": return "---"; default: return ""; } }; var renderInlineChildren = (children) => children.map(renderInline).join(""); var renderInline = (node) => { switch (node.type) { case "text": return escapeInlineMarkdown(node.value); case "emphasis": return `*${renderInlineChildren(node.children)}*`; case "strong": return `**${renderInlineChildren(node.children)}**`; case "delete": return `~~${renderInlineChildren(node.children)}~~`; case "inlineCode": return renderInlineCode(node); case "break": return "\n"; case "link": return renderLink(node); case "image": return renderInlineImage(node); case "html": return ""; default: return "children" in node && Array.isArray(node.children) ? renderInlineChildren(node.children) : ""; } }; var renderInlineCode = (node) => `\`${node.value.replaceAll("`", "\\`")}\``; var renderLink = (node) => { const label = renderInlineChildren(node.children); const href = normalizeLinkHref(node.url); if (!href) return label; return `[${label}](${escapeLinkDestination(href)})`; }; var renderInlineImage = (node) => { const label = node.alt || node.title || node.url; const href = normalizeLinkHref(node.url); if (!href) return escapeInlineMarkdown(label); return `[${escapeInlineMarkdown(label)}](${escapeLinkDestination(href)})`; }; var tableRowToStrings = (row) => row.children.map(tableCellToString); var tableCellToString = (cell) => cell.children.map(inlineToPlainText).join(""); var normalizeRowLength = (row, columnCount) => Array.from({ length: columnCount }, (_, index) => row[index] ?? ""); var defaultCellStyle = (builder) => ({ fontName: builder.fontName, alignment: "left", verticalAlignment: "middle", fontSize: builder.fontSize, lineHeight: builder.lineHeight, characterSpacing: 0, fontColor: builder.fontColor, backgroundColor: "#ffffff", borderColor: TABLE_BORDER_COLOR, borderWidth: resolveBoxSides(TABLE_CELL_BORDER_WIDTH), padding: resolveBoxSides(TABLE_CELL_PADDING) }); var resolveBoxSides = (value) => { if (typeof value === "number") return { top: value, right: value, bottom: value, left: value }; const x = value.x ?? 0; const y = value.y ?? 0; return { top: value.top ?? y, right: value.right ?? x, bottom: value.bottom ?? y, left: value.left ?? x }; }; var getBoxVerticalInset = (value) => (value.borderWidth?.top ?? 0) + (value.borderWidth?.bottom ?? 0) + (value.padding?.top ?? 0) + (value.padding?.bottom ?? 0); var resolveAutoName = (builder, prefix) => { let name = ""; do { builder.nameCounters[prefix] = (builder.nameCounters[prefix] ?? 0) + 1; name = `${prefix}_${builder.nameCounters[prefix]}`; } while (builder.usedNames.has(name)); builder.usedNames.add(name); return name; }; var resolveName = (builder, baseName) => { let name = baseName; let index = 0; while (builder.usedNames.has(name)) { index += 1; name = `${baseName}_${index}`; } builder.usedNames.add(name); return name; }; var estimateTextHeight = (content, fontSize, lineHeight) => { const lineCount = Math.max(1, content.split("\n").length); return Math.max(4, lineCount * pt2mm(fontSize * lineHeight) + 1); }; var escapeInlineMarkdown = (value) => value.replace(MARKDOWN_ESCAPE_PATTERN, (match) => `\\${match}`); var escapeLinkDestination = (href) => encodeURI(href).replace(/[\\()]/g, (match) => `\\${match}`); var slugify = (value) => value.trim().toLowerCase().replace(/[^\p{Letter}\p{Number}]+/gu, "-").replace(/^-|-$/g, ""); var toPlainText = (node) => node.children.map((child) => { if ("value" in child && typeof child.value === "string") return child.value; if ("alt" in child && typeof child.alt === "string") return child.alt; if ("children" in child && Array.isArray(child.children)) return child.children.map((grandchild) => toPlainInlineText(grandchild)).join(""); return ""; }).join(""); var toPlainInlineText = (node) => { if ("value" in node && typeof node.value === "string") return node.value; if ("alt" in node && typeof node.alt === "string") return node.alt; if ("children" in node && Array.isArray(node.children)) return node.children.map(toPlainInlineText).join(""); return ""; }; var inlineToPlainText = (node) => { switch (node.type) { case "text": case "inlineCode": return node.value; case "break": return "\n"; case "image": return node.alt || node.title || node.url; case "link": case "emphasis": case "strong": case "delete": return node.children.map(inlineToPlainText).join(""); case "html": return ""; default: return "children" in node && Array.isArray(node.children) ? node.children.map(inlineToPlainText).join("") : ""; } }; var isBlockContent = (node) => typeof node === "object" && node !== null && "type" in node && typeof node.type === "string" && RENDERABLE_BLOCK_TYPES.has(node.type); var isList = (node) => node.type === "list"; //#endregion export { md2pdf }; //# sourceMappingURL=md2pdf.js.map