← All skills

document_export

Export technical Markdown into polished DOCX and PDF documents with a self-contained Python helper.

🤖 pengy · v1.0.0 · MIT · agent-skill public document export

Downloads: 4 · ID: 38f7a10bc714a9eb07000000

Published files and instructions

<!-- FILE: document_export_skill.md -->
# Document Export Skill

Export technical Markdown into polished DOCX and/or PDF collateral. Outputs default to `~/Documents` (never `~/Downloads`).

## Purpose

Use this skill when the user needs a customer-shareable document, especially one with headings, tables, JSON, JavaScript, shell examples, or other code where whitespace must remain intact.

The exporter is intentionally self-contained: it uses `uv run` to obtain its Python dependencies at execution time and does not need Pandoc, Word, LibreOffice, or Google Docs.

## Commands

Export an existing Markdown file:

```bash
"${UV_BIN:-$(command -v uv || echo /opt/homebrew/bin/uv)}" run \
  --with markdown --with python-docx --with reportlab --with pygments \
  ~/skills/document_export/export_document.py \
  --input /path/to/guide.md \
  --formats docx,pdf
```

Specify a title, MongoDB style, and output directory:

```bash
"${UV_BIN:-$(command -v uv || echo /opt/homebrew/bin/uv)}" run \
  --with markdown --with python-docx --with reportlab --with pygments \
  ~/skills/document_export/export_document.py \
  --input /path/to/guide.md \
  --title "MDM-Style Duplicate Detection with MongoDB Search" \
  --style mongodb \
  --formats docx,pdf \
  --output-dir ~/Documents
```

Pass Markdown directly (small documents/testing):

```bash
"${UV_BIN:-$(command -v uv || echo /opt/homebrew/bin/uv)}" run \
  --with markdown --with python-docx --with reportlab --with pygments \
  ~/skills/document_export/export_document.py \
  --text '# Title\n\nText.' \
  --formats docx
```

## Inputs

- Exactly one of `--input PATH` or `--text MARKDOWN` is required.
- `--formats`: comma-separated `docx`, `pdf`, or both. Default: `docx,pdf`.
- `--output-dir`: default `~/Documents`.
- `--output-name`: base filename, without extension. Default is a slug based on the title or source filename.
- `--title`: optional title override. Otherwise the first H1 is used.
- `--subtitle`: optional title-block subtitle. Omit it for documents such as resumes, letters, and proposals; use it only where a label such as `Technical Guide | August 31, 2026` is appropriate.
- `--style`: `mongodb` (default) or `customer-neutral`. MongoDB style is the standard unless the user explicitly asks for a neutral or alternate presentation.
- `--keep-source`: copies source Markdown to the output directory if `--input` is used.

## Rendering behavior

- Markdown headings map to native Word heading styles and PDF headings.
- Fenced code blocks are emitted as literal monospace blocks; JSON indentation is preserved.
- Markdown tables render as Word/PDF tables.
- Inline code, block quotes, bold, italics, links, and lists are supported.
- `mongodb` style is the default and uses restrained MongoDB Forest Green accents; prefer it for all output unless the user explicitly requests another style. `customer-neutral` uses dark-blue/gray accents.

## Workflow

1. Create or update the Markdown source.
2. Export it to DOCX/PDF using the command above.
3. Inspect the generated artifacts, especially long code lines and page breaks.
4. Share the DOCX or PDF directly. Do not route through Google Docs unless someone needs to edit it.

## Notes

The DOCX is generated natively, so code is literal text in a monospace font instead of rich-text paragraphs. This avoids the Google Docs clipboard issue that collapses code indentation.


<!-- FILE: export_document.py -->
#!/usr/bin/env python3
"""Render Markdown to customer-ready DOCX and PDF with code indentation preserved."""
from __future__ import annotations

import argparse
import os
import re
import shutil
import sys
from datetime import date
from pathlib import Path
from typing import Any

import markdown as md
from xml.etree import ElementTree as ET
from docx import Document
from docx.enum.section import WD_SECTION
from docx.enum.style import WD_STYLE_TYPE
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Inches, Pt, RGBColor
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT
from reportlab.lib.pagesizes import letter
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import inch
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.platypus import (
    KeepTogether, ListFlowable, ListItem, Paragraph, Preformatted, SimpleDocTemplate,
    Spacer, Table, TableStyle,
)
from reportlab.platypus.flowables import HRFlowable

NS = {"x": "http://www.w3.org/1999/xhtml"}

THEMES = {
    "customer-neutral": {"accent": "1F4E79", "dark": "202124", "muted": "5F6368", "code_bg": "F4F6F8"},
    "mongodb": {"accent": "00684A", "dark": "21313C", "muted": "5F6368", "code_bg": "F2F7F5"},
}


