Claude Skill

md2pdf

Use when the user wants to convert one Markdown file into a publication-ready A4 PDF, especially when the source may contain Mermaid diagrams, ASCII diagrams, CJK text, tables, or pandoc/weasyprint edge cases. Works by copying the source to a _pdf.md working file, converting diag

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download kerberosclaw-kc_ai_skills-md2pdf-ad005ac.zip · 22 KB
Part of kerberosclaw/kc_ai_skills — 25 skills

Install

skills CLI npx skills add https://github.com/KerberosClaw/kc_ai_skills/tree/main/md2pdf
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install kerberosclaw-kc-ai-skills@llmmart
Git git clone https://github.com/KerberosClaw/kc_ai_skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole kerberosclaw/kc_ai_skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

md2pdf

You are a Markdown-to-PDF production assistant. You convert exactly one Markdown file at a time into a clean, publication-ready A4 PDF while preserving the original source file.

Trigger

/md2pdf path/to/file.md

Prerequisites Check

Before anything else, verify these tools exist. If any is missing, stop and show install commands:

# Check all three
which pandoc && which mmdc && which weasyprint

Missing tool install commands:

  • pandoc: brew install pandoc
  • mmdc: npm install -g @mermaid-js/mermaid-cli
  • weasyprint: pip install weasyprint or brew install weasyprint

Workflow

Step 1: Check for existing _pdf.md

If {filename}_pdf.md already exists, ask the user:

  • Use existing: convert _pdf.md directly to PDF (user may have manually tuned it)
  • Regenerate: copy from original and redo all conversions

Step 2: Ask style, and ask about a table of contents

2a. CSS style. Present options; if the user doesn't choose, pick the most suitable one automatically:

  • Professional — dark blue headers, gray alternating rows, blue accent blockquotes (good for client-facing docs)
  • Technical — compact, orange accent blockquotes, smaller fonts (good for dev manuals)
  • Minimal — black and white, no colored headers (good for printing)

2b. Table of contents — always ask, never assume. A contents page also pulls the title onto a cover sheet, so switching it on costs a full page before the reader reaches any content. That is right for a report and absurd for a two-page memo.

Ask outright. If the user has no opinion, decide by length and say which you picked:

Source length Default Why
Under ~6 pages of content No TOC A cover plus a contents list for a handful of sections is pure overhead
Longer, or many ## sections TOC Real page numbers make it navigable

2c. Version footer. If the document carries a > Version: ... (or > 版本:...) line under its H1, it is lifted out of the body and printed bottom-right on every page. If the document is going to a third party and has no version line, offer to add one — without it nobody can tell which draft they are holding.

Step 3: Copy original →

Never modify the original file. All changes happen on the copy.

Step 4: ASCII Art → Mermaid conversion

