Skip to content

My solution (incl. table support) #3

@Revadike

Description

@Revadike

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>
  };
};

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions