document_export
Export technical Markdown into polished DOCX and PDF documents with a self-contained Python helper.
Downloads: 4 · ID: 38f7a10bc714a9eb07000000
Export technical Markdown into polished DOCX and PDF documents with a self-contained Python helper.
Downloads: 4 · ID: 38f7a10bc714a9eb07000000
<!-- 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())