Just wanted to share my nuxt composable. I needed table support, so I decided to make a markdown to pdfmake converter from scratch. Feel free to use any code you want.
/**
* Usage:
* const { toPdfmake, toAst, defaultStyles } = useMarkdownConverter()
*
* // One-shot conversion
* const doc = toPdfmake(markdownString)
* pdfMake.createPdf(doc).open()
*
* // With per-call overrides
* const doc = toPdfmake(markdownString, { defaultFont: 'Helvetica' })
*
* // With composable-level defaults (applied to every toPdfmake call)
* const { toPdfmake } = useMarkdownConverter({
* pageWidth: 480,
* styles: { h1: { fontSize: 30, bold: true } },
* })
*
* Safe for SSR: no DOM APIs are used anywhere in the pipeline.
*/
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
import type { Root, Node, Parent, Text, Strong, Emphasis, Delete, InlineCode, Link, Image, Code, Heading, Paragraph, Blockquote, List, ListItem, Table, TableRow, TableCell, Html } from 'mdast';
import type { TDocumentDefinitions } from 'pdfmake/interfaces';
// ─── pdfmake type shims ───────────────────────────────────────────────────────
// pdfmake ships its own declaration file; these lightweight shims avoid adding
// pdfmake as a hard dependency just for types.
type PdfMargin = [number, number, number, number] | [number, number] | number;
interface PdfStyle {
fontSize?: number
bold?: boolean
italics?: boolean
color?: string
background?: string
fillColor?: string
decoration?: 'underline' | 'lineThrough' | 'overline'
font?: string
alignment?: 'left' | 'center' | 'right' | 'justify'
lineHeight?: number
margin?: PdfMargin
link?: string
linkTooltip?: string
}
type PdfInlineItem = string | (PdfStyle & { text: string | PdfInlineItem[] });
type PdfInlineContent = string | PdfInlineItem[];
interface PdfTextNode extends PdfStyle {
text: PdfInlineContent
style?: string
preserveLeadingSpaces?: boolean
}
interface PdfStackNode {
stack: PdfNode[]
style?: string
margin?: PdfMargin
}
interface PdfListNode {
ul?: PdfNode[]
ol?: PdfNode[]
start?: number
margin?: PdfMargin
}
interface PdfTableNode {
table: {
headerRows?: number
widths: Array<string | number>
body: PdfTableCell[][]
}
layout?: Record<string, unknown>
margin?: PdfMargin
}
interface PdfTableCell extends PdfStyle {
text?: PdfInlineContent
stack?: PdfNode[]
border?: [boolean, boolean, boolean, boolean]
margin?: PdfMargin
}
interface PdfCanvasNode {
canvas: Array<{
type: 'line' | 'rect' | 'ellipse' | 'polyline'
x1?: number
y1?: number
x2?: number
y2?: number
lineWidth?: number
lineColor?: string
[k: string]: unknown
}>
margin?: PdfMargin
}
type PdfNode = PdfTextNode | PdfStackNode | PdfListNode | PdfTableNode | PdfCanvasNode;
// ─── Style map ───────────────────────────────────────────────────────────────
export type StyleMap = {
h1: PdfStyle
h2: PdfStyle
h3: PdfStyle
h4: PdfStyle
h5: PdfStyle
h6: PdfStyle
paragraph: PdfStyle
blockquote: PdfStyle
codeBlock: PdfStyle
inlineCode: PdfStyle
tableHeader: PdfStyle
tableCell: PdfStyle
link: PdfStyle
};
export const DEFAULT_STYLES: StyleMap = {
h1: { fontSize: 26, bold: true, margin: [0, 14, 0, 6] },
h2: { fontSize: 22, bold: true, margin: [0, 12, 0, 5] },
h3: { fontSize: 18, bold: true, margin: [0, 10, 0, 4] },
h4: { fontSize: 15, bold: true, margin: [0, 8, 0, 4] },
h5: { fontSize: 13, bold: true, margin: [0, 6, 0, 3] },
h6: { fontSize: 11, bold: true, italics: true, margin: [0, 6, 0, 3] },
paragraph: { fontSize: 11, margin: [0, 0, 0, 8] },
blockquote: { fontSize: 11, italics: true, color: '#555555', margin: [16, 2, 0, 2] },
codeBlock: { fontSize: 9.5, font: 'Courier', background: '#f4f4f4', color: '#222222', margin: [0, 4, 0, 10], lineHeight: 1.3 },
inlineCode: { fontSize: 9.5, font: 'Courier', background: '#f0f0f0', color: '#c0392b' },
tableHeader: { bold: true, fillColor: '#eeeeee', alignment: 'left' },
tableCell: { alignment: 'left' },
link: { color: '#0052cc', decoration: 'underline' }
};
const DEFAULT_TABLE_LAYOUT = {
hLineWidth: () => 0.5,
vLineWidth: () => 0.5,
hLineColor: () => '#c0c0c0',
vLineColor: () => '#c0c0c0',
paddingLeft: () => 6,
paddingRight: () => 6,
paddingTop: () => 4,
paddingBottom: () => 4
};
// ─── Options ─────────────────────────────────────────────────────────────────
export interface MarkdownConverterOptions {
/** Partial style overrides merged with DEFAULT_STYLES at the composable level. */
styles?: Partial<StyleMap>
/** pdfmake font name written into defaultStyle (default: 'Roboto'). */
defaultFont?: string
/** Usable page width in pts used for HR canvas lines (default: 515). */
pageWidth?: number
/** Custom pdfmake table layout object. */
tableLayout?: Record<string, unknown>
/**
* Hook called for every block mdast node after default conversion.
* Return a replacement PdfNode, null to remove the node, or undefined
* to keep the default result unchanged.
*/
transformNode?: (node: Node, defaultResult: PdfNode | null) => PdfNode | null | undefined
}
// ─── Internal context ─────────────────────────────────────────────────────────
interface Ctx {
styles: StyleMap
pageWidth: number
tableLayout: Record<string, unknown>
transformNode?: MarkdownConverterOptions['transformNode']
}
// ─── Inline conversion ───────────────────────────────────────────────────────
const convertInlineNodes = (nodes: Node[], ctx: Ctx): PdfInlineItem[] => {
const result: PdfInlineItem[] = [];
for (const node of nodes) {
switch (node.type) {
case 'text':
result.push((node as Text).value);
break;
case 'strong': {
const inner = convertInlineNodes((node as Strong).children, ctx);
result.push(wrapInline(inner, { bold: true }));
break;
}
case 'emphasis': {
const inner = convertInlineNodes((node as Emphasis).children, ctx);
result.push(wrapInline(inner, { italics: true }));
break;
}
case 'delete': {
const inner = convertInlineNodes((node as Delete).children, ctx);
result.push(wrapInline(inner, { decoration: 'lineThrough', color: '#777777' }));
break;
}
case 'inlineCode':
result.push({ text: (node as InlineCode).value, ...ctx.styles.inlineCode });
break;
case 'link': {
const ln = node as Link;
const inner = convertInlineNodes(ln.children, ctx);
const props: PdfStyle = { ...ctx.styles.link };
if (ln.url) props.link = ln.url;
if (ln.title) props.linkTooltip = ln.title;
result.push(wrapInline(inner, props));
break;
}
case 'image': {
const im = node as Image;
const label = im.alt || im.url || 'image';
result.push({ text: `[Image: ${label}]`, italics: true, color: '#888888' });
break;
}
case 'break':
result.push('\n');
break;
case 'html': {
const stripped = (node as Html).value.replace(/<[^>]*>/g, '').trim();
if (stripped) result.push(stripped);
break;
}
default:
if ((node as Parent).children?.length) {
result.push(...convertInlineNodes((node as Parent).children, ctx));
} else if ((node as Text).value) {
result.push((node as Text).value);
}
}
}
return result;
};
const wrapInline = (inner: PdfInlineItem[], props: PdfStyle): PdfInlineItem => {
if (inner.length === 1 && typeof inner[0] === 'string') {
return { text: inner[0], ...props };
}
return { text: inner, ...props };
};
const simplifyInline = (inline: PdfInlineItem[]): PdfInlineContent => {
if (inline.length === 1 && typeof inline[0] === 'string') return inline[0];
return inline;
};
// ─── Block conversion ─────────────────────────────────────────────────────────
const convertBlockNode = (node: Node, ctx: Ctx): PdfNode | null => {
let result: PdfNode | null = null;
switch (node.type) {
case 'heading': {
const h = node as Heading;
const styleKey = `h${h.depth}` as keyof StyleMap;
const inline = convertInlineNodes(h.children, ctx);
result = { text: simplifyInline(inline), style: styleKey } as PdfTextNode;
break;
}
case 'paragraph': {
const p = node as Paragraph;
const inline = convertInlineNodes(p.children, ctx);
result = { text: simplifyInline(inline), style: 'paragraph' } as PdfTextNode;
break;
}
case 'blockquote': {
const stack = convertChildren((node as Blockquote).children, ctx);
result = {
table: {
widths: [3, '*'],
body: [[
{ text: '', fillColor: '#aaaaaa', border: [false, false, false, false] },
{ stack, border: [false, false, false, false], style: 'blockquote' }
]]
},
layout: {
hLineWidth: () => 0,
vLineWidth: () => 0,
paddingLeft: () => 0,
paddingRight: () => 6,
paddingTop: () => 4,
paddingBottom: () => 4
},
margin: [0, 4, 0, 8]
} as PdfTableNode;
break;
}
case 'code': {
const c = node as Code;
result = {
text: c.value,
style: 'codeBlock',
preserveLeadingSpaces: true
} as PdfTextNode;
break;
}
case 'list':
result = convertList(node as List, ctx);
break;
case 'table':
result = convertTable(node as Table, ctx);
break;
case 'thematicBreak':
result = {
canvas: [{
type: 'line',
x1: 0, y1: 0, x2: ctx.pageWidth, y2: 0,
lineWidth: 0.75,
lineColor: '#cccccc'
}],
margin: [0, 10, 0, 10]
} as PdfCanvasNode;
break;
case 'html': {
const stripped = (node as Html).value.replace(/<[^>]*>/g, '').trim();
if (stripped) result = { text: stripped, style: 'paragraph' } as PdfTextNode;
break;
}
default:
if ((node as Parent).children?.length) {
const children = convertChildren((node as Parent).children, ctx);
if (children.length) result = { stack: children } as PdfStackNode;
}
}
if (ctx.transformNode) {
const override = ctx.transformNode(node, result);
if (override !== undefined) result = override;
}
return result;
};
const convertList = (node: List, ctx: Ctx): PdfListNode => {
const items = node.children.map(item => convertListItem(item, ctx));
const listNode: PdfListNode = node.ordered
? { ol: items, start: node.start ?? 1 }
: { ul: items };
return { ...listNode, margin: [0, 0, 0, 8] };
};
const convertListItem = (item: ListItem, ctx: Ctx): PdfNode => {
const parts: PdfNode[] = [];
for (const child of item.children) {
if (child.type === 'list') {
parts.push(convertList(child as List, ctx));
} else if (child.type === 'paragraph') {
const inline = convertInlineNodes((child as Paragraph).children, ctx);
parts.push({ text: simplifyInline(inline) } as PdfTextNode);
} else {
const converted = convertBlockNode(child, ctx);
if (converted) parts.push(converted);
}
}
if (parts.length === 0) return { text: '' } as PdfTextNode;
if (parts.length === 1) return parts[0]!;
return { stack: parts } as PdfStackNode;
};
const convertTable = (node: Table, ctx: Ctx): PdfTableNode => {
const align = node.align ?? [];
const body: PdfTableCell[][] = (node.children as TableRow[]).map((row, rowIdx) =>
(row.children as TableCell[]).map((cell, colIdx) => {
const inline = convertInlineNodes(cell.children, ctx);
const isHeader = rowIdx === 0;
const colAlign = align[colIdx];
const cellDef: PdfTableCell = {
text: simplifyInline(inline),
margin: [4, 4, 4, 4],
alignment: colAlign === 'center' ? 'center'
: colAlign === 'right' ? 'right'
: 'left'
};
if (isHeader) Object.assign(cellDef, ctx.styles.tableHeader);
return cellDef;
})
);
return {
table: {
headerRows: 1,
widths: new Array(body[0]?.length ?? 1).fill('*'),
body
},
layout: ctx.tableLayout,
margin: [0, 4, 0, 12]
};
};
const convertChildren = (nodes: Node[], ctx: Ctx): PdfNode[] => {
return nodes.map(n => convertBlockNode(n, ctx)).filter((n): n is PdfNode => n !== null);
};
// ─── Composable ──────────────────────────────────────────────────────────────
export const useMarkdownConverter = (composableOptions: MarkdownConverterOptions = {}) => {
// Build the unified processor once; reused for every toPdfmake / toAst call.
const processor = unified().use(remarkParse)
.use(remarkGfm);
// Merge composable-level styles with defaults once.
const baseStyles: StyleMap = {
...DEFAULT_STYLES,
...composableOptions.styles
};
/**
* Convert a markdown string to a pdfmake document definition.
* Per-call options are merged on top of composable-level options.
*/
const toPdfmake = (markdown: string, callOptions: MarkdownConverterOptions = {}): TDocumentDefinitions => {
const styles: StyleMap = callOptions.styles
? { ...baseStyles, ...callOptions.styles }
: baseStyles;
const ctx: Ctx = {
styles,
pageWidth: callOptions.pageWidth ?? composableOptions.pageWidth ?? 515,
tableLayout: callOptions.tableLayout ?? composableOptions.tableLayout ?? DEFAULT_TABLE_LAYOUT,
transformNode: callOptions.transformNode ?? composableOptions.transformNode
};
const ast = processor.parse(markdown) as Root;
const content = convertChildren(ast.children, ctx);
return {
content: content as TDocumentDefinitions['content'],
styles: {
h1: styles.h1, h2: styles.h2, h3: styles.h3,
h4: styles.h4, h5: styles.h5, h6: styles.h6,
paragraph: styles.paragraph,
blockquote: styles.blockquote,
codeBlock: styles.codeBlock
},
defaultStyle: {
font: callOptions.defaultFont ?? composableOptions.defaultFont ?? 'Roboto',
fontSize: 11
}
};
};
/**
* Return the raw mdast Root node for a markdown string.
* Useful for pre-processing or custom rendering logic.
*/
const toAst = (markdown: string): Root => {
return processor.parse(markdown) as Root;
};
return {
/** Convert markdown → pdfmake doc definition */
toPdfmake,
/** Parse markdown → raw mdast (for advanced use) */
toAst,
/** The resolved default styles (read-only reference) */
defaultStyles: baseStyles as Readonly<StyleMap>
};
};
Just wanted to share my nuxt composable. I needed table support, so I decided to make a markdown to pdfmake converter from scratch. Feel free to use any code you want.