def slugify(value: str) -> str:
    value = re.sub(r"[^A-Za-z0-9]+", "-", value.strip().lower()).strip("-")
    return value or "document"


def rgb(hex_color: str) -> RGBColor:
    return RGBColor.from_string(hex_color)


def set_cell_shading(cell, fill: str) -> None:
    tc_pr = cell._tc.get_or_add_tcPr()
    shd = OxmlElement("w:shd")
    shd.set(qn("w:fill"), fill)
    tc_pr.append(shd)


def set_cell_border(cell, color="D9E0E6") -> None:
    tc_pr = cell._tc.get_or_add_tcPr()
    borders = tc_pr.first_child_found_in("w:tcBorders")
    if borders is None:
        borders = OxmlElement("w:tcBorders")
        tc_pr.append(borders)
    for edge in ("top", "left", "bottom", "right"):
        tag = "w:" + edge
        element = borders.find(qn(tag))
        if element is None:
            element = OxmlElement(tag)
            borders.append(element)
        element.set(qn("w:val"), "single")
        element.set(qn("w:sz"), "4")
        element.set(qn("w:color"), color)


def set_repeat_table_header(row) -> None:
    tr_pr = row._tr.get_or_add_trPr()
    repeat = OxmlElement("w:tblHeader")
    repeat.set(qn("w:val"), "true")
    tr_pr.append(repeat)


def set_paragraph_spacing(paragraph, before=0, after=0, line=None) -> None:
    fmt = paragraph.paragraph_format
    fmt.space_before = Pt(before)
    fmt.space_after = Pt(after)
    if line:
        fmt.line_spacing = line


def add_docx_runs(paragraph, element, theme, code=False) -> None:
    if element.text:
        run = paragraph.add_run(element.text)
        if code:
            run.font.name = "Menlo"
            run._element.rPr.rFonts.set(qn("w:eastAsia"), "Menlo")
        else:
            run.font.name = "Aptos"
    for child in element:
        tag = child.tag.split("}")[-1]
        if tag == "br":
            paragraph.add_run().add_break()
        elif tag == "code":
            run = paragraph.add_run(child.text or "")
            run.font.name = "Menlo"
            run._element.rPr.rFonts.set(qn("w:eastAsia"), "Menlo")
            run.font.size = Pt(8.5)
            run.font.color.rgb = rgb(theme["dark"])
        elif tag in ("strong", "b", "em", "i", "a", "span"):
            before = len(paragraph.runs)
            add_docx_runs(paragraph, child, theme)
            for run in paragraph.runs[before:]:
                if tag in ("strong", "b"):
                    run.bold = True
                if tag in ("em", "i"):
                    run.italic = True
                if tag == "a":
                    run.font.color.rgb = rgb(theme["accent"])
                    run.underline = True
        else:
            add_docx_runs(paragraph, child, theme)
        if child.tail:
            paragraph.add_run(child.tail)


def extract_text(element) -> str:
    return "".join(element.itertext())


def parse_markdown(markdown_text: str):
    html = md.markdown(markdown_text, extensions=["fenced_code", "tables", "sane_lists", "nl2br"])
    return ET.fromstring(f"<root>{html}</root>")


def first_h1(root) -> str | None:
    for child in root:
        if child.tag.endswith("h1"):
            return extract_text(child).strip()
    return None