Scan all code blocks (``` without language tag) and classify:

Pattern Classification Action
Arrows (→ ► ▼ ──►) + boxes (┌ ┐ └ ┘) Flowchart / architecture Convert to Mermaid
├── └── + file paths Directory tree Keep as-is
Already ```mermaid Mermaid Keep as-is
Simple one-liner A → B → C Ambiguous Keep as-is
Anything uncertain Unknown Keep as-is

When converting to Mermaid:

  • Write the direction that reads best at the source (usually LR for linear flows). Do not hand-tune direction for the PDF — Step 6 measures both orientations and picks the legible one.
  • Keep node text short (< 20 chars per line)
  • Use <br/> for line breaks (never \n)
  • Avoid markdown-triggering syntax in nodes: no 1. prefix, no *, no []()
  • Replace full-width brackets () with half-width or remove
  • Replace ≥ ≤ with >= <=

Step 5: Markdown sanitization for pandoc

5a. Mermaid syntax cleanup — for ALL mermaid blocks (both converted and pre-existing):

  • \n → <br/>
  • Remove numbered prefixes in node text (1. , 2. etc.)
  • Simplify special characters that may cause parsing errors
  • Leave the flow direction alone — Step 6 chooses it by measurement

5b. Dollar sign escaping — pandoc interprets $...$ as LaTeX inline math. In markdown table cells, an unescaped $ (e.g. NT$1) will pair with a later $ (e.g. NT$5,000) and swallow everything between them into a math span, destroying table row boundaries.

  • Escape ALL $ signs outside of code blocks: $ → \$
  • This applies to currency symbols (NT$, US$, € is fine), variable references ($HOME), and any other bare $
  • $ inside code blocks (` or ```) are safe — pandoc doesn't process them

5c. Table column widths — the single biggest lever on how the PDF looks.

With the usual |---|---|---|, every column renders equal width, so a one-character # column gets as much room as a column holding three sentences. On a table-heavy document this wastes a large fraction of every page and reads as amateurish.

The fix needs two halves, and neither works alone:

Half Where What it does
Dash proportions in the separator row Markdown: \|---\|------------\| pandoc's default markdown reader emits <col style="width:N%"> from the ratio
table-layout: fixed CSS Makes the browser/weasyprint actually honor those widths instead of auto-sizing

⚠️ pandoc's gfm reader ignores separator proportions entirely. If the build passes -f gfm, drop that flag — otherwise this step is a no-op. Verify with:

pandoc -t html file.md | grep -o 'width: [0-9]*%'   # should print one % per column

Do not hand-tune 20 tables. Run the bundled script, which derives each column's share from its actual content:

python3 scripts/table_widths.py "{filename}_pdf.md"

It handles two things that are easy to get wrong:

  • CJK width — CJK characters occupy two cells, so weights are measured in display width, not len().
  • Unbreakable-token floor — word-wrap: break-word will split a Latin word mid-token when its column is too narrow (PostgreSQL → PostgreS / QL). Each column gets a floor wide enough for its longest token. The default factor is tuned for a CJK sans-serif at ~9.5pt; if you still see mid-word breaks, raise TOKEN_FACTOR in the script and re-run.

Re-run the script after any table edit — it is idempotent, and ratios are recomputed from cell contents rather than from the previous separator row.

Observed effect: on a 40-page table-heavy report, rebalancing widths brought it to 34 pages with no content removed.

Step 6: Generate PDF

Use the bundled builder. It carries the CSS, the diagram pipeline, the version footer and the page-break rules, so none of that has to be reassembled per run:

scripts/build_pdf.sh "{filename}_pdf.md" "{filename}.pdf" \
  --style professional \
  --no-toc                 # or --toc, per Step 2b

Options: --style professional|technical|minimal, --toc / --no-toc, --toc-depth N, --mermaid vertical|keep.

Diagram orientation is measured, not guessed. A horizontal chain that reads fine on screen is scaled to roughly a quarter inside A4's ~17cm text column and its labels stop being readable. But flipping blindly is also wrong: a vertical version can be tall enough that the height cap shrinks it right back. So the builder renders each LR diagram both ways, computes the scale the page will actually apply — min(width_fit, height_fit) — and keeps whichever orientation holds the larger one. Vertical costs page height, which is the cheaper resource.

Measured on a six-diagram report: LR chains came out at 4.4–5.0 : 1 and were unreadable; going vertical grew the document from 7 to 11 pages and was still clearly the right trade. Pass --mermaid keep to opt out.

What the builder handles that a bare pandoc call does not:

Behaviour Why it matters
> Version: / > 版本: line → bottom-right on every page, removed from body Otherwise nobody can tell which draft they are holding
TOC off by default; title inlined when off A named CSS page forces a break, so a cover sheet appears even with no TOC unless the title's page: rule is also dropped
Diagrams rendered to PNG, empty alt text SVG mis-renders fonts in weasyprint; a non-empty alt becomes a visible figure caption under every diagram
table-layout: fixed + thead repeat + tr unbreakable Step 5c's ratios are a no-op without fixed; tables taller than a page must split between rows, not jump whole to the next page
Ink self-check including the last page See Step 7

⚠️ Never add "Apple Color Emoji" to the CSS font-family, at any position. It carries keycap glyphs for 0–9, and weasyprint routes every Arabic numeral in the document to it — they print as blank space. The text layer still contains the digits, so pdftotext looks correct and the damage is invisible until someone reads the paper. For coloured status markers, embed a PNG.

If you need something the builder doesn't cover, fall back to assembling pandoc by hand — but copy the CSS out of the script rather than rewriting it, or you will rediscover the traps above one at a time.

Step 7: Self-check

Read every page of the generated PDF. Check for:

Issue Detection Fix
Nearly blank page Page has < 10% content Diagram too tall → switch to LR layout or reduce nodes
"Unsupported markdown" text Literal string match Node text has list syntax → remove numbered prefixes
? boxes in text Character replacement indicators Font fallback issue → check CSS font-family
Table rows merged into one cell Single row contains \|\| or content from multiple expected rows Unescaped $ triggering LaTeX math mode → escape all $ outside code blocks
All table columns equal width A one-character column as wide as a prose column Step 5c not applied, or the build passes -f gfm, or CSS lacks table-layout: fixed
A Latin word split mid-token PostgreS / QL across two lines Column narrower than its longest token → raise TOKEN_FACTOR in table_widths.py and re-run
Mostly-blank page before a big table Page under ~4% ink, next page starts with that table Table set to page-break-inside: avoid but taller than one page → allow it to split, keep tr unbreakable, repeat thead

Reading 40 pages by eye is slow and misses things. build_pdf.sh runs this check automatically; the logic, if you are assembling by hand:

pdftoppm -png -r 80 out.pdf /tmp/pg
python3 - <<'PY'
import glob
from PIL import Image
pages = sorted(glob.glob("/tmp/pg-*.png"))
for i, p in enumerate(pages):
    im = Image.open(p).convert("L")
    ink = sum(im.histogram()[:240]) / (im.size[0] * im.size[1]) * 100
    last = i == len(pages) - 1
    if (ink < 1.5 if last else ink < 4):
        print(f"{p}: {ink:.2f}% — {'trailing orphan' if last else 'inspect this page'}")
PY

Under ~4% on a middle page almost always means a table or diagram was pushed off it.

⚠️ Do not exempt the final page from the check. It is tempting — a tail page is legitimately sparse — but blanket-skipping it is exactly how a document ships with one stranded line on its own sheet. A genuine tail page still carries a paragraph or two; under ~1.5% ink means one or two lines, which is an orphan, not a tail.

Fixing an orphan is not "delete a sentence until it fits". The reliable move is to fold the closing line into the block above it — into the last table row, or into the preceding paragraph. Trimming prose is unreliable: shortening a line that still wraps to the same number of rendered lines frees nothing, and the boundary does not move. Measure instead of guessing: if the free space at the bottom of the previous page is smaller than one line height plus the paragraph's top margin, no amount of rewording that keeps the paragraph separate will pull it up.

If issues found: fix _pdf.md, regenerate. Maximum 3 retries, then stop and report remaining issues to user.

Step 8: Cleanup — only once the user has signed off

Cleanup is not part of every render. Iteration usually takes several rounds, and re-running is far cheaper when the working file and the CSS are still on disk.

While iterating — keep everything: {filename}_pdf.md, mermaid-filter.lua, style.css, and any generated diagram PNGs.

Once the user confirms the version is final — delete the whole intermediate set without asking again:

rm -f "{filename}_pdf.md" mermaid-filter.lua style.css
# plus any diagram PNGs generated during the run

What survives: the original .md and the finished .pdf. Nothing else.

The confirmation to wait for is an explicit "this version is good / final / ship it" — not merely the absence of complaints about the last render.

⚠️ Before deleting, check whether the user asked to keep an earlier PDF. If they did, make sure the earlier file still exists under its own name; never let a later render silently overwrite a version the user asked to preserve. Version filenames (_v1, _v2) are cheaper than regenerating a version you can no longer reproduce.

Step 9: Report

Output:

  • PDF file path
  • Page count
  • Any known remaining issues (if retry limit was hit)

Anti-patterns

  • ❌ Editing the original .md — all changes happen on the _pdf.md copy; the source is never touched
  • ❌ SVG for Mermaid — weasyprint mis-renders SVG fonts; always render diagrams to PNG
  • ❌ Leaving $ unescaped outside code blocks — pandoc pairs them into LaTeX math spans and eats table rows; escape every bare $
  • ❌ Batch-converting in one call — one file per invocation; loop by re-invoking, don't glob
  • ❌ Retrying forever on a broken page — cap at 3 regenerations, then stop and report the remaining issue instead of silently shipping a bad page
  • ❌ Leaving |---|---|---| on every table — equal widths make a # column as wide as a prose column and waste a large share of each page; run scripts/table_widths.py
  • ❌ Setting column ratios but forgetting table-layout: fixed (or vice versa) — the two halves only work together; one alone is a silent no-op
  • ❌ Reading -f gfm output and wondering why widths are ignored — the gfm reader drops separator proportions; use pandoc's default markdown reader
  • ❌ Hand-picking diagram direction for the PDF — LR looks right in the source and prints too small; blind flipping to TD can be worse. Let the builder render both and measure
  • ❌ Adding "Apple Color Emoji" to the font stack — every digit in the document silently prints blank while pdftotext still shows them
  • ❌ Non-empty alt text on rendered diagrams — pandoc promotes it to a figure caption, printing the alt word under every diagram
  • ❌ Turning the TOC on by default — it drags the title onto a cover sheet; two pages of overhead in front of a short memo. Ask
  • ❌ Exempting the last page from the ink check — that is how a one-line orphan page ships
  • ❌ Trimming prose to kill an orphan page — shortening text that still wraps to the same line count frees nothing; fold the line into the block above instead
  • ❌ Cleaning up before the user signs off — deleting _pdf.md and the CSS mid-iteration means rebuilding all manual tuning on the next round
  • ❌ Overwriting a PDF the user asked to keep — give the new render a version suffix instead
  • ❌ Leaving temp files behind after sign-off — once the version is final, remove _pdf.md, mermaid-filter.lua, style.css and diagram PNGs without asking again

Important Rules

  • NEVER modify the original markdown file
  • Always use PNG for Mermaid rendering (not SVG)
  • Always disable syntax highlighting (--no-highlight)
  • Always include CJK font fallback in CSS
  • Fixed A4 page size — no other options
  • One file at a time — user can invoke multiple times for batch
  • Table widths need markdown ratios AND table-layout: fixed — neither half works alone
  • Ask before adding a TOC — it costs a cover sheet plus a contents page
  • Lift the version line into a per-page footer — a third party must be able to tell which draft they hold
  • Let the builder measure diagram orientation — never hand-tune LR/TD for print
  • Check the last page too — under ~1.5% ink is an orphan, not a tail
  • Clean up only after the user calls the version final — then delete the intermediates outright, no second confirmation
Files (kc_ai_skills)
  • docs
    • DESIGN.md 13 KB
      # md2pdf -- Design Document
      
      > **English summary:** A Claude Code skill that converts Markdown files to publication-ready A4 PDFs, with automatic ASCII-art-to-Mermaid conversion, CJK font handling, and a self-check loop that reads its own output and fixes rendering issues. Born from a real session where a "simple PDF export" turned into a 2-hour debugging marathon across SVG font failures, Mermaid syntax traps, and weasyprint's creative interpretation of Chinese characters. This document covers the motivation, technical gaps, design decisions, and the battle scars that shaped every rule in the pipeline.
      
      ---
      
      # 設計文件
      
      ## 這個 Skill 的誕生故事
      
      一切始於一句看似無害的話:「把這份 Markdown 轉成 PDF,要給廠商看。」
      
      聽起來很簡單對吧?Markdown 轉 PDF,2024 年了,應該跟呼吸一樣自然。結果我們花了整整一個對話的時間,來回除錯,才搞定一份看起來正常的 PDF。不是因為工具不夠多 — pandoc、weasyprint、mermaid-cli 都在手邊 — 而是因為這些工具的組合拳,每一拳都往意想不到的地方打。
      
      所以我們決定把這些血淚經驗封裝成一個 skill,讓下次(以及每一次)不用再重新踩一遍。
      
      ---
      
      ## 技術缺口:為什麼現有工具不夠
      
      ### 問題一:Mermaid + PDF = 字型地獄
      
      pandoc 可以透過 lua filter 呼叫 `mmdc` 把 Mermaid 渲染成圖片嵌入 PDF。聽起來很美好。
      
      但如果你用 SVG 輸出,weasyprint 渲染時會嘗試用系統字型來顯示 SVG 裡的文字。問題是 Mermaid 的 SVG 會引用它自己的字型(通常是 sans-serif),而 weasyprint 找不到對應的字型,結果就是:**方框都在,文字全消失**。
      
      你盯著一份只有框線沒有字的流程圖,會開始懷疑人生。
      
      > 解法:一律用 PNG 輸出,scale 3x 確保解析度。文字直接烤進圖片裡,不再依賴字型渲染。粗暴但有效。
      
      ### 問題二:CJK 在 Code Block 裡變問號
      
      weasyprint 對 `<pre>` 和 `<code>` 預設使用 monospace 字型。macOS 的 monospace 字型(Menlo、Courier)不包含中日韓字元。所以你會看到 `http://{???IP}:8080` 這種鬼東西。
      
      更刺激的是,pandoc 的語法高亮會把程式碼包在 `<span>` 裡面,每個 span 可能用不同的 CSS class,而這些 class 的字型設定又不一定繼承父元素。於是 JSON 裡的 `{`、`}`、`:` 和數字全部變成方框。
      
      > 解法:關閉語法高亮(`--no-highlight`),然後在 CSS 裡對 `pre`、`code` 顯式指定 `"Menlo", "Heiti TC", "Arial Unicode MS", monospace` 的 fallback chain。Menlo 負責英文字元,Heiti TC 接住中文。
      
      ### 問題三:Mermaid 的 Markdown 解析陷阱
      
      Mermaid 的節點文字支援 Markdown 語法 — 這聽起來是個功能,實際上是個坑。
      
      - 節點裡寫 `"1. 客戶提供測試集"` → Mermaid 把 `1.` 解析成 ordered list → 渲染出 "Unsupported markdown: list"
      - 節點裡寫 `\n` 換行 → 某些渲染器會原封不動地把 `\n` 當文字印出來
      - 全形括號 `()`、特殊符號 `≥` → 可能觸發解析錯誤
      
      > 解法:`\n` 一律改 `<br/>`。節點文字移除數字編號開頭。特殊符號用 ASCII 替代或移除。
      
      ### 問題四:圖太高,PDF 跑版
      
      一個 7 層垂直的 ResNet 架構圖,渲染成 PNG 後高度超過 A4 頁面。weasyprint 的做法是:把圖片推到下一頁,前一頁留白。然後圖片還是超出邊界被裁切。
      
      > 解法:CSS 加上 `img { max-height: 700px; }`。Mermaid 圖如果太高就改成橫向(LR)佈局。自檢時偵測空白頁,觸發自動修正。
      
      ---
      
      ### 問題五:所有表格欄寬都一樣,整份文件看起來很業餘
      
      這條是最晚才發現的,也是最影響觀感的一條。
      
      Markdown 表格寫 `|---|---|---|` 是常態,沒人會去數破折號。問題是這樣寫出來,每一欄都等寬——一個只放編號的「#」欄,跟一個放三句話的說明欄,佔的寬度一模一樣。編號欄空一大片,說明欄擠成細長條、一句話折成五行。表格一多,整份 PDF 就散了。
      
      修法本身不難,難在**它需要兩個半邊同時到位,少一邊完全沒作用**:
      
      - **Markdown 這半**:pandoc **預設的** markdown reader 會把分隔列的破折號比例轉成 `<col style="width:N%">`。`|---|------------|` 就是 1:4。
      - **CSS 這半**:要加 `table-layout: fixed`,瀏覽器與 weasyprint 才會真的照那個比例排;否則它會自己依內容重算,`<col>` 形同虛設。
      
      只做 markdown 那半,看 HTML 有 `width:N%` 但 PDF 沒變化,會以為 pandoc 壞了。只做 CSS 那半,欄寬變成純粹依內容自動分配,一樣不是你要的。
      
      還有一個陷阱:**`-f gfm` 會讓整件事直接失效**。gfm reader 不理會分隔列比例,`<col>` 根本不會產生。很多人為了要 GitHub 相容的表格語法而加 `-f gfm`,然後怎麼調都沒反應。
      
      > 解法:不要手調。寫一支腳本依各欄實際內容長度自動配比(中文字算兩格),CSS 那半併進 style 樣板。實測一份表格密集的報告,40 頁 → 34 頁,內容一個字沒刪。
      
      順帶一個副作用要處理:`word-wrap: break-word` 在欄位過窄時會把英文字從中間切開(`PostgreSQL` 變成 `PostgreS / QL`)。所以每一欄要再設一個下限,寬到放得下該欄最長的那個英文詞。這個係數跟字型有關,CJK sans-serif 的英文字比預期寬,要試出來。
      
      ---
      
      ## 競品分析(又名「一定有人做過了吧?」)
      
      做之前我們先查了一輪。Markdown 轉 PDF,2026 年了,應該有成熟方案。結果發現大家都做了一部分,但沒有人把 CJK + Mermaid + 自動修正這條路走完。
      
      ### 現有方案一覽
      
      | 工具 | 它做什麼 | 它不做什麼 |
      |------|---------|-----------|
      | **pandoc + LaTeX** | 業界黃金標準。排版精美,學術論文首選。 | macOS 裝 LaTeX 要下載 3-4 GB。CJK 需要額外設定 XeLaTeX + 字型。門檻高到讓人想放棄人生。 |
      | **md-to-pdf (npm)** | 用 Puppeteer 渲染,CSS 驅動,裝起來簡單。 | 不處理 Mermaid code block。你的流程圖會變成一坨原始碼躺在 PDF 裡。 |
      | **Typora Export** | GUI 一鍵匯出,所見即所得,Mermaid 原生支援。 | GUI 工具。你不能在 terminal 裡自動化它。AI agent 更不可能幫你按按鈕。 |
      | **VS Code Markdown PDF** | VS Code 套件,右鍵就能轉。方便。 | 同上,IDE 套件不能 CLI 化。而且 CJK 字型問題一樣存在。 |
      | **Marp** | 把 Markdown 變成簡報 PDF。如果你要的是投影片,它很強。 | 它做的是簡報,不是文件。A4 多頁技術文件不是它的主場。 |
      | **grip + wkhtmltopdf** | 用 GitHub API 渲染 Markdown,然後轉 PDF。 | 需要網路連線呼叫 GitHub API。Mermaid 不渲染。wkhtmltopdf 已經停止維護了。 |
      
      ### 沒人填補的缺口
      
      我們注意到一個有趣的現象:**能處理 Mermaid 的工具不支援 CLI,能 CLI 的工具不處理 Mermaid,能處理 Mermaid 又能 CLI 的不處理 CJK。**
      
      然後更關鍵的是 -- **沒有人做自動修正。**
      
      所有工具都是「轉完就丟給你」。SVG 文字消失?你的問題。圖太大跑版?你的問題。code block 裡中文變問號?你的問題。
      
      我們想要的是:轉完之後自己看一遍,壞了就自己修。不要讓使用者當 QA。
      
      這就是這個 skill 的切入點 -- 不是做一個更好的轉換器,而是做一個**會自我檢查的轉換流程**。
      
      ---
      
      ## 設計決策
      
      ### 決策一:不動原檔
      
      原始 Markdown 裡的 ASCII art 是作者的心血。有些人就是喜歡用 `┌─┐` 和 `──►` 畫圖,而且在 terminal、GitHub preview、HackMD 上看起來都好好的。
      
      所以我們複製一份 `_pdf.md` 來改,原檔一個字都不動。
      
      如果下次執行時發現 `_pdf.md` 已經存在,會詢問使用者:「要直接用上次的版本轉?還是從原檔重新產?」 — 因為使用者可能已經手動微調過 `_pdf.md`,直接覆蓋就太不尊重人了。
      
      ### 決策二:ASCII Art 辨識而非全部轉換
      
      不是所有 code block 裡的東西都該變成 Mermaid。目錄樹(`├── src/`)就該是目錄樹。
      
      辨識規則:
      - 有箭頭(`→ ► ▼ ──►`)+ 方框(`┌ ┐ └ ┘`)→ 大概率是流程圖 → 轉 Mermaid
      - `├──` `└──` + 檔名路徑 → 目錄樹 → 保留
      - 已經是 ` ```mermaid ` → 不動
      - 不確定 → 保留,寧可不轉也不要轉壞
      
      ### 決策三:自檢但設上限
      
      PDF 產出後會自動讀取每一頁,檢查:
      - 幾乎空白的頁面(圖被推到下頁)
      - Mermaid 渲染失敗文字("Unsupported markdown")
      - 圖片超出頁面邊界
      
      發現問題就修 `_pdf.md` 然後重新產。但最多重試 **3 次**。實測證明 2 次不夠 -- 有時候第一次修錯方向,第二次修對方向但不完整,第三次才真正解決。但 3 次之後如果還是壞的,問題大概率需要人類判斷,繼續重試也只是浪費時間。
      
      ### 決策四:風格可選但有預設
      
      執行時會列出幾種 CSS 風格讓使用者選。不選的話就自動決定一個合適的。
      
      因為「給客戶看的規格書」和「給工程師看的技術手冊」,排版需求天差地別。但如果使用者只是想快速轉個 PDF,不應該被迫思考 CSS。
      
      ### 決策五:依賴先檢查
      
      執行前先確認 `pandoc`、`mmdc`、`weasyprint` 都存在。缺少就報錯 + 給安裝指令,不要跑到一半才炸。
      
      因為 pandoc 跑到一半發現沒有 weasyprint 的那個錯誤訊息,不是每個人都看得懂的。
      
      ---
      
      ## 完整流程
      
      ```
      原始 file.md(不動)
            │
            ├── 複製 → file_pdf.md
            │
            ▼
      ASCII art 辨識 + 轉 Mermaid
            │
            ▼
      Mermaid 語法清理
      (\n→<br/>, 移除 list 語法, 特殊符號處理)
            │
            ▼
      產生暫存 lua filter + CSS
            │
            ▼
      pandoc + weasyprint → PDF
            │
            ▼
      自檢(逐頁讀取)
            │
            ├── OK → 清理暫存檔 → 完成
            └── 有問題 → 修正 → 重新產(最多 3 次)
                    └── 仍有問題 → 停止,告知使用者
      ```
      
      ---
      
      ## 技術棧
      
      | 工具 | 用途 | 為什麼選它 |
      |------|------|----------|
      | pandoc | Markdown → HTML → PDF 的管線 | 業界標準,lua filter 擴展性強 |
      | weasyprint | HTML → PDF 引擎 | 不需要 LaTeX,CSS 驅動,macOS 友善 |
      | mmdc (mermaid-cli) | Mermaid → PNG | 官方 CLI,支援 CJK |
      | lua filter | pandoc 擴展 | 在轉換過程中攔截 mermaid code block,呼叫 mmdc |
      
      ### 為什麼不用 LaTeX?
      
      因為 macOS 上裝 LaTeX 要下載 3-4 GB 的東西,而且中文排版需要額外設定 XeLaTeX + CJK 字型。weasyprint 用 CSS 就能搞定,裝起來只要 `pip install weasyprint`。
      
      取捨很明確:LaTeX 排版更精緻,但 weasyprint 夠用且門檻極低。我們要的是「快速產出可交付的 PDF」,不是「排一本書」。
      
      ---
      
      ## 實測結果
      
      我們拿了一份真實的產品規格書(一對一聊天紅包發送功能,506 行 Markdown)來跑第一次端到端測試。這份文件什麼都有:4 個 Mermaid 圖、5 個 ASCII UI 線框稿、大量 CJK 表格、JSON code block 含中文註解。
      
      ### 第一次產出
      
      大部分都正常 -- Mermaid 圖渲染正確、ASCII 線框稿保留、CJK 字型沒問題。但 3.3 業務規則表格爆了:9 行全部擠成一個 cell。
      
      ### 診斷過程
      
      一開始以為是表格內的 `**bold**` 語法干擾了 pandoc 解析。移除 bold,沒用。
      
      接著懷疑是 `$5,000` 的 `$` 符號。轉義了 `$5,000` → `\$5,000`,還是沒用。
      
      最後用 `pandoc -t html` 看中間產物,發現真正的兇手:pandoc 把 `NT$1 ~ NT$5,000` 裡的兩個 `$` 配對成 LaTeX inline math,中間所有的 `|`(表格分隔符)都被吃掉了。`$1 ~ NT$` 變成一個 math span,後面 7 行的表格結構全部崩潰。
      
      解法:轉義所有 `$` → `\$`。
      
      ### 最終結果
      
      15 頁 PDF,1.3 MB。所有內容正確渲染,無跑版、無裁切、無字型問題。
      
      ### 學到的教訓
      
      1. **永遠先看 HTML 中間產物**。PDF 出問題時,先 `pandoc -t html` 確認是 pandoc 解析問題還是 weasyprint 渲染問題。這次如果一開始就看 HTML,可以省掉一次無效的 retry。
      2. **`$` 是隱形殺手**。在含有貨幣符號的技術文件中(`NT$`、`US$`),pandoc 的 LaTeX math 解析會默默地吞掉表格結構。這不是 bug,是 feature(pandoc 的角度),但對我們來說就是坑。
      3. **2 次 retry 不夠**。這次實際用了 3 次才修好。第一次修錯方向(移除 bold),第二次修對方向但不完整(只轉義了一個 `$`),第三次才全部轉義成功。所以 retry 上限從 2 調整為 3。
      
      ---
      
      ## 這份文件為什麼存在
      
      因為下一個碰到「Markdown 轉 PDF 怎麼這麼多坑」的人,不應該要重新踩一遍。
      
      每個決策背後都有一個「我們試過了,壞掉了,然後學到了」的故事。把這些故事記下來,就是設計文件存在的意義。
      
      不然你以為 `--no-highlight` 和 `"Menlo", "Heiti TC"` 這種詭異的 CSS 組合是怎麼來的?不是靈感,是絕望。
      
  • scripts
    • build_pdf.sh 13.8 KB
      #!/usr/bin/env bash
      # build_pdf.sh — Markdown -> A4 PDF (pandoc + weasyprint), with the layout
      # decisions that are painful to rediscover every time baked in.
      #
      #   ./build_pdf.sh SOURCE.md [OUTPUT.pdf] [options]
      #
      # Options
      #   --style professional|technical|minimal   default: professional
      #   --toc / --no-toc                         default: --no-toc
      #   --toc-depth N                            default: 2
      #   --mermaid vertical|keep                  default: vertical (see below)
      #
      # Requires: pandoc, mmdc (@mermaid-js/mermaid-cli), weasyprint.
      # Optional: pdftoppm + Pillow, for the per-page ink self-check.
      #
      # What this handles that a bare pandoc call does not
      # --------------------------------------------------
      # 1. VERSION FOOTER. A `> Version: x` / `> 版本:x` line directly under the H1 is
      #    lifted out of the body and printed bottom-right on every page. Without it,
      #    nobody can tell which draft they are holding. No version line -> no footer.
      # 2. TOC IS OPT-IN. A cover page plus a contents list for a 2-page document is
      #    pure overhead. Off by default; turn it on for long documents.
      # 3. WIDE MERMAID -> VERTICAL. A `flowchart LR` chain is fine on screen but gets
      #    scaled down to unreadable when squeezed into A4's ~17cm text column. Long
      #    horizontal chains are flipped to TD for the PDF only; the source keeps LR.
      # 4. TABLES SPLIT, ROWS DO NOT. A table taller than the page must break between
      #    rows with the header repeated, or it gets pushed whole onto the next page
      #    and leaves most of one blank.
      #
      # The original file is never modified; all rewriting happens on a temp copy.
      
      set -euo pipefail
      
      SRC_IN=""; OUT=""; STYLE="professional"; WANT_TOC=0; TOC_DEPTH=2; MERMAID="vertical"
      while [ $# -gt 0 ]; do
        case "$1" in
          --style)     STYLE="$2"; shift 2 ;;
          --toc)       WANT_TOC=1; shift ;;
          --no-toc)    WANT_TOC=0; shift ;;
          --toc-depth) TOC_DEPTH="$2"; shift 2 ;;
          --mermaid)   MERMAID="$2"; shift 2 ;;
          -h|--help)   sed -n '2,30p' "$0"; exit 0 ;;
          -*)          echo "unknown option: $1" >&2; exit 2 ;;
          *)           if [ -z "$SRC_IN" ]; then SRC_IN="$1"; else OUT="$1"; fi; shift ;;
        esac
      done
      
      [ -n "$SRC_IN" ] || { echo "usage: build_pdf.sh SOURCE.md [OUTPUT.pdf] [options]" >&2; exit 2; }
      [ -f "$SRC_IN" ] || { echo "source not found: $SRC_IN" >&2; exit 1; }
      
      for t in pandoc mmdc weasyprint; do
        command -v "$t" >/dev/null || { echo "missing: $t" >&2; exit 1; }
      done
      
      # Work from the source file's directory so relative <img src="img/..."> still resolve.
      SRC_ABS="$(cd "$(dirname "$SRC_IN")" && pwd)/$(basename "$SRC_IN")"
      cd "$(dirname "$SRC_ABS")"
      SRC="$(basename "$SRC_ABS")"
      OUT="${OUT:-$(basename "$SRC" .md).pdf}"
      WORK="$(mktemp -d)"
      trap 'rm -rf "$WORK"' EXIT
      
      # ---------------------------------------------------------------- preprocess
      python3 - "$SRC" "$WORK/doc.md" "$MERMAID" "$WORK" <<'PY'
      import re, struct, subprocess, sys
      
      src, dst, mermaid_mode, work = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
      s = open(src, encoding="utf-8").read()
      
      # --- Mermaid: render, and pick the orientation that stays legible ----------
      # A horizontal chain is fine on screen and too small on paper: A4's text column
      # is ~17cm, so a 5:1 diagram is scaled to about a quarter and its labels stop
      # being readable. Which orientation wins is not guessable from node count -- a
      # vertical version can be so tall that the height cap shrinks it right back.
      #
      # So render both and measure. The objective is the scale factor the page will
      # actually apply, min(width_fit, height_fit); whichever orientation keeps the
      # larger one keeps the bigger text. Vertical costs page height, which is the
      # cheaper resource. `--mermaid keep` opts out entirely.
      PAGE_W_PX = 643      # 17cm text column at 96dpi
      PAGE_H_PX = 640      # CSS img max-height
      flipped = rendered = 0
      
      def png_size(path):
          with open(path, "rb") as f:
              return struct.unpack(">II", f.read(26)[16:24])
      
      def render(body, tag):
          mmd, png = f"{work}/{tag}.mmd", f"{work}/{tag}.png"
          open(mmd, "w", encoding="utf-8").write(body)
          r = subprocess.run(["mmdc", "-i", mmd, "-o", png, "-b", "white", "--scale", "3"],
                             capture_output=True)
          if r.returncode != 0:
              return None
          try:
              return png, png_size(png)
          except Exception:
              return None
      
      def fit(size):
          w, h = size
          return min(PAGE_W_PX / w, PAGE_H_PX / h)
      
      def handle(m):
          global flipped, rendered
          body = m.group(1)
          rendered += 1
          tag = f"d{rendered}"
          base = render(body, tag)
          if base is None:
              return m.group(0)          # let it through as a code block rather than lose it
          png, size = base
          horizontal = re.match(r"^\s*(flowchart|graph)\s+LR\b", body)
          if mermaid_mode == "vertical" and horizontal:
              alt_body = re.sub(r"^(\s*)(flowchart|graph)\s+LR\b", r"\1\2 TD", body, count=1, flags=re.M)
              alt = render(alt_body, tag + "v")
              if alt and fit(alt[1]) > fit(size):
                  png = alt[0]
                  flipped += 1
          # Alt text must stay empty: pandoc promotes a non-empty alt into a figure
          # caption, so every diagram would print the word "diagram" underneath it.
          return f"![]({png})"
      
      s, total = re.subn(r"```mermaid\n(.*?)```", handle, s, flags=re.S)
      
      # --- H1 -> pandoc metadata title ------------------------------------------
      # Must be removed from the body, not hidden with {.unlisted}: the H1 is the TOC's
      # only top-level entry and everything else nests under it, so marking it unlisted
      # deletes the entire tree and the contents page comes out empty.
      m = re.search(r"^# (.+)$", s, flags=re.M)
      title = m.group(1).strip() if m else ""
      if m:
          s = s[:m.start()] + s[m.end():].lstrip("\n")
      
      # --- Version line -> per-page footer --------------------------------------
      mv = re.search(r"^> *(?:版本|Version)[::] *(.+)$", s, flags=re.M)
      version = mv.group(1).strip() if mv else ""
      if mv:
          s = s[:mv.start()] + s[mv.end():].lstrip("\n")
      
      open(dst, "w", encoding="utf-8").write(s)
      open(dst + ".title", "w", encoding="utf-8").write(title)
      open(dst + ".version", "w", encoding="utf-8").write(version)
      
      print(f"title: {title or '(none)'}")
      print(f"version footer: {version or '(none — no `> Version:` line found)'}")
      print(f"mermaid: {total} diagram(s), {flipped} turned vertical for legibility")
      PY
      
      # -------------------------------------------------------------------- styles
      case "$STYLE" in
        professional) ACCENT="#1a3a5c"; ACCENT2="#2c5480"; RULE="#c8d4e0"; ZEBRA="#f4f7fa"; BODY_PT="10.5pt"; TBL_PT="9.5pt" ;;
        technical)    ACCENT="#8a4b08"; ACCENT2="#a9631a"; RULE="#e0d2c0"; ZEBRA="#faf6f1"; BODY_PT="10pt";   TBL_PT="9pt" ;;
        minimal)      ACCENT="#000000"; ACCENT2="#333333"; RULE="#cccccc"; ZEBRA="#f5f5f5"; BODY_PT="10.5pt"; TBL_PT="9.5pt" ;;
        *) echo "unknown --style: $STYLE (professional|technical|minimal)" >&2; exit 2 ;;
      esac
      if [ "$STYLE" = "minimal" ]; then TH_BG="#ffffff"; TH_FG="#000000"; else TH_BG="$ACCENT"; TH_FG="#ffffff"; fi
      
      cat > "$WORK/style.css" <<CSS
      @page { size: A4; margin: 2cm; @bottom-center { content: counter(page); font-size: 9pt; color: #888; } }
      __VERSION_FOOTER__
      
      /* NEVER add "Apple Color Emoji" to this font-family, at any position. It carries
         keycap glyphs for 0-9, and weasyprint will route every Arabic numeral in the
         document to it -- they then print as blank space. The text layer still contains
         the digits, so the damage is invisible until someone looks at the paper.
         For coloured status markers, embed a PNG instead of relying on emoji glyphs. */
      body { font-family: "Heiti TC","PingFang TC","Arial Unicode MS",sans-serif; font-size: $BODY_PT; line-height: 1.65; color: #222; }
      h1 { color: $ACCENT; font-size: 20pt; border-bottom: 3px solid $ACCENT; padding-bottom: 8px; }
      h2 { color: $ACCENT; font-size: 15pt; margin-top: 1.6em; border-bottom: 1px solid $RULE; padding-bottom: 4px; page-break-after: avoid; }
      h3 { color: $ACCENT2; font-size: 12.5pt; margin-top: 1.2em; page-break-after: avoid; }
      h4 { color: $ACCENT2; font-size: 11pt; page-break-after: avoid; }
      
      /* table-layout: fixed is what makes the separator-row dash ratios (which pandoc
         emits as <col style="width:N%">) actually bind. Ratios alone are a silent
         no-op; run scripts/table_widths.py to compute them from cell contents. */
      table { border-collapse: collapse; width: 100%; margin: 1em 0; font-size: $TBL_PT; page-break-inside: auto; table-layout: fixed; }
      td, th { word-wrap: break-word; overflow-wrap: break-word; }
      thead { display: table-header-group; }
      tr { page-break-inside: avoid; }
      th { background-color: $TH_BG; color: $TH_FG; padding: 7px 9px; text-align: left; font-weight: 600; border-bottom: 2px solid $RULE; }
      td { padding: 6px 9px; border-bottom: 1px solid $RULE; vertical-align: top; }
      tr:nth-child(even) td { background-color: $ZEBRA; }
      
      blockquote { border-left: 4px solid $ACCENT2; background: $ZEBRA; margin: 1em 0; padding: 0.6em 1em; color: #33475b; }
      code { font-family: "Menlo","Heiti TC","Arial Unicode MS",monospace; font-size: 9pt; background: #eef1f5; padding: 1px 4px; border-radius: 3px; }
      pre { white-space: pre-wrap; word-wrap: break-word; background: #f6f8fa; padding: 10px; border-radius: 4px; }
      pre code { background-color: transparent; padding: 0; }
      img { max-width: 100%; max-height: 640px; display: block; margin: 1em auto; }
      /* Tall portrait diagrams need more headroom than the default; A4's text area is
         25.7cm high, so leave room for the heading that precedes it.
         Use a class, not img[src*="..."]: --pdf-engine base64-inlines images, so the
         src becomes data:image/png;... and attribute selectors never match. */
      img.tall { max-height: 23cm; }
      a { color: $ACCENT2; text-decoration: none; }
      ul, ol { padding-left: 1.6em; }
      li { margin: 0.25em 0; }
      
      /* Contents page. weasyprint supports target-counter() for real page numbers and
         leader('.') for the dot fill. pandoc --toc emits <nav id="TOC">. */
      h1.title { color: $ACCENT; font-size: 20pt; font-weight: 700; border-bottom: 3px solid $ACCENT; padding-bottom: 8px; margin-bottom: 1.4em; }
      __TOC_PAGE__
      nav#TOC::before {
        content: "__TOC_LABEL__";
        display: block; color: $ACCENT; font-size: 15pt; font-weight: 700;
        border-bottom: 1px solid $RULE; padding-bottom: 4px; margin-top: 1.6em; margin-bottom: 1em;
      }
      nav#TOC ul { list-style: none; padding-left: 0; margin: 0; }
      nav#TOC > ul > li { margin-top: .5em; font-weight: 600; }
      nav#TOC ul ul { padding-left: 1.4em; margin-top: .2em; }
      nav#TOC ul ul li { font-weight: 400; font-size: 10pt; }
      nav#TOC a { color: #222; text-decoration: none; }
      nav#TOC a::after { content: leader('.') target-counter(attr(href), page); color: #8a97a5; font-weight: 400; }
      @page toc { @bottom-center { content: none; } }
      CSS
      
      VER="$(cat "$WORK/doc.md.version" 2>/dev/null || true)"
      TOC_LABEL="${TOC_LABEL:-Contents}"
      
      python3 - "$WORK/style.css" "$VER" "$WANT_TOC" "$TOC_LABEL" <<'PY'
      import sys
      path, version, want_toc, label = sys.argv[1], sys.argv[2], sys.argv[3] == "1", sys.argv[4]
      css = open(path, encoding="utf-8").read()
      
      css = css.replace("__VERSION_FOOTER__",
          '@page { @bottom-right { content: "%s"; font-size: 8pt; color: #999; } }' % version.replace('"', "'")
          if version else "")
      
      # A named page forces a break, so the title block and the TOC must share ONE named
      # page or they each take a sheet. With no TOC, the title must NOT be on a named
      # page at all, or it still gets a sheet to itself in front of a 2-page document.
      css = css.replace("__TOC_PAGE__",
          "header#title-block-header { page: toc; }\nnav#TOC { page: toc; page-break-after: always; }"
          if want_toc else "header#title-block-header { margin-top: 0; }")
      css = css.replace("__TOC_LABEL__", label)
      open(path, "w", encoding="utf-8").write(css)
      PY
      
      # -------------------------------------------------------------------- render
      # Note the `+` expansion: under `set -u`, bash 3.2 (still the macOS default)
      # treats "${arr[@]}" on an empty array as an unbound variable and aborts.
      TOC_ARGS=()
      [ "$WANT_TOC" = "1" ] && TOC_ARGS=(--toc --toc-depth="$TOC_DEPTH")
      
      pandoc "$WORK/doc.md" \
        ${TOC_ARGS[@]+"${TOC_ARGS[@]}"} \
        --metadata title="$(cat "$WORK/doc.md.title")" \
        --pdf-engine=weasyprint \
        --css="$WORK/style.css" \
        --resource-path=".:$PWD:$WORK" \
        --syntax-highlighting=none \
        -o "$OUT"
      
      case "$OUT" in /*) echo "wrote: $OUT" ;; *) echo "wrote: $PWD/$OUT" ;; esac
      
      # ---------------------------------------------------------------- self-check
      # Two different failures, two different thresholds:
      #   - a middle page under ~4% ink means a table or diagram was pushed off it
      #   - a LAST page holding one or two lines is a trailing orphan. Do NOT exempt
      #     the final page from checking: a real tail page is still fairly full, and
      #     blanket-skipping it is exactly how a one-line page ships unnoticed.
      if command -v pdftoppm >/dev/null; then
        pdftoppm -png -r 80 "$OUT" "$WORK/pg"
        python3 - "$WORK" "$WANT_TOC" <<'PY'
      import glob, sys
      try:
          from PIL import Image
      except ImportError:
          print("(Pillow not installed — skipping ink self-check)"); raise SystemExit
      work, want_toc = sys.argv[1], sys.argv[2] == "1"
      pages = sorted(glob.glob(work + "/pg-*.png"))
      def ink(p):
          im = Image.open(p).convert("L")
          return sum(im.histogram()[:240]) / (im.size[0] * im.size[1]) * 100
      thin, orphan = [], None
      for i, p in enumerate(pages):
          if i == 0 and want_toc:      # cover + contents is legitimately sparse
              continue
          v = ink(p)
          if i == len(pages) - 1:
              if v < 1.5 and len(pages) > 1:
                  orphan = v
          elif v < 4:
              thin.append((i + 1, v))
      print(f"{len(pages)} page(s)")
      if thin:
          print("WARNING thin pages (something was pushed off):",
                ", ".join(f"p{n} {v:.1f}%" for n, v in thin))
      if orphan is not None:
          print(f"WARNING last page is a trailing orphan ({orphan:.2f}% ink) — "
                "shorten the text above it, or fold the closing line into the "
                "preceding block, so the document ends one page earlier")
      if not thin and orphan is None:
          print("ink check passed — no thin or orphan pages")
      PY
      fi
      
    • table_widths.py 4.9 KB
      #!/usr/bin/env python3
      """Rewrite markdown table separator rows so dash counts encode per-column width ratios.
      
      Why this exists
      ---------------
      pandoc's DEFAULT markdown reader turns the dash proportions in a table's separator
      row into `<col style="width:N%">`. That only has any visible effect when the CSS
      also sets `table-layout: fixed`. Both halves are required:
      
          |---|------------|          <- ratio lives here (markdown)
          table { table-layout: fixed }  <- enforcement lives here (CSS)
      
      With the usual `|---|---|---|`, every column is equal width, so a one-character
      "#" column gets as much room as a column holding three sentences. On a
      table-heavy document that wastes a large fraction of every page.
      
      Two gotchas this script handles
      -------------------------------
      1. CJK characters occupy two cells, so column weight is measured in display
         width, not `len()`.
      2. `word-wrap: break-word` will split a Latin word mid-token when its column is
         too narrow (e.g. "PostgreSQL" -> "PostgreS / QL"). Each column therefore gets
         a floor wide enough for its longest unbreakable token.
      
      Usage
      -----
          python3 table_widths.py FILE.md [FILE.md ...]
      
      Rewrites in place. Idempotent: running it twice produces the same result, because
      ratios are recomputed from cell contents, not from the previous separator row.
      
      Caveat: pandoc's `gfm` reader ignores separator-row proportions entirely. If the
      build passes `-f gfm`, drop that flag or this script has no effect.
      """
      
      import re
      import sys
      import unicodedata
      
      TOTAL_DASHES = 110      # ~= full text width; only ratios matter, not the absolute
      POWER = 0.62            # <1 compresses extremes so one long column can't crush the rest
      MIN_DASHES = 3
      TOKEN_FACTOR = 1.5      # empirical: dashes needed per char of an unbreakable token
      TOKEN_PAD = 5           # plus cell padding, in dashes
      
      
      def display_width(text):
          """Width in terminal cells, ignoring markdown emphasis and inline HTML."""
          text = re.sub(r"\*\*|`|<[^>]+>", "", text)
          return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in text)
      
      
      def longest_token(texts):
          """Longest unbreakable run of Latin/digits across the given cells."""
          best = 0
          for t in texts:
              t = re.sub(r"\*\*|`|<[^>]+>", "", t)
              for tok in re.findall(r"[A-Za-z0-9][A-Za-z0-9._+-]*", t):
                  best = max(best, len(tok))
          return best
      
      
      def split_row(line):
          s = line.strip()
          if s.startswith("|"):
              s = s[1:]
          if s.endswith("|"):
              s = s[:-1]
          return [c.strip() for c in re.split(r"(?<!\\)\|", s)]   # keep escaped \| intact
      
      
      def is_separator(line):
          s = line.strip()
          return bool(s.startswith("|") and re.fullmatch(r"\|[\s:\-|]+\|", s) and "-" in s)
      
      
      def rewrite(path):
          lines = open(path, encoding="utf-8").read().split("\n")
          out, i, count = [], 0, 0
      
          while i < len(lines):
              if i + 1 < len(lines) and lines[i].strip().startswith("|") and is_separator(lines[i + 1]):
                  header = split_row(lines[i])
                  ncol = len(header)
      
                  body, j = [], i + 2
                  while j < len(lines) and lines[j].strip().startswith("|") and not is_separator(lines[j]):
                      row = split_row(lines[j])
                      if len(row) == ncol:
                          body.append(row)
                      j += 1
      
                  weights, floors = [], []
                  for c in range(ncol):
                      cells = [display_width(header[c])] + [display_width(r[c]) for r in body]
                      # average drives the ratio; max keeps a column with one long cell from being starved
                      weights.append(0.7 * (sum(cells) / len(cells)) + 0.3 * max(cells))
                      tok = longest_token([header[c]] + [r[c] for r in body])
                      floors.append(max(MIN_DASHES, round(tok * TOKEN_FACTOR) + TOKEN_PAD) if tok else MIN_DASHES)
      
                  adjusted = [max(w, 1) ** POWER for w in weights]
                  total = sum(adjusted)
                  dashes = [max(floors[c], round(TOTAL_DASHES * adjusted[c] / total)) for c in range(ncol)]
      
                  old = split_row(lines[i + 1])
                  sep = []
                  for c in range(ncol):
                      marker = old[c] if c < len(old) else "---"
                      left, right = marker.startswith(":"), marker.endswith(":")
                      sep.append((":" if left else "") + "-" * dashes[c] + (":" if right else ""))
      
                  out.append(lines[i])
                  out.append("|" + "|".join(sep) + "|")
                  out.extend(lines[i + 2:j])
                  i = j
                  count += 1
              else:
                  out.append(lines[i])
                  i += 1
      
          open(path, "w", encoding="utf-8").write("\n".join(out))
          return count
      
      
      def main():
          args = [a for a in sys.argv[1:] if not a.startswith("-")]
          if not args or any(a in ("-h", "--help") for a in sys.argv[1:]):
              print(__doc__)
              return 0 if args or "-h" in sys.argv or "--help" in sys.argv else 1
          for path in args:
              print(f"{path}: {rewrite(path)} tables rebalanced")
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
  • SKILL.md 16.7 KB
    ---
    name: md2pdf
    description: "Use when the user wants to convert one Markdown file into a publication-ready A4 PDF, especially when the source may contain Mermaid diagrams, ASCII diagrams, CJK text, tables, or pandoc/weasyprint edge cases. Works by copying the source to a _pdf.md working file, converting diagrams, escaping PDF-breaking syntax, balancing table column widths, rendering with pandoc + weasyprint, then self-checking pages by ink coverage. Cleans up intermediates only after the user calls the version final. NOT for batch conversion, slide decks, or editing the original Markdown in place."
    version: 1.3.0
    status: stable
    triggers:
      - "/md2pdf"
      - "轉 pdf"
      - "markdown 轉 pdf"
      - "convert to pdf"
    ---
    
    # md2pdf
    
    You are a Markdown-to-PDF production assistant. You convert exactly one Markdown file at a time into a clean, publication-ready A4 PDF while preserving the original source file.
    
    ## Trigger
    
    ```
    /md2pdf path/to/file.md
    ```
    
    ## Prerequisites Check
    
    Before anything else, verify these tools exist. If any is missing, stop and show install commands:
    
    ```bash
    # Check all three
    which pandoc && which mmdc && which weasyprint
    ```
    
    Missing tool install commands:
    - **pandoc**: `brew install pandoc`
    - **mmdc**: `npm install -g @mermaid-js/mermaid-cli`
    - **weasyprint**: `pip install weasyprint` or `brew install weasyprint`
    
    ## Workflow
    
    ### Step 1: Check for existing _pdf.md
    
    If `{filename}_pdf.md` already exists, ask the user:
    - **Use existing**: convert `_pdf.md` directly to PDF (user may have manually tuned it)
    - **Regenerate**: copy from original and redo all conversions
    
    ### Step 2: Ask style, and ask about a table of contents
    
    **2a. CSS style.** Present options; if the user doesn't choose, pick the most suitable one automatically:
    
    - **Professional** — dark blue headers, gray alternating rows, blue accent blockquotes (good for client-facing docs)
    - **Technical** — compact, orange accent blockquotes, smaller fonts (good for dev manuals)
    - **Minimal** — black and white, no colored headers (good for printing)
    
    **2b. Table of contents — always ask, never assume.** A contents page also pulls the
    title onto a cover sheet, so switching it on costs a full page before the reader
    reaches any content. That is right for a report and absurd for a two-page memo.
    
    Ask outright. If the user has no opinion, decide by length and say which you picked:
    
    | Source length | Default | Why |
    |---------------|---------|-----|
    | Under ~6 pages of content | **No TOC** | A cover plus a contents list for a handful of sections is pure overhead |
    | Longer, or many `##` sections | **TOC** | Real page numbers make it navigable |
    
    **2c. Version footer.** If the document carries a `> Version: ...` (or `> 版本:...`)
    line under its H1, it is lifted out of the body and printed bottom-right on every
    page. If the document is going to a third party and has no version line, offer to
    add one — without it nobody can tell which draft they are holding.
    
    ### Step 3: Copy original → {filename}_pdf.md
    
    **Never modify the original file.** All changes happen on the copy.
    
    ### Step 4: ASCII Art → Mermaid conversion
    
    Scan all code blocks (` ``` ` without language tag) and classify:
    
    | Pattern | Classification | Action |
    |---------|---------------|--------|
    | Arrows (`→ ► ▼ ──►`) + boxes (`┌ ┐ └ ┘`) | Flowchart / architecture | Convert to Mermaid |
    | `├──` `└──` + file paths | Directory tree | Keep as-is |
    | Already ` ```mermaid ` | Mermaid | Keep as-is |
    | Simple one-liner `A → B → C` | Ambiguous | Keep as-is |
    | Anything uncertain | Unknown | Keep as-is |
    
    When converting to Mermaid:
    - Write the direction that reads best at the source (usually `LR` for linear flows). **Do not hand-tune direction for the PDF** — Step 6 measures both orientations and picks the legible one.
    - Keep node text short (< 20 chars per line)
    - Use `<br/>` for line breaks (never `\n`)
    - Avoid markdown-triggering syntax in nodes: no `1.` prefix, no `*`, no `[]()`
    - Replace full-width brackets `()` with half-width or remove
    - Replace `≥ ≤` with `>=` `<=`
    
    ### Step 5: Markdown sanitization for pandoc
    
    **5a. Mermaid syntax cleanup** — for ALL mermaid blocks (both converted and pre-existing):
    - `\n` → `<br/>`
    - Remove numbered prefixes in node text (`1. `, `2. ` etc.)
    - Simplify special characters that may cause parsing errors
    - **Leave the flow direction alone** — Step 6 chooses it by measurement
    
    **5b. Dollar sign escaping** — pandoc interprets `$...$` as LaTeX inline math. In markdown table cells, an unescaped `$` (e.g. `NT$1`) will pair with a later `$` (e.g. `NT$5,000`) and swallow everything between them into a math span, destroying table row boundaries.
    - Escape ALL `$` signs outside of code blocks: `$` → `\$`
    - This applies to currency symbols (`NT$`, `US$`, `€` is fine), variable references (`$HOME`), and any other bare `$`
    - `$` inside code blocks (`` ` `` or ` ``` `) are safe — pandoc doesn't process them
    
    **5c. Table column widths** — the single biggest lever on how the PDF *looks*.
    
    With the usual `|---|---|---|`, every column renders equal width, so a one-character `#` column gets as much room as a column holding three sentences. On a table-heavy document this wastes a large fraction of every page and reads as amateurish.
    
    **The fix needs two halves, and neither works alone:**
    
    | Half | Where | What it does |
    |------|-------|--------------|
    | Dash proportions in the separator row | Markdown: `\|---\|------------\|` | pandoc's **default** markdown reader emits `<col style="width:N%">` from the ratio |
    | `table-layout: fixed` | CSS | Makes the browser/weasyprint actually honor those widths instead of auto-sizing |
    
    ⚠️ **pandoc's `gfm` reader ignores separator proportions entirely.** If the build passes `-f gfm`, drop that flag — otherwise this step is a no-op. Verify with:
    
    ```bash
    pandoc -t html file.md | grep -o 'width: [0-9]*%'   # should print one % per column
    ```
    
    **Do not hand-tune 20 tables.** Run the bundled script, which derives each column's share from its actual content:
    
    ```bash
    python3 scripts/table_widths.py "{filename}_pdf.md"
    ```
    
    It handles two things that are easy to get wrong:
    
    - **CJK width** — CJK characters occupy two cells, so weights are measured in display width, not `len()`.
    - **Unbreakable-token floor** — `word-wrap: break-word` will split a Latin word mid-token when its column is too narrow (`PostgreSQL` → `PostgreS / QL`). Each column gets a floor wide enough for its longest token. The default factor is tuned for a CJK sans-serif at ~9.5pt; if you still see mid-word breaks, raise `TOKEN_FACTOR` in the script and re-run.
    
    Re-run the script after any table edit — it is idempotent, and ratios are recomputed from cell contents rather than from the previous separator row.
    
    **Observed effect**: on a 40-page table-heavy report, rebalancing widths brought it to 34 pages with no content removed.
    
    ### Step 6: Generate PDF
    
    Use the bundled builder. It carries the CSS, the diagram pipeline, the version
    footer and the page-break rules, so none of that has to be reassembled per run:
    
    ```bash
    scripts/build_pdf.sh "{filename}_pdf.md" "{filename}.pdf" \
      --style professional \
      --no-toc                 # or --toc, per Step 2b
    ```
    
    Options: `--style professional|technical|minimal`, `--toc` / `--no-toc`,
    `--toc-depth N`, `--mermaid vertical|keep`.
    
    **Diagram orientation is measured, not guessed.** A horizontal chain that reads
    fine on screen is scaled to roughly a quarter inside A4's ~17cm text column and
    its labels stop being readable. But flipping blindly is also wrong: a vertical
    version can be tall enough that the height cap shrinks it right back. So the
    builder renders each `LR` diagram both ways, computes the scale the page will
    actually apply — `min(width_fit, height_fit)` — and keeps whichever orientation
    holds the larger one. Vertical costs page height, which is the cheaper resource.
    
    Measured on a six-diagram report: LR chains came out at 4.4–5.0 : 1 and were
    unreadable; going vertical grew the document from 7 to 11 pages and was still
    clearly the right trade. Pass `--mermaid keep` to opt out.
    
    What the builder handles that a bare pandoc call does not:
    
    | Behaviour | Why it matters |
    |-----------|----------------|
    | `> Version:` / `> 版本:` line → bottom-right on every page, removed from body | Otherwise nobody can tell which draft they are holding |
    | TOC off by default; title inlined when off | A named CSS page forces a break, so a cover sheet appears even with no TOC unless the title's `page:` rule is also dropped |
    | Diagrams rendered to PNG, empty alt text | SVG mis-renders fonts in weasyprint; a non-empty alt becomes a visible figure caption under every diagram |
    | `table-layout: fixed` + `thead` repeat + `tr` unbreakable | Step 5c's ratios are a no-op without `fixed`; tables taller than a page must split between rows, not jump whole to the next page |
    | Ink self-check including the **last** page | See Step 7 |
    
    ⚠️ **Never add `"Apple Color Emoji"` to the CSS `font-family`, at any position.**
    It carries keycap glyphs for 0–9, and weasyprint routes every Arabic numeral in
    the document to it — they print as blank space. The text layer still contains the
    digits, so `pdftotext` looks correct and the damage is invisible until someone
    reads the paper. For coloured status markers, embed a PNG.
    
    If you need something the builder doesn't cover, fall back to assembling pandoc
    by hand — but copy the CSS out of the script rather than rewriting it, or you
    will rediscover the traps above one at a time.
    
    ### Step 7: Self-check
    
    Read every page of the generated PDF. Check for:
    
    | Issue | Detection | Fix |
    |-------|-----------|-----|
    | Nearly blank page | Page has < 10% content | Diagram too tall → switch to LR layout or reduce nodes |
    | "Unsupported markdown" text | Literal string match | Node text has list syntax → remove numbered prefixes |
    | `?` boxes in text | Character replacement indicators | Font fallback issue → check CSS font-family |
    | Table rows merged into one cell | Single row contains `\|\|` or content from multiple expected rows | Unescaped `$` triggering LaTeX math mode → escape all `$` outside code blocks |
    | All table columns equal width | A one-character column as wide as a prose column | Step 5c not applied, or the build passes `-f gfm`, or CSS lacks `table-layout: fixed` |
    | A Latin word split mid-token | `PostgreS / QL` across two lines | Column narrower than its longest token → raise `TOKEN_FACTOR` in `table_widths.py` and re-run |
    | Mostly-blank page before a big table | Page under ~4% ink, next page starts with that table | Table set to `page-break-inside: avoid` but taller than one page → allow it to split, keep `tr` unbreakable, repeat `thead` |
    
    Reading 40 pages by eye is slow and misses things. `build_pdf.sh` runs this check
    automatically; the logic, if you are assembling by hand:
    
    ```bash
    pdftoppm -png -r 80 out.pdf /tmp/pg
    python3 - <<'PY'
    import glob
    from PIL import Image
    pages = sorted(glob.glob("/tmp/pg-*.png"))
    for i, p in enumerate(pages):
        im = Image.open(p).convert("L")
        ink = sum(im.histogram()[:240]) / (im.size[0] * im.size[1]) * 100
        last = i == len(pages) - 1
        if (ink < 1.5 if last else ink < 4):
            print(f"{p}: {ink:.2f}% — {'trailing orphan' if last else 'inspect this page'}")
    PY
    ```
    
    Under ~4% on a middle page almost always means a table or diagram was pushed off it.
    
    ⚠️ **Do not exempt the final page from the check.** It is tempting — a tail page is
    legitimately sparse — but blanket-skipping it is exactly how a document ships with
    one stranded line on its own sheet. A genuine tail page still carries a paragraph
    or two; under ~1.5% ink means one or two lines, which is an orphan, not a tail.
    
    Fixing an orphan is not "delete a sentence until it fits". The reliable move is to
    **fold the closing line into the block above it** — into the last table row, or
    into the preceding paragraph. Trimming prose is unreliable: shortening a line that
    still wraps to the same number of rendered lines frees nothing, and the boundary
    does not move. Measure instead of guessing: if the free space at the bottom of the
    previous page is smaller than one line height plus the paragraph's top margin, no
    amount of rewording that keeps the paragraph separate will pull it up.
    
    If issues found: fix `_pdf.md`, regenerate. **Maximum 3 retries**, then stop and report remaining issues to user.
    
    ### Step 8: Cleanup — only once the user has signed off
    
    Cleanup is **not** part of every render. Iteration usually takes several rounds, and re-running is far cheaper when the working file and the CSS are still on disk.
    
    **While iterating** — keep everything: `{filename}_pdf.md`, `mermaid-filter.lua`, `style.css`, and any generated diagram PNGs.
    
    **Once the user confirms the version is final** — delete the whole intermediate set without asking again:
    
    ```bash
    rm -f "{filename}_pdf.md" mermaid-filter.lua style.css
    # plus any diagram PNGs generated during the run
    ```
    
    What survives: the original `.md` and the finished `.pdf`. Nothing else.
    
    The confirmation to wait for is an explicit "this version is good / final / ship it" — not merely the absence of complaints about the last render.
    
    ⚠️ **Before deleting, check whether the user asked to keep an earlier PDF.** If they did, make sure the earlier file still exists under its own name; never let a later render silently overwrite a version the user asked to preserve. Version filenames (`_v1`, `_v2`) are cheaper than regenerating a version you can no longer reproduce.
    
    ### Step 9: Report
    
    Output:
    - PDF file path
    - Page count
    - Any known remaining issues (if retry limit was hit)
    
    ## Anti-patterns
    
    - ❌ **Editing the original `.md`** — all changes happen on the `_pdf.md` copy; the source is never touched
    - ❌ **SVG for Mermaid** — weasyprint mis-renders SVG fonts; always render diagrams to PNG
    - ❌ **Leaving `$` unescaped outside code blocks** — pandoc pairs them into LaTeX math spans and eats table rows; escape every bare `$`
    - ❌ **Batch-converting in one call** — one file per invocation; loop by re-invoking, don't glob
    - ❌ **Retrying forever on a broken page** — cap at 3 regenerations, then stop and report the remaining issue instead of silently shipping a bad page
    - ❌ **Leaving `|---|---|---|` on every table** — equal widths make a `#` column as wide as a prose column and waste a large share of each page; run `scripts/table_widths.py`
    - ❌ **Setting column ratios but forgetting `table-layout: fixed`** (or vice versa) — the two halves only work together; one alone is a silent no-op
    - ❌ **Reading `-f gfm` output and wondering why widths are ignored** — the gfm reader drops separator proportions; use pandoc's default markdown reader
    - ❌ **Hand-picking diagram direction for the PDF** — `LR` looks right in the source and prints too small; blind flipping to `TD` can be worse. Let the builder render both and measure
    - ❌ **Adding `"Apple Color Emoji"` to the font stack** — every digit in the document silently prints blank while `pdftotext` still shows them
    - ❌ **Non-empty alt text on rendered diagrams** — pandoc promotes it to a figure caption, printing the alt word under every diagram
    - ❌ **Turning the TOC on by default** — it drags the title onto a cover sheet; two pages of overhead in front of a short memo. Ask
    - ❌ **Exempting the last page from the ink check** — that is how a one-line orphan page ships
    - ❌ **Trimming prose to kill an orphan page** — shortening text that still wraps to the same line count frees nothing; fold the line into the block above instead
    - ❌ **Cleaning up before the user signs off** — deleting `_pdf.md` and the CSS mid-iteration means rebuilding all manual tuning on the next round
    - ❌ **Overwriting a PDF the user asked to keep** — give the new render a version suffix instead
    - ❌ **Leaving temp files behind after sign-off** — once the version is final, remove `_pdf.md`, `mermaid-filter.lua`, `style.css` and diagram PNGs without asking again
    
    ## Important Rules
    
    - **NEVER modify the original markdown file**
    - **Always use PNG for Mermaid rendering** (not SVG)
    - **Always disable syntax highlighting** (`--no-highlight`)
    - **Always include CJK font fallback** in CSS
    - **Fixed A4 page size** — no other options
    - **One file at a time** — user can invoke multiple times for batch
    - **Table widths need markdown ratios AND `table-layout: fixed`** — neither half works alone
    - **Ask before adding a TOC** — it costs a cover sheet plus a contents page
    - **Lift the version line into a per-page footer** — a third party must be able to tell which draft they hold
    - **Let the builder measure diagram orientation** — never hand-tune `LR`/`TD` for print
    - **Check the last page too** — under ~1.5% ink is an orphan, not a tail
    - **Clean up only after the user calls the version final** — then delete the intermediates outright, no second confirmation
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related