Skip to content

My solution (incl. table support) #3

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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