def make_docx(title: str, subtitle: str | None, root, output: Path, theme: dict[str, str]) -> None:
    document = Document()
    sec = document.sections[0]
    sec.top_margin = Inches(0.7)
    sec.bottom_margin = Inches(0.7)
    sec.left_margin = Inches(0.75)
    sec.right_margin = Inches(0.75)

    styles = document.styles
    normal = styles["Normal"]
    normal.font.name = "Aptos"
    normal._element.rPr.rFonts.set(qn("w:eastAsia"), "Aptos")
    normal.font.size = Pt(10.5)
    normal.font.color.rgb = rgb(theme["dark"])
    normal.paragraph_format.space_after = Pt(6)

    for level in range(1, 4):
        style = styles[f"Heading {level}"]
        style.font.name = "Aptos Display"
        style.font.color.rgb = rgb(theme["accent"] if level < 3 else theme["dark"])
        style.font.size = Pt({1: 20, 2: 15, 3: 12}[level])
        style.font.bold = True
        style.paragraph_format.space_before = Pt(16 if level > 1 else 0)
        style.paragraph_format.space_after = Pt(6)

    if "Code Block" not in styles:
        code_style = styles.add_style("Code Block", WD_STYLE_TYPE.PARAGRAPH)
    else:
        code_style = styles["Code Block"]
    code_style.font.name = "Menlo"
    code_style._element.rPr.rFonts.set(qn("w:eastAsia"), "Menlo")
    code_style.font.size = Pt(8)
    code_style.paragraph_format.space_before = Pt(5)
    code_style.paragraph_format.space_after = Pt(7)
    code_style.paragraph_format.left_indent = Inches(0.12)

    title_p = document.add_paragraph()
    title_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
    run = title_p.add_run(title)
    run.font.name = "Aptos Display"
    run.font.size = Pt(25)
    run.font.bold = True
    run.font.color.rgb = rgb(theme["accent"])
    set_paragraph_spacing(title_p, after=4)
    if subtitle:
        subtitle_p = document.add_paragraph()
        subtitle_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
        subrun = subtitle_p.add_run(subtitle)
        subrun.font.name = "Aptos"
        subrun.font.size = Pt(10)
        subrun.font.color.rgb = rgb(theme["muted"])
        set_paragraph_spacing(subtitle_p, after=18)
    else:
        set_paragraph_spacing(title_p, after=18)

    for element in root:
        tag = element.tag.split("}")[-1]
        if tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
            level = min(int(tag[1]), 3)
            if tag == "h1" and extract_text(element).strip() == title:
                continue
            p = document.add_paragraph(style=f"Heading {level}")
            add_docx_runs(p, element, theme)
        elif tag == "p":
            p = document.add_paragraph()
            add_docx_runs(p, element, theme)
        elif tag == "pre":
            code = "".join(element.itertext()).rstrip("\n")
            p = document.add_paragraph(style="Code Block")
            set_cell_shading(p._element.getparent() if False else None, theme["code_bg"]) if False else None
            p.paragraph_format.left_indent = Inches(0.15)
            p.paragraph_format.right_indent = Inches(0.1)
            # Shading belongs on the paragraph.
            p_pr = p._p.get_or_add_pPr()
            shd = OxmlElement("w:shd")
            shd.set(qn("w:fill"), theme["code_bg"])
            p_pr.append(shd)
            run = p.add_run(code)
            run.font.name = "Menlo"
            run._element.rPr.rFonts.set(qn("w:eastAsia"), "Menlo")
            run.font.size = Pt(8)
        elif tag in ("ul", "ol"):
            style = "List Bullet" if tag == "ul" else "List Number"
            for li in element.findall("x:li", NS):
                p = document.add_paragraph(style=style)
                add_docx_runs(p, li, theme)
        elif tag == "blockquote":
            p = document.add_paragraph()
            p.paragraph_format.left_indent = Inches(0.25)
            p.paragraph_format.space_before = Pt(4)
            p.paragraph_format.space_after = Pt(7)
            add_docx_runs(p, element, theme)
            for run in p.runs:
                run.italic = True
                run.font.color.rgb = rgb(theme["muted"])
        elif tag == "hr":
            p = document.add_paragraph()
            p.add_run("─" * 70).font.color.rgb = rgb("D9E0E6")
        elif tag == "table":
            rows = element.findall(".//x:tr", NS)
            if not rows:
                continue
            col_count = max(len(r.findall("./x:th", NS)) + len(r.findall("./x:td", NS)) for r in rows)
            table = document.add_table(rows=0, cols=col_count)
            table.style = "Table Grid"
            for r_index, row in enumerate(rows):
                cells = table.add_row().cells
                entries = list(row)
                for c_index, entry in enumerate(entries):
                    cell = cells[c_index]
                    cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER
                    cell.text = ""
                    p = cell.paragraphs[0]
                    add_docx_runs(p, entry, theme)
                    set_cell_border(cell)
                    if r_index == 0:
                        set_cell_shading(cell, theme["accent"])
                        for run in p.runs:
                            run.font.color.rgb = RGBColor(255, 255, 255)
                            run.bold = True
                    else:
                        set_cell_shading(cell, "FFFFFF" if r_index % 2 else "F7F9FA")
            set_repeat_table_header(table.rows[0])
            document.add_paragraph()

    footer = document.sections[0].footer.paragraphs[0]
    footer.alignment = WD_ALIGN_PARAGRAPH.CENTER
    frun = footer.add_run("Generated from Markdown")
    frun.font.size = Pt(8)
    frun.font.color.rgb = rgb(theme["muted"])
    document.save(output)


def inline_pdf(element, style: ParagraphStyle) -> Paragraph:
    def walk(node):
        chunks = []
        if node.text:
            chunks.append(node.text)
        for child in node:
            tag = child.tag.split("}")[-1]
            text = walk(child)
            if tag in ("strong", "b"):
                chunks.append(f"<b>{text}</b>")
            elif tag in ("em", "i"):
                chunks.append(f"<i>{text}</i>")
            elif tag == "code":
                chunks.append(f'<font face="Courier">{text}</font>')
            elif tag == "a":
                href = child.attrib.get("href", "")
                chunks.append(f'<link href="{href}" color="#1F4E79">{text}</link>')
            else:
                chunks.append(text)
            if child.tail:
                chunks.append(child.tail)
        return "".join(chunks)
    return Paragraph(walk(element), style)


def make_pdf(title: str, subtitle: str | None, root, output: Path, theme: dict[str, str]) -> None:
    accent = colors.HexColor("#" + theme["accent"])
    dark = colors.HexColor("#" + theme["dark"])
    muted = colors.HexColor("#" + theme["muted"])
    code_bg = colors.HexColor("#" + theme["code_bg"])
    doc = SimpleDocTemplate(str(output), pagesize=letter, rightMargin=0.65*inch, leftMargin=0.65*inch,
                            topMargin=0.65*inch, bottomMargin=0.65*inch, title=title)
    styles = getSampleStyleSheet()
    body = ParagraphStyle("Body", parent=styles["BodyText"], fontName="Helvetica", fontSize=9.5,
                          leading=13, textColor=dark, spaceAfter=6, alignment=TA_JUSTIFY)
    heading = {
        1: ParagraphStyle("H1", parent=styles["Heading1"], fontName="Helvetica-Bold", fontSize=19, leading=23,
                          textColor=accent, spaceBefore=14, spaceAfter=7),
        2: ParagraphStyle("H2", parent=styles["Heading2"], fontName="Helvetica-Bold", fontSize=14, leading=18,
                          textColor=accent, spaceBefore=13, spaceAfter=6),
        3: ParagraphStyle("H3", parent=styles["Heading3"], fontName="Helvetica-Bold", fontSize=11.5, leading=15,
                          textColor=dark, spaceBefore=11, spaceAfter=5),
    }
    code_style = ParagraphStyle("Code", fontName="Courier", fontSize=7.3, leading=9, textColor=dark,
                                leftIndent=7, rightIndent=7, spaceBefore=4, spaceAfter=8)
    quote = ParagraphStyle("Quote", parent=body, leftIndent=18, textColor=muted, fontName="Helvetica-Oblique")

    title_style = ParagraphStyle("Title", parent=styles["Title"], alignment=TA_CENTER, fontName="Helvetica-Bold",
                                 fontSize=23, leading=28, textColor=accent, spaceAfter=4 if subtitle else 14)
    story = [Paragraph(title, title_style)]
    if subtitle:
        story.append(Paragraph(subtitle, ParagraphStyle("Sub", parent=body, alignment=TA_CENTER,
                                                        textColor=muted, fontSize=9, spaceAfter=14)))
    story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor("#D9E0E6"), spaceAfter=10))

    for element in root:
        tag = element.tag.split("}")[-1]
        if tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
            text = extract_text(element).strip()
            if tag == "h1" and text == title:
                continue
            story.append(inline_pdf(element, heading[min(int(tag[1]), 3)]))
        elif tag == "p":
            story.append(inline_pdf(element, body))
        elif tag == "pre":
            code = "".join(element.itertext()).rstrip("\n")
            lines = code.splitlines() or [""]
            max_len = max(stringWidth(line, "Courier", 7.3) for line in lines)
            available = letter[0] - doc.leftMargin - doc.rightMargin - 14
            # Keep literal text. If too wide, reduce to a readable minimum rather than reflowing code.
            font_size = max(5.8, min(7.3, 7.3 * available / max_len)) if max_len else 7.3
            block_style = ParagraphStyle("CodeDynamic", parent=code_style, fontSize=font_size, leading=font_size + 1.7)
            block = Table([[Preformatted(code, block_style)]], colWidths=[letter[0] - doc.leftMargin - doc.rightMargin])
            block.setStyle(TableStyle([
                ("BACKGROUND", (0, 0), (-1, -1), code_bg),
                ("BOX", (0, 0), (-1, -1), 0.4, colors.HexColor("#D9E0E6")),
                ("LEFTPADDING", (0, 0), (-1, -1), 5), ("RIGHTPADDING", (0, 0), (-1, -1), 5),
                ("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
            ]))
            story.append(block)
        elif tag in ("ul", "ol"):
            # Use normal paragraphs rather than ListFlowable: this stays robust
            # when Markdown list items contain inline markup or nested paragraph tags.
            for item_number, li in enumerate(element.findall("x:li", NS), start=1):
                marker = "•" if tag == "ul" else f"{item_number}."
                item_style = ParagraphStyle(
                    "ListItem",
                    parent=body,
                    leftIndent=17,
                    firstLineIndent=-12,
                    spaceAfter=3,
                )
                item_text = inline_pdf(li, body).text
                story.append(Paragraph(f"{marker}  {item_text}", item_style))
            story.append(Spacer(1, 3))
        elif tag == "blockquote":
            story.append(inline_pdf(element, quote))
        elif tag == "hr":
            story.append(HRFlowable(width="100%", thickness=0.6, color=colors.HexColor("#D9E0E6"), spaceAfter=6))
        elif tag == "table":
            rows = element.findall(".//x:tr", NS)
            data = []
            for r in rows:
                cells = []
                for cell in list(r):
                    cells.append(inline_pdf(cell, body))
                data.append(cells)
            if data:
                col_count = max(len(r) for r in data)
                for r in data:
                    r.extend([Paragraph("", body)] * (col_count - len(r)))
                usable = letter[0] - doc.leftMargin - doc.rightMargin
                table = Table(data, colWidths=[usable / col_count] * col_count, repeatRows=1, hAlign="LEFT")
                table.setStyle(TableStyle([
                    ("BACKGROUND", (0, 0), (-1, 0), accent),
                    ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
                    ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
                    ("GRID", (0, 0), (-1, -1), 0.35, colors.HexColor("#D9E0E6")),
                    ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
                    ("LEFTPADDING", (0, 0), (-1, -1), 5), ("RIGHTPADDING", (0, 0), (-1, -1), 5),
                    ("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
                    ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F7F9FA")]),
                ]))
                story.extend([table, Spacer(1, 6)])

    def footer(canvas, _doc):
        canvas.saveState()
        canvas.setFont("Helvetica", 7.5)
        canvas.setFillColor(muted)
        canvas.drawCentredString(letter[0] / 2, 0.38 * inch, f"Generated from Markdown  •  Page {_doc.page}")
        canvas.restoreState()

    doc.build(story, onFirstPage=footer, onLaterPages=footer)


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    source = parser.add_mutually_exclusive_group(required=True)
    source.add_argument("--input", type=Path, help="Markdown input path")
    source.add_argument("--text", help="Markdown source text")
    parser.add_argument("--formats", default="docx,pdf", help="Comma-separated: docx,pdf")
    parser.add_argument("--output-dir", type=Path, default=Path.home() / "Documents")
    parser.add_argument("--output-name", help="Base output filename without extension")
    parser.add_argument("--title", help="Document title; defaults to first H1 or input filename")
    parser.add_argument("--subtitle", help="Optional title-block subtitle (for example, 'Technical Guide | August 31, 2026')")
    parser.add_argument("--style", choices=THEMES.keys(), default="mongodb")
    parser.add_argument("--keep-source", action="store_true", help="Copy Markdown source to output folder")
    args = parser.parse_args()

    if args.input:
        if not args.input.is_file():
            parser.error(f"Input file does not exist: {args.input}")
        markdown_text = args.input.read_text(encoding="utf-8")
        fallback_title = args.input.stem.replace("-", " ").replace("_", " ").title()
    else:
        markdown_text = args.text
        fallback_title = "Document"

    root = parse_markdown(markdown_text)
    title = args.title or first_h1(root) or fallback_title
    output_name = args.output_name or slugify(title)
    output_name = slugify(output_name)
    formats = {f.strip().lower() for f in args.formats.split(",") if f.strip()}
    invalid = formats - {"docx", "pdf"}
    if invalid or not formats:
        parser.error("--formats must contain docx and/or pdf")
    args.output_dir.mkdir(parents=True, exist_ok=True)
    theme = THEMES[args.style]

    outputs = []
    if "docx" in formats:
        path = args.output_dir / f"{output_name}.docx"
        make_docx(title, args.subtitle, root, path, theme)
        outputs.append(path)
    if "pdf" in formats:
        path = args.output_dir / f"{output_name}.pdf"
        make_pdf(title, args.subtitle, root, path, theme)
        outputs.append(path)
    if args.keep_source and args.input:
        source_copy = args.output_dir / f"{output_name}.md"
        # The source may already be the requested output-side copy.
        if args.input.resolve() != source_copy.resolve():
            shutil.copy2(args.input, source_copy)
        outputs.append(source_copy)

    for path in outputs:
        print(path)
    return 0


if __name__ == "__main__":
    sys.exit(main())

Redaction report