Claude Skill

lov-any2pdf

Convert Markdown to polished PDF with CJK typography, code, tables, images, covers, TOC, bookmarks, and reading themes. Trigger on md2pdf, Chinese report typesetting, or print-ready PDF requests.

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

Full trust report

Download lovstudio-skills-skills_any2pdf-77d464c.zip · 3554 KB
Part of lovstudio/skills — 83 skills

Install

skills CLI npx skills add https://github.com/lovstudio/skills/tree/main/skills/any2pdf
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lovstudio-skills@llmmart
Git git clone https://github.com/lovstudio/skills.git

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

README

PDF大师 · PDF Master

Version

Markdown to professionally typeset PDF with reportlab. CJK/Latin mixed text, code blocks, tables, images, Obsidian callouts, emoji fallback, formulas, cover pages, TOC, bookmarks, watermarks, and 16 themes.

Part of skill-publisher/skills — by example.com

Install

npx skills add any2pdf -g -y

Requires: Python 3.8+ and pip install reportlab

Optional:

pip install matplotlib
sudo apt install fonts-dejavu-core fonts-liberation fonts-freefont-ttf fonts-noto fonts-noto-cjk fonts-noto-color-emoji

Usage

python md2pdf.py --input report.md --output report.pdf --theme warm-academic

For long Chinese reports and dense tables, use the publication-style reading preset:

python md2pdf.py --input report.md --output report.pdf --theme songti-reading

songti-reading pairs Songti body text with sans-serif headings/emphasis, Palatino Latin prose, Menlo code, roomier leading, symbol fallback, and a fit-width opening view.

You can also keep options in top-of-file frontmatter:

---
title: My Report
author: Author Name
theme: warm-academic
watermark: DRAFT
---
Option Default Description
--input (required) Markdown file path
--output output.pdf Output PDF path
--title From H1 Cover page title
--subtitle Subtitle
--author Author name
--theme warm-academic Color theme
--watermark Watermark text
--cover true Generate cover page
--toc true Generate table of contents
--frontispiece Full-page image after cover
--code-max-lines 30 Max lines per code block
--image-cover false Use frontispiece image as full-bleed cover

Themes

songti-reading is the recommended preset when sustained Chinese reading matters more than compact page count.

Reading and print Technical and modern Specialized
songti-reading github-light consulting-navy
warm-academic nord-frost ieee-journal
paper-classic ocean-breeze elegant-book
classic-thesis solarized-light chinese-red
tufte monokai-warm / dracula-soft ink-wash

License

MIT

Skill manifest

PDF大师 · PDF Master

This skill converts any Markdown file into a publication-quality PDF using Python's reportlab library. It was developed through extensive iteration on real Chinese technical reports and solves several hard problems that naive MD→PDF converters get wrong.

Triggers

Activate when

  • User wants to convert .md → .pdf
  • User has a markdown report/document and wants professional typesetting
  • Document contains CJK characters (Chinese/Japanese/Korean) mixed with Latin text
  • Document has fenced code blocks, markdown tables, or nested lists
  • Document has local/remote images, Obsidian callouts, emoji, or math formulas
  • User wants a cover page, table of contents, or watermark in their PDF

Do not activate when

  • The user only wants to revise Markdown content and does not need a PDF output.
  • The source is HTML, DOCX, or another format and the user has not authorized a Markdown conversion step.
  • The user needs a slide deck, editable Word document, or image export rather than a PDF.

Quick Start

python md2pdf/scripts/md2pdf.py \
  --input report.md \
  --output report.pdf \
  --title "My Report" \
  --author "Author Name" \
  --theme warm-academic

All parameters except --input are optional — sensible defaults are applied.

Pre-Conversion Options (MANDATORY)

IMPORTANT: You MUST use the AskUserQuestion tool to ask these questions BEFORE running the conversion. Do NOT list options as plain text — use the tool so the user gets a proper interactive prompt. Ask all options in a SINGLE AskUserQuestion call.

Use AskUserQuestion with the following template. The tone should be friendly and concise — like a design assistant, not a config form:

开始转 PDF!先帮你确认几个选项 👇

━━━ 📐 设计风格 ━━━
 a) 暖学术    — 陶土色调,温润典雅,适合人文/社科报告
 b) 经典论文  — 棕色调,灵感源自 LaTeX classicthesis,适合学术论文
 c) Tufte     — 极简留白,深红点缀,适合数据叙事/技术写作
 d) 期刊蓝    — 藏蓝严谨,灵感源自 IEEE,适合正式发表风格
 e) 精装书    — 咖啡色调,书卷气,适合长篇专著/技术书
 f) 中国红    — 朱红配暖纸,适合中文正式报告/白皮书
 g) 水墨      — 纯灰黑,素雅克制,适合文学/设计类内容
 h) GitHub    — 蓝白极简,程序员熟悉的风格
 i) Nord 冰霜 — 蓝灰北欧风,清爽现代
 j) 海洋      — 青绿色调,清新自然
 k) LaTeX 清爽 — pandoc+XeLaTeX 原生排版,无封面无装饰,干净学术风(需装 pandoc+texlive)
 l) 咨询深蓝  — 深海军蓝色块 + 白底 + 大写左对齐标题,麦肯锡 / BCG / Deloitte 研究报告风格
 m) 宋黑阅读  — 宋体正文、黑体层级、舒展行距与适宽打开,适合中文长报告和密集表格

━━━ 🖼 扉页图片(封面之后的全页插图) ━━━
 1) 跳过
 2) 我提供本地图片路径
 3) AI 根据内容自动生成一张

━━━ 💧 水印 ━━━
 1) 不加
 2) 自定义文字(如 "DRAFT"、"内部资料")
    可选调整:字号(默认52)、透明度(0-1)、角度(默认35°)、间距

━━━ 📇 封底物料(名片/二维码/品牌) ━━━
 1) 跳过
 2) 我提供图片
 3) 纯文字信息

示例回复:"a, 扉页跳过, 水印:仅供学习参考, 封底图片:/path/qr.png"
直接说人话就行,不用记编号 😄

Mapping User Choices to CLI Args

Choice CLI arg
Design style a-m --theme with value from table below (k uses pandoc engine)
Frontispiece local --frontispiece <path>
Frontispiece AI Generate image first, then --frontispiece /tmp/frontispiece.png
Watermark text --watermark "文字"
Watermark style --wm-size 30 --wm-opacity 0.1 --wm-angle 45 (all optional)
Image as cover --image-cover true (requires --frontispiece)
Back cover image --banner <path>
Back cover text --disclaimer "声明" and/or --copyright "© 信息"

Theme Name Mapping

Choice --theme value Inspiration
a) 暖学术 warm-academic Skill Publisher design system
b) 经典论文 classic-thesis LaTeX classicthesis
c) Tufte tufte Edward Tufte's books
d) 期刊蓝 ieee-journal IEEE journal format
e) 精装书 elegant-book LaTeX ElegantBook
f) 中国红 chinese-red Chinese formal documents
g) 水墨 ink-wash 水墨画 / ink wash painting
h) GitHub github-light GitHub Markdown style
i) Nord nord-frost Nord color scheme
j) 海洋 ocean-breeze —
k) LaTeX 清爽 latex-clean pandoc+XeLaTeX 原生排版,无封面
l) 咨询深蓝 consulting-navy McKinsey / BCG / Deloitte deep-research report
m) 宋黑阅读 songti-reading 中文出版物常见的宋体正文 + 黑体标题层级

Handling AI-Generated Frontispiece

If user chose AI generation: read the document title + first paragraphs, use an image generation tool to create a themed illustration matching the chosen design style, show for approval, then pass via --frontispiece /path/to/image.png

Architecture

Markdown → Preprocess (split merged headings) → Parse (code-fence-aware) → Story (reportlab flowables) → PDF build

Key components:

  1. Font system: Palatino (Latin body), Songti SC Regular (CJK body), selectable serif/sans CJK emphasis, Menlo (code) on macOS; auto-fallback on Linux/Windows
  2. CJK wrapper: _font_wrap() wraps CJK character runs in <font> tags for automatic font switching
  3. Mixed text renderer: _draw_mixed() handles CJK/Latin mixed text on canvas (cover, headers, footers)
  4. Code block handler: esc_code() preserves indentation, mid-line alignment, and line breaks in reportlab Paragraphs
  5. Smart table widths: Proportional column widths based on content length, with 18mm minimum
  6. Bookmark system: ChapterMark flowable creates PDF sidebar bookmarks + named anchors
  7. Heading preprocessor: _preprocess_md() splits merged headings like # Part## Chapter into separate lines
  8. Image handler: local, relative, file://, and remote markdown images are scaled into the body frame with fallback text on errors
  9. Callout renderer: Obsidian-style > [!NOTE] blocks render as themed boxed callouts
  10. Formula renderer: display formulas use optional matplotlib mathtext images, with styled text fallback
  11. Emoji fallback: emoji are rendered as cached Twemoji PNGs when available, or with a local emoji font fallback

Hard-Won Lessons

CJK Characters Rendering as □

reportlab's Paragraph only uses the font in ParagraphStyle. If fontName="Mono" but text contains Chinese, they render as □. Fix: Always apply _font_wrap() to ALL text that might contain CJK, including code blocks.

Code Blocks Losing Line Breaks

reportlab treats \n as whitespace. Fix: esc_code() converts \n → <br/> and all spaces → &nbsp;, preserving indentation and mid-line alignment before _font_wrap().

CJK/Latin Word Wrapping

Default reportlab breaks lines only at spaces, causing ugly splits like "Claude\nCode". Fix: Set wordWrap='CJK' on body/bullet styles to allow breaks at CJK character boundaries.

Songti Reading Needs a Sans Hierarchy

Using one sans family everywhere gives dense Chinese reports a uniform dark texture, while using Songti everywhere weakens headings and table headers. Fix: use the songti-reading theme for Songti SC Regular body text, Palatino Latin prose, sans-serif Chinese headings/emphasis, Menlo code, wider leading, and serif table bodies. Keep this as an explicit theme rather than silently changing every report.

On macOS, Songti.ttc index 0 is Songti SC Black, not Regular. The readable face is index 6. Common report symbols such as ✓, ✗, and → need an explicit symbol-font fallback or they may render as □ even when the surrounding Chinese is correct.

Canvas Text with CJK (Cover/Footer)

drawString() / drawCentredString() with a Latin font can't render 年/月/日 etc. Fix: Use _draw_mixed() for ALL user-content canvas text (dates, stats, disclaimers).

Images Silently Dropped (Relative Paths)

An image reference to charts/chart_01.png in a markdown file used to get skipped without warning because the image path was resolved against the current working directory, not the markdown's directory. Fix: main() now passes input_dir (the .md's directory) into the builder, and the image handler resolves relative paths against it. Missing images now also emit a WARN: image not found: ... to stderr instead of silently dropping.

Multi-Line Image References (pandoc --wrap=auto)

When feeding pandoc's output into md2pdf, pandoc's default --wrap=auto (72 cols) wraps long image references to paths such as path.png across multiple lines, which defeated the single-line image regex. Fix: _preprocess_md() now collapses multi-line image references into one line (outside code fences) before parsing.

Pipeline tip: If you're piping HTML → markdown via pandoc, use pandoc --wrap=none input.html -o output.md to avoid wrap-related parsing issues for images and tables alike.

Input Format

This skill takes Markdown files only as input. If you have HTML, DOCX, or other formats, convert them to markdown first (e.g. pandoc --wrap=none). Embedded HTML blocks in markdown are passed through as text — pre-process any visual content (charts, complex tables) into plain markdown tables or image references before invoking md2pdf.

Configuration Reference

Most options can also be set in top-of-file YAML-style frontmatter. Explicit CLI arguments take precedence over frontmatter values.

Argument Frontmatter Key Default Description
--input — (required) Path to markdown file
--output — output.pdf Output PDF path
--title title From first H1 Document title for cover page
--subtitle subtitle "" Subtitle text
--author author "" Author name
--date date Today Date string
--version version "" Version string for cover
--watermark watermark "" Watermark text (empty = none)
--theme theme warm-academic Color theme name
--theme-file — "" Custom theme JSON file path
--cover cover true Generate cover page
--toc toc true Generate table of contents
--page-size page-size A4 Page size (A4 or Letter)
--frontispiece frontispiece "" Full-page image after cover
--banner banner bundled assets/backcover-banner.jpg Back cover banner image (pass none to disable)
--header-title header-title "" Report title in page header
--footer-left footer-left author Brand/author in footer
--stats-line stats-line "" Stats on cover
--stats-line2 stats-line2 "" Second stats line
--edition-line edition-line "" Edition line at cover bottom
--disclaimer disclaimer "" Back cover disclaimer
--copyright copyright "" Back cover copyright
--code-max-lines code-max-lines 30 Max lines per code block
--image-cover image-cover false Use frontispiece as full-bleed cover (page 1), text cover becomes page 2
--heading-top-spacer heading-top-spacer 5 Top spacer before H1/H2 chapter titles in mm
--wm-size wm-size auto (half page width) Watermark font size; auto-scales so text width ≈ 50% of page width
--wm-opacity wm-opacity 0.1 Watermark opacity (0.0–1.0)
--wm-angle wm-angle 35 Watermark rotation angle in degrees
--wm-spacing-x wm-spacing-x 9999 Watermark horizontal spacing in pt (≥2000 = single centered per page)
--wm-spacing-y wm-spacing-y 9999 Watermark vertical spacing in pt (≥2000 = single centered per page)

Themes

Available: songti-reading, warm-academic, nord-frost, github-light, solarized-light, paper-classic, ocean-breeze, consulting-navy.

Each theme defines: page background, ink color, accent color, faded text, border, code background, watermark tint.

Fallback: pandoc + XeLaTeX

When reportlab fails (e.g. wide/complex tables cause infinite loops in table width calculation), fall back to pandoc with XeLaTeX. This produces high-quality CJK PDFs with proper table handling.

When to use pandoc engine

  • User chose latex-clean theme (k) — pandoc is the primary engine, not a fallback
  • Document has many wide multi-column tables (reportlab's table layout may hang)
  • Document needs LaTeX-quality typesetting (justified text, hyphenation)
  • reportlab md2pdf.py hangs or crashes on the input

Basic command

pandoc input.md -o output.pdf \
  --pdf-engine=xelatex \
  -V CJKmainfont="Songti SC" -V mainfont="Palatino" -V monofont="Menlo" \
  -V geometry:margin=2.5cm -V fontsize=11pt \
  --toc -V toc-title="目录" -V documentclass=article

Adding watermark + headers/footers

pandoc input.md -o output.pdf \
  --pdf-engine=xelatex \
  -V CJKmainfont="Songti SC" -V mainfont="Palatino" -V monofont="Menlo" \
  -V geometry:margin=2.5cm -V fontsize=11pt \
  -V colorlinks=true -V linkcolor=red -V toccolor=red -V urlcolor=red \
  --toc -V toc-title="目录" -V documentclass=article \
  -V header-includes='
\usepackage{fancyhdr}
\pagestyle{fancy}
\fancyhf{}
\fancyhead[L]{\small 页眉左侧文字}
\fancyhead[R]{\small 页眉右侧文字}
\fancyfoot[C]{\thepage}
\usepackage{draftwatermark}
\SetWatermarkText{水印文字}
\SetWatermarkScale{0.5}
\SetWatermarkColor[gray]{0.9}
'

Pandoc theme presets

Each preset defines a complete set of pandoc -V flags. Use the full command from "Adding watermark + headers/footers" above, replacing the color/link flags per preset.

Theme linkcolor toccolor urlcolor Watermark color Notes
chinese-red red red red [gray]{0.9} 朱红正式,适合政企报告、白皮书
warm-academic brown brown brown [gray]{0.9} 陶土色调,温润学术风
classic-thesis brown brown brown [gray]{0.85} LaTeX classicthesis 灵感
ieee-journal blue blue blue [gray]{0.9} 藏蓝严谨,期刊风格
github-light blue blue blue [gray]{0.92} 极简蓝白,程序员友好
ink-wash black black black [gray]{0.92} 水墨素雅,文学/设计类
nord-frost teal teal teal [gray]{0.9} 北欧冰霜蓝灰
consulting-navy blue blue blue [gray]{0.88} 咨询深蓝,研究报告风

chinese-red 完整示例(本次一滕项目实际使用)

pandoc input.md -o output.pdf \
  --pdf-engine=xelatex \
  -V CJKmainfont="Songti SC" -V mainfont="Palatino" -V monofont="Menlo" \
  -V geometry:margin=2.5cm -V fontsize=11pt \
  -V colorlinks=true -V linkcolor=red -V toccolor=red -V urlcolor=red \
  --toc -V toc-title="目录" -V documentclass=article \
  -V header-includes='
\usepackage{fancyhdr}
\pagestyle{fancy}
\fancyhf{}
\fancyhead[L]{\small 报告标题 | 品牌名}
\fancyhead[R]{\small 商业机密}
\fancyfoot[C]{\thepage}
\usepackage{draftwatermark}
\SetWatermarkText{商业机密}
\SetWatermarkScale{0.5}
\SetWatermarkColor[gray]{0.9}
'

latex-clean 完整示例(最简洁的 LaTeX 学术风格)

选择 latex-clean 时,跳过 reportlab,直接使用 pandoc 生成。特点:无封面、无扉页、 无装饰色块,纯 LaTeX 排版 + 点线 TOC 引导符,适合内容为王的学术/技术文档。

pandoc input.md -o output.pdf \
  --pdf-engine=xelatex \
  -V CJKmainfont="Songti SC" -V mainfont="Palatino" -V monofont="Menlo" \
  -V geometry:margin=2.5cm -V fontsize=11pt \
  -V colorlinks=true -V linkcolor=brown -V toccolor=brown -V urlcolor=brown \
  --toc -V toc-title="目录" -V documentclass=article

如需添加页眉页脚或水印,参考上方 "Adding watermark + headers/footers" 追加 -V header-includes。

Known limitations (pandoc fallback)

  • No cover page (pandoc article class has no built-in cover — use --include-before-body with a LaTeX snippet if needed)
  • Frontispiece/back cover not supported (use md2pdf.py for these)
  • → ★ ☆ symbols may warn in Palatino — they render via CJK font fallback, safe to ignore
  • ASCII art diagrams render as code blocks (same as md2pdf.py)

Dependencies (pandoc fallback)

Requires pandoc and a TeX distribution with XeLaTeX:

brew install pandoc
brew install --cask mactex-no-gui   # or basictex

Dependencies

pip install reportlab --break-system-packages
# Optional formula rendering:
pip install matplotlib --break-system-packages

Recommended Ubuntu/Debian fonts:

sudo apt install fonts-dejavu-core fonts-liberation fonts-freefont-ttf fonts-noto fonts-noto-cjk fonts-noto-color-emoji

Runtime context (shared)

运行前读取本 Skill 包的 skill.yaml,由宿主提供 skill-runtime/v1 上下文。字段解析顺序为:当前请求、项目上下文、个人 Preferences、品牌 Profile、通用默认值。

  • 只使用 Manifest 声明的字段;Profile 保存公开品牌事实,Preferences 保存个人工作偏好。
  • required: true 字段缺失时,按 Manifest 的问题配置向用户提出一个聚焦问题;用户明确同意后再保存回答。
  • 报错提供可复制的 context_id、字段路径与来源,诊断内容避开秘密、完整私人路径和原始配置。

通用反馈闭环

用户在 Skill 驱动任务中提出修改意见时,继续当前产物前必须执行:

  1. 先判断意见是 task-specific(仅本次)还是 reusable(可跨任务复用)。
  2. task-specific 只修改当前任务,不改 Skill。
  3. reusable 先确定作用域:领域规则先更新对应 canonical Skill;适用于所有 Skill 的规则先更新共享规范。
  4. 完成规则更新、版本、lint 与分发核验后,再把修改应用到当前任务。
  5. reusable 修改会使此前的“确认”“继续”“发吧”失效;完成当前产物修改和回读后必须停下,等待用户下一步指示,不自动进入发布、提交或其他外部写入。
Files (skills)
  • assets
    • backcover-banner.jpg 3.4 MB · in bundle
  • cases
    • cases.json 1 KB
      [
        {
          "title": "季度技术复盘报告",
          "description": "将团队技术复盘的 Markdown 笔记转为 warm-academic 主题 PDF,含封面、目录、水印,直接发给领导。",
          "testimonial": "以前用 Word 排版一份报告要半天,现在一条命令 30 秒搞定,而且比 Word 好看太多了。",
          "author": "赵工 · 技术主管"
        },
        {
          "title": "开源项目文档 PDF 化",
          "description": "把 GitHub 上的 README + 设计文档合并转为 ieee-journal 风格 PDF,用于学术引用和归档。",
          "testimonial": "CJK 混排终于不用操心了,中英文、代码块、表格全部自动处理,排版效果堪比 LaTeX。",
          "author": "Mike · 开源作者"
        },
        {
          "title": "客户方案 PDF 交付",
          "description": "用 chinese-red 主题将商务方案 Markdown 转为正式 PDF,带封面和公司水印,直接交付客户。",
          "testimonial": "客户拿到 PDF 后以为我们有专门的设计师排版,其实就是一条命令。",
          "author": "林总 · 咨询公司"
        }
      ]
      
    • issue-regressions.md 942 B
      ---
      title: Issue Regression Coverage
      subtitle: H4-H6, callouts, images, math, emoji, frontmatter
      author: any2pdf
      theme: github-light
      toc: false
      code-max-lines: 50
      ---
      
      # Issue Regression Coverage
      
      This H1 body paragraph must remain in the PDF even when the cover already uses the same title. Inline math $E = mc^2$ and emoji ✅ should render.
      
      #### H4 heading should not loop
      
      Content after H4 must render.
      
      ##### H5 heading should not loop
      
      Content after H5 must render.
      
      ###### H6 heading should not loop
      
      Content after H6 must render.
      
      > [!NOTE] Obsidian callout
      > Callout body supports **bold text**, inline code `value`, and CJK 混排.
      > - A nested list item stays inside the callout block.
      
      ![Bundled banner](../assets/backcover-banner.jpg)
      
      The paragraph after the image should still be present.
      
      ```text
      left      middle      right
      1         20          300
      ```
      
      $$
      \frac{a^2 + b^2}{c^2} = 1
      $$
      
      Final paragraph after display math.
      
  • references
    • themes.md 4.2 KB
      # Theme Reference
      
      Each theme is a Python dict with these keys. All values are hex color strings.
      
      ## Theme Structure
      
      ```python
      THEME = {
          "canvas":      "#F9F9F7",  # page background
          "canvas_sec":  "#F0EEE6",  # secondary bg (code blocks, alternating rows)
          "ink":         "#181818",  # primary text
          "ink_faded":   "#87867F",  # secondary text (subtitles, captions)
          "accent":      "#4F46E5",  # primary accent (headings, rules, table headers)
          "accent_light":"#D99A82",  # lighter accent
          "border":      "#E8E6DC",  # borders, horizontal rules
          "code_bg":     "#F0EEE6",  # code block background (usually = canvas_sec)
          "watermark":   (0.82, 0.80, 0.76, 0.12),  # RGBA tuple for watermark
      }
      ```
      
      ## Available Themes
      
      ### songti-reading
      Warm-white canvas with muted ink blue. Uses Songti SC Regular for Chinese body text,
      Palatino for Latin prose, sans-serif Chinese for headings/emphasis, and Menlo for code.
      The wider leading, wider minimum table columns, continuous headings, and fit-width
      opening view are optimized for long Chinese reports and dense audit tables.
      ```python
      {"canvas":"#FDFCF9","canvas_sec":"#F5F6F3","ink":"#232B36","ink_faded":"#66707C",
       "accent":"#315A78","accent_light":"#7891A6","border":"#E0E4E5"}
      ```
      
      ### warm-academic (default)
      Warm ivory canvas with terracotta accents. Inspired by academic papers on aged paper.
      Best for: Chinese technical reports, research papers, documentation.
      ```python
      {"canvas":"#F9F9F7","canvas_sec":"#F0EEE6","ink":"#181818","ink_faded":"#87867F",
       "accent":"#4F46E5","accent_light":"#D99A82","border":"#E8E6DC"}
      ```
      
      ### nord-frost
      Cool blue-gray palette from the Nord color scheme.
      Best for: Developer documentation, technical specs.
      ```python
      {"canvas":"#ECEFF4","canvas_sec":"#E5E9F0","ink":"#2E3440","ink_faded":"#4C566A",
       "accent":"#5E81AC","accent_light":"#81A1C1","border":"#D8DEE9"}
      ```
      
      ### github-light
      Clean white with blue accents, matching GitHub's documentation style.
      Best for: Open-source project docs, README-style reports.
      ```python
      {"canvas":"#FFFFFF","canvas_sec":"#F6F8FA","ink":"#1F2328","ink_faded":"#656D76",
       "accent":"#0969DA","accent_light":"#218BFF","border":"#D0D7DE"}
      ```
      
      ### monokai-warm
      Dark background with warm accent colors.
      Best for: Code-heavy reports, developer presentations.
      ```python
      {"canvas":"#272822","canvas_sec":"#1E1F1C","ink":"#F8F8F2","ink_faded":"#75715E",
       "accent":"#F92672","accent_light":"#FD971F","border":"#49483E"}
      ```
      
      ### solarized-light
      Ethan Schoonover's Solarized Light palette.
      Best for: Long-form reading, academic papers.
      ```python
      {"canvas":"#FDF6E3","canvas_sec":"#EEE8D5","ink":"#657B83","ink_faded":"#93A1A1",
       "accent":"#CB4B16","accent_light":"#DC322F","border":"#EEE8D5"}
      ```
      
      ### dracula-soft
      Softened Dracula theme with muted dark purple background.
      Best for: Evening reading, dark-mode preference reports.
      ```python
      {"canvas":"#282A36","canvas_sec":"#21222C","ink":"#F8F8F2","ink_faded":"#6272A4",
       "accent":"#BD93F9","accent_light":"#FF79C6","border":"#44475A"}
      ```
      
      ### paper-classic
      Pure white with black text and minimal red accents. Traditional print look.
      Best for: Formal documents, print-ready reports, submissions.
      ```python
      {"canvas":"#FFFFFF","canvas_sec":"#FAFAFA","ink":"#000000","ink_faded":"#666666",
       "accent":"#CC0000","accent_light":"#FF3333","border":"#DDDDDD"}
      ```
      
      ### ocean-breeze
      Light teal/aqua tones for a fresh, modern feel.
      Best for: Product reports, marketing docs, light technical content.
      ```python
      {"canvas":"#F0F7F4","canvas_sec":"#E0EDE8","ink":"#1A2E35","ink_faded":"#5A7D7C",
       "accent":"#2A9D8F","accent_light":"#64CCBF","border":"#C8DDD6"}
      ```
      
      ### consulting-navy
      White canvas, dark navy accent. ALL-CAPS left-aligned section headings with underline rule, left page-stripe, serif body. Cover is a solid navy block with white title.
      Best for: McKinsey / BCG / Deloitte-style deep-research reports, market analyses, white papers.
      ```python
      {"canvas":"#FFFFFF","canvas_sec":"#F4F6F9","ink":"#1A1A1A","ink_faded":"#5A6573",
       "accent":"#1E3A5F","accent_light":"#3A5C82","border":"#D6DCE5"}
      ```
      
      ## Custom Theme
      
      Pass a JSON file path to `--theme-file`:
      
      ```json
      {
        "canvas": "#FEFEFE",
        "canvas_sec": "#F5F5F5",
        "ink": "#222222",
        "ink_faded": "#888888",
        "accent": "#E74C3C",
        "accent_light": "#F39C12",
        "border": "#DDDDDD"
      }
      ```
      
  • scripts
    • md2pdf.py 102 KB
      #!/usr/bin/env python3
      """
      md2pdf — Convert Markdown to professionally typeset PDF.
      
      Features:
        - CJK/Latin mixed text with automatic font switching
        - Fenced code blocks with preserved indentation and line breaks
        - Markdown tables with smart proportional column widths
        - Markdown images, Obsidian callouts, emoji fallback, LaTeX-style formulas
        - Cover page, clickable TOC, PDF bookmarks, page numbers
        - Frontispiece (full-page image after cover) and back cover (banner branding)
        - Configurable color themes
        - Watermark support
        - Running header with report title + chapter name
        - Footer with author/brand, page number, date
      
      Usage:
        python md2pdf.py --input report.md --output report.pdf --title "My Report"
      
      Dependencies:
        pip install reportlab --break-system-packages
        Optional for rendered formulas: pip install matplotlib --break-system-packages
      """
      
      import re, os, sys, json, argparse, io, tempfile, hashlib, urllib.parse, urllib.request
      from bisect import bisect_right as _bisect_right
      from datetime import date
      from reportlab.lib.pagesizes import A4, LETTER
      from reportlab.lib.units import mm
      from reportlab.lib.colors import Color, HexColor, black, white
      from reportlab.lib.styles import ParagraphStyle
      from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_JUSTIFY
      from reportlab.lib.utils import ImageReader
      from reportlab.platypus import (
          BaseDocTemplate, PageTemplate, Frame, Paragraph, Spacer, PageBreak,
          Table, TableStyle, NextPageTemplate, Flowable, Image as RLImage
      )
      from reportlab.pdfbase import pdfmetrics
      from reportlab.pdfbase import pdfdoc
      from reportlab.pdfbase.pdfmetrics import Font
      from reportlab.pdfbase.ttfonts import TTFont
      
      # ═══════════════════════════════════════════════════════════════════════
      # FONTS — cross-platform font discovery (macOS / Linux / Windows)
      # ═══════════════════════════════════════════════════════════════════════
      import platform as _platform
      _PLAT = _platform.system()  # "Darwin", "Linux", "Windows"
      _SKILL_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
      _BUNDLED_FONT_DIR = os.path.join(_SKILL_ROOT, "assets", "fonts")
      
      def _find_font(candidates):
          """Return first existing path from candidates list.
          Each candidate is either a string path or a (path, subfontIndex) tuple."""
          for c in candidates:
              path = c[0] if isinstance(c, tuple) else c
              if os.path.exists(path):
                  return c
          return None
      
      # Font candidates per role — ordered by preference, first match wins.
      # Each role lists candidates for macOS, Windows, Linux in one flat list.
      _FONT_CANDIDATES = {
          "Sans": [
              "/System/Library/Fonts/Supplemental/Arial.ttf",                          # macOS
              "C:/Windows/Fonts/arial.ttf",                                            # Windows
              "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",                       # Linux common
              "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
              "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
              "/usr/share/fonts/truetype/freefont/FreeSans.ttf",
              "/usr/share/fonts/truetype/crosextra/Carlito-Regular.ttf",               # Linux Debian
              "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",                   # Linux Noto
              "/usr/share/fonts/noto/NotoSans-Regular.ttf",                            # Linux Fedora
          ],
          "SansBold": [
              "/System/Library/Fonts/Supplemental/Arial Bold.ttf",
              "C:/Windows/Fonts/arialbd.ttf",
              "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
              "/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf",
              "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
              "/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",
              "/usr/share/fonts/truetype/crosextra/Carlito-Bold.ttf",
              "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf",
              "/usr/share/fonts/noto/NotoSans-Bold.ttf",
          ],
          "SansIt": [
              "/System/Library/Fonts/Supplemental/Arial Italic.ttf",
              "C:/Windows/Fonts/ariali.ttf",
              "/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.ttf",
              "/usr/share/fonts/truetype/liberation2/LiberationSans-Italic.ttf",
              "/usr/share/fonts/truetype/liberation/LiberationSans-Italic.ttf",
              "/usr/share/fonts/truetype/freefont/FreeSansOblique.ttf",
              "/usr/share/fonts/truetype/crosextra/Carlito-Italic.ttf",
              "/usr/share/fonts/truetype/noto/NotoSans-Italic.ttf",
          ],
          "SansBI": [
              "/System/Library/Fonts/Supplemental/Arial Bold Italic.ttf",
              "C:/Windows/Fonts/arialbi.ttf",
              "/usr/share/fonts/truetype/dejavu/DejaVuSans-BoldOblique.ttf",
              "/usr/share/fonts/truetype/liberation2/LiberationSans-BoldItalic.ttf",
              "/usr/share/fonts/truetype/liberation/LiberationSans-BoldItalic.ttf",
              "/usr/share/fonts/truetype/freefont/FreeSansBoldOblique.ttf",
              "/usr/share/fonts/truetype/crosextra/Carlito-BoldItalic.ttf",
              "/usr/share/fonts/truetype/noto/NotoSans-BoldItalic.ttf",
          ],
          "Serif": [
              ("/System/Library/Fonts/Palatino.ttc", 0),                               # macOS Palatino
              "C:/Windows/Fonts/times.ttf",                                            # Windows TNR
              "/usr/share/fonts/truetype/liberation2/LiberationSerif-Regular.ttf",     # Linux
              "/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf",
              "/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf",
              "/usr/share/fonts/truetype/freefont/FreeSerif.ttf",
              "/usr/share/fonts/truetype/noto/NotoSerif-Regular.ttf",
              "/usr/share/fonts/noto/NotoSerif-Regular.ttf",
          ],
          "SerifBold": [
              ("/System/Library/Fonts/Palatino.ttc", 2),
              "C:/Windows/Fonts/timesbd.ttf",
              "/usr/share/fonts/truetype/liberation2/LiberationSerif-Bold.ttf",
              "/usr/share/fonts/truetype/liberation/LiberationSerif-Bold.ttf",
              "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf",
              "/usr/share/fonts/truetype/freefont/FreeSerifBold.ttf",
              "/usr/share/fonts/truetype/noto/NotoSerif-Bold.ttf",
          ],
          "SerifIt": [
              ("/System/Library/Fonts/Palatino.ttc", 1),
              "C:/Windows/Fonts/timesi.ttf",
              "/usr/share/fonts/truetype/liberation2/LiberationSerif-Italic.ttf",
              "/usr/share/fonts/truetype/liberation/LiberationSerif-Italic.ttf",
              "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Italic.ttf",
              "/usr/share/fonts/truetype/freefont/FreeSerifItalic.ttf",
          ],
          "SerifBI": [
              ("/System/Library/Fonts/Palatino.ttc", 3),
              "C:/Windows/Fonts/timesbi.ttf",
              "/usr/share/fonts/truetype/liberation2/LiberationSerif-BoldItalic.ttf",
              "/usr/share/fonts/truetype/liberation/LiberationSerif-BoldItalic.ttf",
              "/usr/share/fonts/truetype/dejavu/DejaVuSerif-BoldItalic.ttf",
              "/usr/share/fonts/truetype/freefont/FreeSerifBoldItalic.ttf",
          ],
          "CJK": [
              # Songti.ttc index 0 is Songti SC Black; index 6 is the readable Regular face.
              ("/System/Library/Fonts/Supplemental/Songti.ttc", 6),                   # macOS Songti SC Regular
              "C:/Windows/Fonts/simsun.ttc",                                           # Windows SimSun (宋体)
              "C:/Windows/Fonts/msyh.ttc",                                             # Windows MSYH (微软雅黑)
              "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
              "/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf",
              "/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc",              # Linux Noto CJK
              "/usr/share/fonts/noto-cjk/NotoSerifCJK-Regular.ttc",                   # Linux Fedora
              "/usr/share/fonts/truetype/arphic/uming.ttc",
              "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
              "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
              "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",            # Linux Droid
              "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
              "/usr/share/fonts/truetype/unifont/unifont.ttf",
              "/System/Library/Fonts/Supplemental/Arial Unicode.ttf",                  # macOS fallback
          ],
          "CJKBold": [
              ("/System/Library/Fonts/Supplemental/Songti.ttc", 1),
              "C:/Windows/Fonts/simsunb.ttf",
              "C:/Windows/Fonts/msyhbd.ttc",
              "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
              "/usr/share/fonts/opentype/noto/NotoSansCJKsc-Bold.otf",
              "/usr/share/fonts/opentype/noto/NotoSerifCJK-Bold.ttc",
              "/usr/share/fonts/noto-cjk/NotoSerifCJK-Bold.ttc",
              "/usr/share/fonts/truetype/arphic/uming.ttc",
              "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
              "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
          ],
          "Mono": [
              ("/System/Library/Fonts/Menlo.ttc", 0),                                  # macOS
              "C:/Windows/Fonts/consola.ttf",                                          # Windows Consolas
              "C:/Windows/Fonts/cour.ttf",                                             # Windows Courier New
              "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",                  # Linux
              "/usr/share/fonts/truetype/noto/NotoSansMono-Regular.ttf",
          ],
          "MonoBold": [
              ("/System/Library/Fonts/Menlo.ttc", 1),
              "C:/Windows/Fonts/consolab.ttf",
              "C:/Windows/Fonts/courbd.ttf",
              "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf",
              "/usr/share/fonts/truetype/noto/NotoSansMono-Bold.ttf",
          ],
          "Emoji": [
              ("/System/Library/Fonts/Apple Color Emoji.ttc", 0),
              "C:/Windows/Fonts/seguiemj.ttf",
              "C:/Windows/Fonts/seguisym.ttf",
              "/usr/share/fonts/truetype/noto/NotoEmoji-Regular.ttf",
              "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf",
              "/usr/share/fonts/truetype/ancient-scripts/Symbola_hint.ttf",
          ],
          "Symbols": [
              "/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
              "C:/Windows/Fonts/seguisym.ttf",
              "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
              "/usr/share/fonts/truetype/noto/NotoSansSymbols2-Regular.ttf",
              "/usr/share/fonts/truetype/freefont/FreeSans.ttf",
          ],
      }
      
      _CJK_SANS_BOLD_CANDIDATES = [
          os.path.join(_BUNDLED_FONT_DIR, "NotoSansSC-SemiBold.ttf"),
          ("/System/Library/Fonts/STHeiti Medium.ttc", 1),
          "C:/Windows/Fonts/msyhbd.ttc",
          "/usr/share/opentype/noto/NotoSansCJK-Bold.ttc",
          "/usr/share/opentype/noto/NotoSansCJKsc-Bold.otf",
          "/usr/share/fonts/noto-cjk/NotoSansCJK-Bold.ttc",
          "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
      ]
      
      _REGISTERED_TTF = set()
      _EMOJI_FONT_REGISTERED = False
      
      _BUILTIN_FONT_FALLBACKS = {
          "Sans": "Helvetica",
          "SansBold": "Helvetica-Bold",
          "SansIt": "Helvetica-Oblique",
          "SansBI": "Helvetica-BoldOblique",
          "Serif": "Times-Roman",
          "SerifBold": "Times-Bold",
          "SerifIt": "Times-Italic",
          "SerifBI": "Times-BoldItalic",
          "Mono": "Courier",
          "MonoBold": "Courier-Bold",
          "CJK": "Helvetica",
          "CJKBold": "Helvetica-Bold",
          "Emoji": "Helvetica",
          "Symbols": "Helvetica",
      }
      
      def register_fonts(cjk_bold_style="serif"):
          global _EMOJI_FONT_REGISTERED
          missing = []
          candidates_by_role = {name: list(candidates) for name, candidates in _FONT_CANDIDATES.items()}
          if cjk_bold_style == "sans":
              candidates_by_role["CJKBold"] = [
                  *list(_CJK_SANS_BOLD_CANDIDATES),
                  *candidates_by_role["CJKBold"],
              ]
          for name, candidates in candidates_by_role.items():
              spec = _find_font(candidates)
              if spec is None:
                  missing.append(name)
              else:
                  try:
                      if isinstance(spec, tuple):
                          pdfmetrics.registerFont(TTFont(name, spec[0], subfontIndex=spec[1]))
                      else:
                          pdfmetrics.registerFont(TTFont(name, spec))
                      _REGISTERED_TTF.add(name)
                      if name == "Emoji":
                          _EMOJI_FONT_REGISTERED = True
                      continue
                  except Exception as e:
                      missing.append(name)
                      print(f"Warning: Font {name} — {e}", file=sys.stderr)
              base = _BUILTIN_FONT_FALLBACKS.get(name)
              if base:
                  try:
                      pdfmetrics.registerFont(Font(name, base, "WinAnsiEncoding"))
                  except Exception:
                      pass
          if missing:
              print(f"Warning: Missing fonts: {', '.join(missing)}. PDF may have □ characters.", file=sys.stderr)
              if _PLAT == "Linux":
                  print("  Fix: sudo apt install fonts-dejavu-core fonts-liberation fonts-freefont-ttf fonts-noto fonts-noto-cjk fonts-noto-color-emoji", file=sys.stderr)
              elif _PLAT == "Windows":
                  print("  Fix: Install Noto fonts from https://fonts.google.com/noto", file=sys.stderr)
          pdfmetrics.registerFontFamily("Sans", normal="Sans", bold="SansBold",
                                        italic="SansIt", boldItalic="SansBI")
          pdfmetrics.registerFontFamily("CJK", normal="CJK", bold="CJKBold",
                                        italic="CJK", boldItalic="CJKBold")
          pdfmetrics.registerFontFamily("Serif", normal="Serif", bold="SerifBold",
                                        italic="SerifIt", boldItalic="SerifBI")
      
      # ═══════════════════════════════════════════════════════════════════════
      # THEMES — each theme has colors + layout for real typographic difference
      # ═══════════════════════════════════════════════════════════════════════
      # Layout keys:
      #   margins: (left, right, top, bottom) in mm
      #   body_font: "Serif" or "Sans"
      #   body_size / body_leading: body text dimensions
      #   h1_size / h2_size / h3_size: heading sizes
      #   heading_align: TA_CENTER or TA_LEFT
      #   heading_decoration: "rules" | "underline" | "dot" | "none"
      #   header_style: "full" | "minimal" | "none"
      #   code_style: "bg" (background fill) | "border" (left border only)
      #   cover_style: "centered" | "left-aligned" | "minimal"
      #   page_decoration: "none" | "top-bar" | "left-stripe" | "side-rule" | "corner-marks"
      #   continuous_headings: keep H1/H2 in flow instead of forcing divider pages
      #   cjk_bold_style: "serif" | "sans" for headings and emphasized Chinese
      #   fit_width_on_open: request SinglePage + FitH in supporting PDF viewers
      
      _DEFAULT_LAYOUT = {
          "margins": (25, 22, 28, 25),
          "body_font": "Serif", "body_size": 10.5, "body_leading": 17,
          "h1_size": 26, "h2_size": 18, "h3_size": 12,
          "heading_align": "center", "heading_decoration": "rules",
          "header_style": "full", "code_style": "bg", "cover_style": "centered",
          "page_decoration": "none",
      }
      
      THEMES = {
          "songti-reading": {
              "canvas":"#FDFCF9","canvas_sec":"#F5F6F3","ink":"#232B36","ink_faded":"#66707C",
              "accent":"#315A78","accent_light":"#7891A6","border":"#E0E4E5",
              "watermark_rgba":(0.82,0.85,0.88,0.04),
              "layout": {
                  "margins":(28, 28, 22, 24),
                  "body_font":"Serif","body_size":11.8,"body_leading":20.0,
                  "h1_size":22,"h2_size":17,"h3_size":14.2,
                  "heading_font":"SansBold","heading_align":"left","heading_decoration":"none",
                  "header_style":"none","code_style":"border","cover_style":"minimal",
                  "page_decoration":"none","continuous_headings":True,
                  "cjk_bold_style":"sans","table_body_font":"Serif",
                  "table_header_size":11.0,"table_body_size":10.8,"table_leading":17.2,
                  "table_min_width":23,"table_padding":5,
                  "code_size":10.0,"code_leading":16.2,
                  "fit_width_on_open":True,
              }
          },
          "warm-academic": {
              "canvas":"#F9F9F7","canvas_sec":"#F0EEE6","ink":"#181818","ink_faded":"#87867F",
              "accent":"#4F46E5","accent_light":"#D99A82","border":"#E8E6DC",
              "watermark_rgba":(0.82,0.80,0.76,0.12),
              "layout": {
                  "body_font":"Serif","body_size":10.5,"body_leading":17,
                  "heading_align":"center","heading_decoration":"rules",
                  "header_style":"full","code_style":"bg","cover_style":"centered",
                  "page_decoration":"top-bar",
              }
          },
          "nord-frost": {
              "canvas":"#ECEFF4","canvas_sec":"#E5E9F0","ink":"#2E3440","ink_faded":"#4C566A",
              "accent":"#5E81AC","accent_light":"#81A1C1","border":"#D8DEE9",
              "watermark_rgba":(0.74,0.78,0.85,0.10),
              "layout": {
                  "body_font":"Sans","body_size":10,"body_leading":16,
                  "h3_size":11,"heading_align":"left","heading_decoration":"underline",
                  "header_style":"minimal","code_style":"border","cover_style":"left-aligned",
                  "page_decoration":"left-stripe",
              }
          },
          "github-light": {
              "canvas":"#FFFFFF","canvas_sec":"#F6F8FA","ink":"#1F2328","ink_faded":"#656D76",
              "accent":"#0969DA","accent_light":"#218BFF","border":"#D0D7DE",
              "watermark_rgba":(0.80,0.82,0.85,0.08),
              "layout": {
                  "body_font":"Sans","body_size":10,"body_leading":16.5,
                  "heading_align":"left","heading_decoration":"none",
                  "header_style":"minimal","code_style":"bg","cover_style":"left-aligned",
                  "page_decoration":"left-stripe",
              }
          },
          "solarized-light": {
              "canvas":"#FDF6E3","canvas_sec":"#EEE8D5","ink":"#657B83","ink_faded":"#93A1A1",
              "accent":"#CB4B16","accent_light":"#DC322F","border":"#EEE8D5",
              "watermark_rgba":(0.85,0.82,0.72,0.10),
          },
          "paper-classic": {
              "canvas":"#FFFFFF","canvas_sec":"#FAFAFA","ink":"#000000","ink_faded":"#666666",
              "accent":"#CC0000","accent_light":"#FF3333","border":"#DDDDDD",
              "watermark_rgba":(0.85,0.85,0.85,0.08),
          },
          "ocean-breeze": {
              "canvas":"#F0F7F4","canvas_sec":"#E0EDE8","ink":"#1A2E35","ink_faded":"#5A7D7C",
              "accent":"#2A9D8F","accent_light":"#64CCBF","border":"#C8DDD6",
              "watermark_rgba":(0.75,0.85,0.80,0.10),
              "layout": {
                  "body_font":"Sans","body_size":10.5,"body_leading":17,
                  "heading_align":"left","heading_decoration":"underline",
                  "header_style":"full","code_style":"bg","cover_style":"centered",
                  "page_decoration":"top-bar",
              }
          },
          "monokai-warm": {
              "canvas":"#272822","canvas_sec":"#1E1F1C","ink":"#F8F8F2","ink_faded":"#75715E",
              "accent":"#F92672","accent_light":"#FD971F","border":"#49483E",
              "watermark_rgba":(0.30,0.30,0.28,0.08),
          },
          "dracula-soft": {
              "canvas":"#282A36","canvas_sec":"#21222C","ink":"#F8F8F2","ink_faded":"#6272A4",
              "accent":"#BD93F9","accent_light":"#FF79C6","border":"#44475A",
              "watermark_rgba":(0.35,0.30,0.45,0.08),
          },
          # --- Inspired by classic LaTeX templates ---
          "tufte": {
              "canvas":"#FFFFF8","canvas_sec":"#F7F7F0","ink":"#111111","ink_faded":"#999988",
              "accent":"#980000","accent_light":"#C04040","border":"#E0DDD0",
              "watermark_rgba":(0.88,0.87,0.82,0.08),
              "layout": {
                  "margins":(30, 55, 25, 25),  # wide right margin (Tufte sidenote style)
                  "body_font":"Serif","body_size":11,"body_leading":18,
                  "h1_size":24,"h2_size":16,"h3_size":11,
                  "heading_align":"left","heading_decoration":"none",
                  "header_style":"none","code_style":"border","cover_style":"minimal",
                  "page_decoration":"side-rule",
              }
          },
          "classic-thesis": {
              "canvas":"#FEFEFE","canvas_sec":"#F5F2EB","ink":"#2B2B2B","ink_faded":"#7A7568",
              "accent":"#8B4513","accent_light":"#A0522D","border":"#D6CFC2",
              "watermark_rgba":(0.82,0.78,0.72,0.10),
              "layout": {
                  "body_font":"Serif","body_size":10.5,"body_leading":17,
                  "h1_size":28,"h2_size":20,
                  "heading_align":"center","heading_decoration":"rules",
                  "header_style":"full","code_style":"bg","cover_style":"centered",
                  "page_decoration":"corner-marks",
              }
          },
          "ieee-journal": {
              "canvas":"#FFFFFF","canvas_sec":"#F5F5F5","ink":"#000000","ink_faded":"#555555",
              "accent":"#003366","accent_light":"#336699","border":"#CCCCCC",
              "watermark_rgba":(0.82,0.82,0.82,0.08),
              "layout": {
                  "margins":(20, 20, 22, 20),  # tight margins like journal papers
                  "body_font":"Serif","body_size":9.5,"body_leading":14,
                  "h1_size":22,"h2_size":14,"h3_size":11,
                  "heading_align":"left","heading_decoration":"underline",
                  "header_style":"minimal","code_style":"border","cover_style":"left-aligned",
                  "page_decoration":"top-band",
              }
          },
          "elegant-book": {
              "canvas":"#FBF9F1","canvas_sec":"#F0ECE0","ink":"#1A1A1A","ink_faded":"#6E6B5E",
              "accent":"#5B3A29","accent_light":"#7D5642","border":"#DDD8C8",
              "watermark_rgba":(0.85,0.82,0.75,0.10),
              "layout": {
                  "margins":(28, 24, 30, 28),  # generous margins for book feel
                  "body_font":"Serif","body_size":10.5,"body_leading":18,
                  "h1_size":28,"h2_size":20,
                  "heading_align":"center","heading_decoration":"dot",
                  "header_style":"full","code_style":"bg","cover_style":"centered",
                  "page_decoration":"double-rule",
              }
          },
          "chinese-red": {
              "canvas":"#FFFDF5","canvas_sec":"#F8F0E0","ink":"#1A1009","ink_faded":"#8C7A5E",
              "accent":"#B22222","accent_light":"#D44040","border":"#E8DCC8",
              "watermark_rgba":(0.88,0.82,0.72,0.10),
              "layout": {
                  "body_font":"Serif","body_size":11,"body_leading":18,
                  "h1_size":28,"h2_size":20,
                  "heading_align":"center","heading_decoration":"rules",
                  "header_style":"full","code_style":"bg","cover_style":"centered",
                  "page_decoration":"top-bar",
              }
          },
          "ink-wash": {
              "canvas":"#F8F6F0","canvas_sec":"#EEEAE0","ink":"#2C2C2C","ink_faded":"#8A8A80",
              "accent":"#404040","accent_light":"#666660","border":"#D8D4C8",
              "watermark_rgba":(0.80,0.80,0.76,0.10),
              "layout": {
                  "margins":(30, 30, 30, 28),  # symmetric, generous whitespace
                  "body_font":"Serif","body_size":10.5,"body_leading":18,
                  "h1_size":24,"h2_size":16,"h3_size":11,
                  "heading_align":"center","heading_decoration":"dot",
                  "header_style":"none","code_style":"border","cover_style":"minimal",
                  "page_decoration":"none",
              }
          },
          "consulting-navy": {
              # McKinsey/BCG/Deloitte deep-research aesthetic:
              # white canvas, dark-navy section blocks, ALL-CAPS left-aligned headings,
              # serif body, sans heading. Cover = solid navy block with white title.
              "canvas":"#FFFFFF","canvas_sec":"#F4F6F9","ink":"#1A1A1A","ink_faded":"#5A6573",
              "accent":"#1E3A5F","accent_light":"#3A5C82","border":"#D6DCE5",
              "watermark_rgba":(0.78,0.82,0.88,0.08),
              "layout": {
                  "margins":(22, 22, 26, 24),
                  "body_font":"Serif","body_size":10.5,"body_leading":16.5,
                  "h1_size":22,"h2_size":16,"h3_size":11,
                  "heading_align":"left","heading_decoration":"banner",
                  "header_style":"minimal","code_style":"bg",
                  "cover_style":"consulting-block",
                  "page_decoration":"left-stripe",
              }
          },
      }
      
      def load_theme(name, theme_file=None):
          if theme_file and os.path.exists(theme_file):
              with open(theme_file) as f:
                  t = json.load(f)
          elif name in THEMES:
              t = THEMES[name]
          else:
              print(f"Unknown theme '{name}', falling back to warm-academic", file=sys.stderr)
              t = THEMES["warm-academic"]
          # Merge layout with defaults
          layout = dict(_DEFAULT_LAYOUT)
          layout.update(t.get("layout", {}))
          return {
              "canvas":    HexColor(t["canvas"]),
              "canvas_sec":HexColor(t["canvas_sec"]),
              "ink":       HexColor(t["ink"]),
              "ink_faded": HexColor(t["ink_faded"]),
              "accent":    HexColor(t["accent"]),
              "accent_light":HexColor(t.get("accent_light", t["accent"])),
              "border":    HexColor(t["border"]),
              "wm_color":  Color(*t.get("watermark_rgba", (0.82,0.80,0.76,0.12))),
              "layout":    layout,
          }
      
      # ═══════════════════════════════════════════════════════════════════════
      # CJK / EMOJI DETECTION + FONT WRAPPING
      # ═══════════════════════════════════════════════════════════════════════
      _CJK_RANGES = [
          (0x2E80,0x2EFF),(0x2F00,0x2FDF),(0x3000,0x303F),(0x3400,0x4DBF),
          (0x4E00,0x9FFF),(0xF900,0xFAFF),(0xFE30,0xFE4F),(0xFF00,0xFFEF),
          (0x20000,0x2A6DF),(0x2A700,0x2B73F),(0x2B740,0x2B81F),
      ]
      _CJK_STARTS = [lo for lo, hi in _CJK_RANGES]
      _CJK_ENDS = [hi for lo, hi in _CJK_RANGES]
      
      def _is_cjk(ch):
          cp = ord(ch)
          idx = _bisect_right(_CJK_STARTS, cp) - 1
          return idx >= 0 and cp <= _CJK_ENDS[idx]
      
      _EMOJI_RANGES = [
          (0x1F000,0x1FAFF),(0x2600,0x27BF),(0x2300,0x23FF),(0x2B00,0x2BFF),
      ]
      _EMOJI_TRAILING = set([0x200D, 0x20E3, 0xFE0E, 0xFE0F])
      _TWEMOJI_CACHE = os.path.join(tempfile.gettempdir(), "any2pdf-twemoji")
      _TWEMOJI_MISSES = set()
      
      def _is_emoji_char(ch):
          cp = ord(ch)
          return any(lo <= cp <= hi for lo, hi in _EMOJI_RANGES) or 0x1F3FB <= cp <= 0x1F3FF
      
      def _emoji_sequence(text, start):
          end = start + 1
          while end < len(text):
              cp = ord(text[end])
              if cp in _EMOJI_TRAILING or 0x1F3FB <= cp <= 0x1F3FF:
                  end += 1
                  continue
              if text[end - 1] == "\u200d" and _is_emoji_char(text[end]):
                  end += 1
                  continue
              break
          return text[start:end], end
      
      def _emoji_codepoint(seq):
          cps = []
          for ch in seq:
              cp = ord(ch)
              if cp in (0xFE0E, 0xFE0F):
                  continue
              cps.append(f"{cp:x}")
          return "-".join(cps)
      
      def _esc_attr(text):
          return esc(str(text)).replace('"', "&quot;")
      
      def _twemoji_path(seq):
          code = _emoji_codepoint(seq)
          if not code or code in _TWEMOJI_MISSES:
              return None
          os.makedirs(_TWEMOJI_CACHE, exist_ok=True)
          path = os.path.join(_TWEMOJI_CACHE, f"{code}.png")
          if os.path.exists(path):
              return path
          url = f"https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/72x72/{code}.png"
          try:
              req = urllib.request.Request(url, headers={"User-Agent": "any2pdf/1.0"})
              with urllib.request.urlopen(req, timeout=2.0) as resp:
                  data = resp.read()
              if data:
                  with open(path, "wb") as f:
                      f.write(data)
                  return path
          except Exception:
              _TWEMOJI_MISSES.add(code)
          return None
      
      def _emoji_inline(text, size=10):
          """Replace supported emoji runs with cached Twemoji image tags."""
          out, i = [], 0
          while i < len(text):
              ch = text[i]
              if _is_emoji_char(ch):
                  seq, end = _emoji_sequence(text, i)
                  path = _twemoji_path(seq)
                  if path:
                      src = _esc_attr(path)
                      out.append(f'<img src="{src}" width="{size}" height="{size}" valign="-2"/>')
                  elif _EMOJI_FONT_REGISTERED:
                      out.append(f"<font name='Emoji'>{seq}</font>")
                  else:
                      out.append(seq)
                  i = end
              else:
                  out.append(ch)
                  i += 1
          return ''.join(out)
      
      _SYMBOL_FONT_CHARS = frozenset("✓✗→")
      
      def _font_wrap_plain(text, cjk_font="CJK"):
          out, buf, role = [], [], None
          for ch in text:
              if ch in _SYMBOL_FONT_CHARS:
                  next_role = "Symbols"
              elif _is_cjk(ch):
                  next_role = cjk_font
              elif _EMOJI_FONT_REGISTERED and (_is_emoji_char(ch) or (ord(ch) in _EMOJI_TRAILING and role == "Emoji")):
                  next_role = "Emoji"
              else:
                  next_role = None
              if next_role != role and buf:
                  seg = ''.join(buf)
                  out.append(f"<font name='{role}'>{seg}</font>" if role else seg)
                  buf = []
              buf.append(ch); role = next_role
          if buf:
              seg = ''.join(buf)
              out.append(f"<font name='{role}'>{seg}</font>" if role else seg)
          return ''.join(out)
      
      def _font_wrap(text, force_cjk_bold=False):
          """Wrap CJK, symbols, and emoji without touching ReportLab XML tags."""
          parts = re.split(r'(<[^>]+>)', text)
          out, bold_depth = [], 0
          for part in parts:
              if part.startswith("<") and part.endswith(">"):
                  tag = part.lower().replace(" ", "")
                  if tag in ("</b>", "</strong>"):
                      bold_depth = max(0, bold_depth - 1)
                  out.append(part)
                  if tag in ("<b>", "<strong>"):
                      bold_depth += 1
                  continue
              cjk_font = "CJKBold" if force_cjk_bold or bold_depth else "CJK"
              out.append(_font_wrap_plain(part, cjk_font))
          return ''.join(out)
      
      def _draw_mixed(c, x, y, text, size, anchor="left", max_w=0):
          """Draw mixed CJK/Latin text on canvas with font switching.
          If max_w > 0, wrap into multiple lines. Returns bottom y of drawn text."""
          if max_w > 0:
              return _draw_mixed_wrap(c, x, y, text, size, anchor, max_w)
          segs, buf, in_cjk = [], [], False
          for ch in text:
              cj = _is_cjk(ch)
              if cj != in_cjk and buf:
                  segs.append(("CJK" if in_cjk else "Sans", ''.join(buf))); buf = []
              buf.append(ch); in_cjk = cj
          if buf: segs.append(("CJK" if in_cjk else "Sans", ''.join(buf)))
          total_w = sum(c.stringWidth(t, f, size) for f, t in segs)
          if anchor == "right": x -= total_w
          elif anchor == "center": x -= total_w / 2
          for font, txt in segs:
              c.setFont(font, size); c.drawString(x, y, txt)
              x += c.stringWidth(txt, font, size)
      
      def _measure_mixed(c, text, size):
          """Measure width of mixed CJK/Latin text."""
          w = 0
          buf, in_cjk = [], False
          for ch in text:
              cj = _is_cjk(ch)
              if cj != in_cjk and buf:
                  w += c.stringWidth(''.join(buf), "CJK" if in_cjk else "Sans", size); buf = []
              buf.append(ch); in_cjk = cj
          if buf: w += c.stringWidth(''.join(buf), "CJK" if in_cjk else "Sans", size)
          return w
      
      def _draw_mixed_wrap(c, x, y, text, size, anchor, max_w):
          """Word-wrap mixed text into multiple lines, shrink font if single word overflows."""
          words = text.split(' ')
          # Shrink font until longest word fits (floor 16pt)
          while size > 16:
              longest = max(_measure_mixed(c, w, size) for w in words)
              if longest <= max_w: break
              size -= 1
          # Greedy line breaking
          lines, cur = [], []
          cur_w = 0
          space_w = c.stringWidth(' ', 'Sans', size)
          for word in words:
              ww = _measure_mixed(c, word, size)
              test_w = cur_w + (space_w if cur else 0) + ww
              if cur and test_w > max_w:
                  lines.append(' '.join(cur)); cur = [word]; cur_w = ww
              else:
                  cur.append(word); cur_w = test_w
          if cur: lines.append(' '.join(cur))
          # Draw lines downward from y (top line at y)
          line_h = size * 1.3
          for i, line in enumerate(lines):
              _draw_mixed(c, x, y - i * line_h, line, size, anchor)
          return y - (len(lines) - 1) * line_h
      
      def _draw_mixed_segs(c, x, y, segs):
          """Draw pre-defined (font, text, size) segments on canvas.
          Used for mixed-font subtitle rendering."""
          total_w = sum(c.stringWidth(txt, font, sz) for font, txt, sz in segs)
          x = x - total_w / 2  # always centered
          for font, txt, sz in segs:
              c.setFont(font, sz)
              c.drawString(x, y, txt)
              x += c.stringWidth(txt, font, sz)
      
      # ═══════════════════════════════════════════════════════════════════════
      # INLINE MARKDOWN + ESCAPING
      # ═══════════════════════════════════════════════════════════════════════
      def esc(text):
          return text.replace("&","&amp;").replace("<","&lt;").replace(">","&gt;")
      
      def esc_code(text):
          """Escape for code blocks: preserve indentation, alignment, and newlines."""
          out = []
          for line in text.split('\n'):
              e = esc(line)
              out.append(e.replace(' ', '&nbsp;'))
          return '<br/>'.join(out)
      
      def md_inline(text, accent_hex="#4F46E5", force_cjk_bold=False):
          text = esc(text)
          code_spans = []
          text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
          # Replace code spans — neutralize * inside code to avoid italic regex conflict
          def _code_repl(m):
              code_spans.append(f"<font name='Mono' size='8' color='{accent_hex}'>{m.group(1)}</font>")
              return f"@@ANY2PDF_CODE_{len(code_spans) - 1}@@"
          text = re.sub(r'`(.+?)`', _code_repl, text)
          text = re.sub(r'\\\((.+?)\\\)',
              rf"<font name='SerifIt' color='{accent_hex}'>\1</font>", text)
          text = re.sub(r'(?<!\\)\$(?!\$)(.+?)(?<!\\)\$',
              rf"<font name='SerifIt' color='{accent_hex}'>\1</font>", text)
          text = re.sub(r'(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)', r'<i>\1</i>', text)
          text = re.sub(r'(?<!!)\[(.+?)\]\(.+?\)', r'<u>\1</u>', text)
          text = _emoji_inline(text)
          for idx, html in enumerate(code_spans):
              text = text.replace(f"@@ANY2PDF_CODE_{idx}@@", html)
          return _font_wrap(text, force_cjk_bold=force_cjk_bold)
      
      _MD_IMAGE_RE = re.compile(r'!\[([^\]]*)\]\(([^)]+)\)')
      
      # ═══════════════════════════════════════════════════════════════════════
      # CUSTOM FLOWABLES
      # ═══════════════════════════════════════════════════════════════════════
      _anchor_counter = [0]
      _outline_level = [-1]
      _cur_chapter = [""]
      
      class ChapterMark(Flowable):
          width = height = 0
          def __init__(self, t, level=0):
              Flowable.__init__(self); self.title = t; self.level = level
              _anchor_counter[0] += 1; self.key = f"anchor_{_anchor_counter[0]}"
          def draw(self):
              _cur_chapter[0] = self.title
              self.canv.bookmarkPage(self.key)
              target = min(self.level, _outline_level[0] + 1)
              _outline_level[0] = target
              self.canv.addOutlineEntry(self.title, self.key, level=target, closed=(target==0))
      
      class HRule(Flowable):
          def __init__(self, w, thick=0.5, clr=None):
              Flowable.__init__(self)
              self.width = w; self.height = 4*mm; self._t = thick; self._c = clr or HexColor("#E8E6DC")
          def draw(self):
              self.canv.setStrokeColor(self._c); self.canv.setLineWidth(self._t)
              self.canv.line(0, 2*mm, self.width, 2*mm)
      
      class HRuleCentered(Flowable):
          """Horizontally centered rule within the frame width."""
          def __init__(self, frame_w, rule_w, thick=0.5, clr=None):
              Flowable.__init__(self)
              self.width = frame_w; self.height = 4*mm
              self._rw = rule_w; self._t = thick; self._c = clr or HexColor("#E8E6DC")
          def draw(self):
              self.canv.setStrokeColor(self._c); self.canv.setLineWidth(self._t)
              x0 = (self.width - self._rw) / 2
              self.canv.line(x0, 2*mm, x0 + self._rw, 2*mm)
      
      class ClayDot(Flowable):
          """Small accent-colored dot separator."""
          def __init__(self, w, clr=None):
              Flowable.__init__(self)
              self.width = w; self.height = 6*mm
              self._c = clr or HexColor("#4F46E5")
          def draw(self):
              self.canv.setFillColor(self._c)
              cx = self.width / 2
              self.canv.circle(cx, 3*mm, 1.5*mm, fill=1, stroke=0)
      
      class LeftBorderParagraph(Flowable):
          """Paragraph with a left accent border line (for code blocks in 'border' style)."""
          def __init__(self, para, border_color, border_width=2):
              Flowable.__init__(self)
              self._para = para
              self._bc = border_color; self._bw = border_width
          def wrap(self, aw, ah):
              w, h = self._para.wrap(aw, ah)
              self.width = w; self.height = h
              return w, h
          def draw(self):
              self._para.drawOn(self.canv, 0, 0)
              self.canv.setStrokeColor(self._bc); self.canv.setLineWidth(self._bw)
              self.canv.line(2, -2, 2, self.height + 2)
      
      class SectionBanner(Flowable):
          """Consulting-report section banner: ALL-CAPS heading on a tinted bg
          with a thick accent left bar + thin underline rule.
          Used for top-level H2 section markers in the consulting-navy theme."""
          def __init__(self, frame_w, title, accent, ink, tint, size=15):
              Flowable.__init__(self)
              self.width = frame_w
              self.height = 14*mm
              self._title = title
              self._accent = accent
              self._ink = ink
              self._tint = tint
              self._size = size
      
          def draw(self):
              c = self.canv
              # Tinted background block
              c.setFillColor(self._tint)
              c.rect(0, 0, self.width, self.height, fill=1, stroke=0)
              # Thick accent left bar
              c.setFillColor(self._accent)
              c.rect(0, 0, 2.5*mm, self.height, fill=1, stroke=0)
              # Bottom thin underline rule
              c.setStrokeColor(self._accent); c.setLineWidth(0.6)
              c.line(0, 0, self.width, 0)
              # Title text — left-aligned, vertically centered, SansBold for Latin, CJK for CJK
              c.setFillColor(self._accent)
              text_x = 7*mm
              text_y = self.height/2 - self._size*0.32
              title_up = self._title.upper()
              # Draw with font switching, forcing SansBold for non-CJK
              buf, in_cjk = [], False
              segs = []
              for ch in title_up:
                  cj = _is_cjk(ch)
                  if cj != in_cjk and buf:
                      segs.append(("CJK" if in_cjk else "SansBold", ''.join(buf))); buf = []
                  buf.append(ch); in_cjk = cj
              if buf: segs.append(("CJK" if in_cjk else "SansBold", ''.join(buf)))
              x = text_x
              for font, txt in segs:
                  c.setFont(font, self._size); c.drawString(x, text_y, txt)
                  x += c.stringWidth(txt, font, self._size)
      
      # ═══════════════════════════════════════════════════════════════════════
      # PDF BUILDER
      # ═══════════════════════════════════════════════════════════════════════
      class PDFBuilder:
          def __init__(self, config):
              self.cfg = config
              self.T = config["theme"]  # resolved theme colors
              self.L = self.T["layout"]  # layout parameters
              self.page_w, self.page_h = config["page_size"]
              lm, rm, tm, bm = self.L["margins"]
              self.lm, self.rm, self.tm, self.bm = lm*mm, rm*mm, tm*mm, bm*mm
              self.body_w = self.page_w - self.lm - self.rm
              self.body_h = self.page_h - self.tm - self.bm
              self.accent_hex = config.get("accent_hex", "#4F46E5")
              # Base directory used to resolve relative image paths in the markdown.
              # Defaults to the directory of the input .md file (set by main()).
              self.input_dir = config.get("input_dir", "")
              self.ST = self._build_styles()
      
          def _build_styles(self):
              T = self.T; L = self.L
              s = {}
              bf = L["body_font"]  # "Serif" or "Sans"
              bs, bl = L["body_size"], L["body_leading"]
              heading_font = L.get("heading_font", "Serif")
              h_align = TA_CENTER if L["heading_align"] == "center" else TA_LEFT
              s['part'] = ParagraphStyle('Part', fontName=heading_font, fontSize=L["h1_size"],
                  leading=L["h1_size"]+10, textColor=T["ink"], alignment=h_align,
                  spaceBefore=0, spaceAfter=0)
              s['chapter'] = ParagraphStyle('Ch', fontName=heading_font, fontSize=L["h2_size"],
                  leading=L["h2_size"]+8, textColor=T["ink"], alignment=h_align,
                  spaceBefore=0, spaceAfter=0)
              s['h3'] = ParagraphStyle('H3', fontName="SansBold", fontSize=L["h3_size"],
                  leading=L["h3_size"]+5, textColor=T["accent"], alignment=TA_LEFT,
                  spaceBefore=10, spaceAfter=4)
              s['h4'] = ParagraphStyle('H4', fontName="SansBold", fontSize=max(L["h3_size"] - 1, 9),
                  leading=max(L["h3_size"] + 3, 12), textColor=T["ink"], alignment=TA_LEFT,
                  spaceBefore=8, spaceAfter=3)
              s['h5'] = ParagraphStyle('H5', fontName="SansBold", fontSize=max(L["h3_size"] - 2, 8.5),
                  leading=max(L["h3_size"] + 2, 11), textColor=T["ink_faded"], alignment=TA_LEFT,
                  spaceBefore=6, spaceAfter=2)
              s['body'] = ParagraphStyle('Body', fontName=bf, fontSize=bs, leading=bl,
                  textColor=T["ink"], alignment=TA_JUSTIFY, spaceBefore=2, spaceAfter=4,
                  wordWrap='CJK')
              s['body_indent'] = ParagraphStyle('BI', parent=s['body'],
                  leftIndent=14, rightIndent=14, textColor=T["ink_faded"],
                  borderColor=T["accent"], borderWidth=0, borderPadding=4)
              s['callout_body'] = ParagraphStyle('CalloutBody', fontName=bf,
                  fontSize=bs, leading=bl, textColor=T["ink"], alignment=TA_LEFT,
                  spaceBefore=0, spaceAfter=0, wordWrap='CJK')
              s['caption'] = ParagraphStyle('Caption', fontName="Sans", fontSize=8.5,
                  leading=11, textColor=T["ink_faded"], alignment=TA_CENTER,
                  spaceBefore=3, spaceAfter=6, wordWrap='CJK')
              s['math'] = ParagraphStyle('MathFallback', fontName="Mono", fontSize=8.8,
                  leading=12, textColor=T["accent"], alignment=TA_CENTER,
                  spaceBefore=6, spaceAfter=6, wordWrap='CJK')
              s['bullet'] = ParagraphStyle('Bul', fontName=bf, fontSize=bs, leading=bl,
                  textColor=T["ink"], alignment=TA_LEFT, spaceBefore=1, spaceAfter=1,
                  leftIndent=18, bulletIndent=6, wordWrap='CJK')
              # Code block: "bg" = background fill, "border" = left accent line (no bg)
              self._code_style_type = L["code_style"]
              code_size = L.get("code_size", 7.5)
              code_leading = L.get("code_leading", 10.5)
              if L["code_style"] == "border":
                  s['code'] = ParagraphStyle('Code', fontName="Mono", fontSize=code_size, leading=code_leading,
                      textColor=HexColor("#3D3D3A"), alignment=TA_LEFT, spaceBefore=4, spaceAfter=4,
                      leftIndent=14, rightIndent=8, backColor=None,
                      borderColor=None, borderWidth=0, borderPadding=6)
              else:
                  s['code'] = ParagraphStyle('Code', fontName="Mono", fontSize=code_size, leading=code_leading,
                      textColor=HexColor("#3D3D3A"), alignment=TA_LEFT, spaceBefore=4, spaceAfter=4,
                      leftIndent=8, rightIndent=8, backColor=T["canvas_sec"],
                      borderColor=T["border"], borderWidth=0.5, borderPadding=6)
              s['toc1'] = ParagraphStyle('T1', fontName="Serif", fontSize=12, leading=20,
                  textColor=T["ink"], leftIndent=0, spaceBefore=6, spaceAfter=2)
              s['toc2'] = ParagraphStyle('T2', fontName="Sans", fontSize=10, leading=16,
                  textColor=T["ink_faded"], leftIndent=16, spaceBefore=1, spaceAfter=1)
              table_header_size = L.get("table_header_size", 8.5)
              table_body_size = L.get("table_body_size", 8)
              table_leading = L.get("table_leading", 11)
              table_body_font = L.get("table_body_font", "Sans")
              s['th'] = ParagraphStyle('TH', fontName="SansBold", fontSize=table_header_size, leading=table_leading,
                  textColor=white, alignment=TA_CENTER)
              s['tc'] = ParagraphStyle('TC', fontName=table_body_font, fontSize=table_body_size, leading=table_leading,
                  textColor=T["ink"], alignment=TA_LEFT)
              return s
      
          # ── Page callbacks ──
          def _draw_bg(self, c):
              c.setFillColor(self.T["canvas"])
              c.rect(0, 0, self.page_w, self.page_h, fill=1, stroke=0)
      
          def _configure_viewer(self, c):
              """Set the initial view requested by a reading-focused theme."""
              if c.getPageNumber() != 1 or not self.L.get("fit_width_on_open", False):
                  return
              catalog = c._doc._catalog
              catalog.setPageLayout("SinglePage")
              catalog.OpenAction = pdfdoc.PDFArray([
                  c._doc.thisPageRef(), pdfdoc.PDFName("FitH"), pdfdoc.PDFnull,
              ])
      
          def _cover_page(self, c, doc):
              self._configure_viewer(c)
              c.saveState(); self._draw_bg(c)
              T = self.T; cx = self.page_w / 2
              cover = self.L["cover_style"]
      
              if cover == "left-aligned":
                  self._cover_left_aligned(c, T, cx)
              elif cover == "minimal":
                  self._cover_minimal(c, T, cx)
              elif cover == "consulting-block":
                  self._cover_consulting_block(c, T, cx)
              else:
                  self._cover_centered(c, T, cx)
      
              c.restoreState()
      
          def _cover_centered(self, c, T, cx):
              """Classic centered cover with accent bars and rule."""
              # Top accent bar
              c.setFillColor(T["accent"])
              c.rect(0, self.page_h - 3*mm, self.page_w, 3*mm, fill=1, stroke=0)
      
              title_y = self.page_h * 0.62
              c.setFillColor(T["ink"])
              btm = _draw_mixed(c, cx, title_y, self.cfg.get("title", "Document"), 38, anchor="center", max_w=self.page_w - 40*mm)
      
              ver = self.cfg.get("version", "")
              if ver:
                  c.setFillColor(T["accent"]); c.setFont("Sans", 13)
                  c.drawCentredString(cx, btm - 30, ver)
      
              rule_y = btm - 52
              c.setStrokeColor(T["accent"]); c.setLineWidth(1.5)
              c.line(cx - 17*mm, rule_y, cx + 17*mm, rule_y)
      
              sub = self.cfg.get("subtitle", "")
              sub_segs = self.cfg.get("subtitle_segs")
              if sub_segs:
                  c.setFillColor(T["ink_faded"]); _draw_mixed_segs(c, cx, rule_y - 32, sub_segs)
              elif sub:
                  c.setFillColor(T["ink"]); _draw_mixed(c, cx, rule_y - 32, sub, 20, anchor="center")
      
              stats = self.cfg.get("stats_line", "")
              stats2 = self.cfg.get("stats_line2", "")
              if stats or stats2:
                  c.setFillColor(T["ink_faded"]); stats_y = rule_y - 72
                  if stats: _draw_mixed(c, cx, stats_y, stats, 9.5, anchor="center")
                  if stats2: _draw_mixed(c, cx, stats_y - 18, stats2, 9.5, anchor="center")
      
              c.setStrokeColor(T["border"]); c.setLineWidth(0.5)
              c.line(self.lm + 20*mm, 52*mm, self.page_w - self.rm - 20*mm, 52*mm)
      
              author = self.cfg.get("author", "")
              if author:
                  c.setFillColor(T["ink_faded"]); _draw_mixed(c, cx, 38*mm, author, 10, anchor="center")
      
              dt = self.cfg.get("date", str(date.today()))
              c.setFillColor(T["ink_faded"]); _draw_mixed(c, cx, 28*mm, dt, 9, anchor="center")
      
              edition = self.cfg.get("edition_line", "")
              if edition:
                  c.setFillColor(T["ink_faded"]); _draw_mixed(c, cx, 20*mm, edition, 7.5, anchor="center")
      
              c.setFillColor(T["accent"])
              c.rect(0, 0, self.page_w, 3*mm, fill=1, stroke=0)
      
          def _cover_left_aligned(self, c, T, cx):
              """Modern left-aligned cover (GitHub/IEEE style)."""
              # Thick left accent stripe
              c.setFillColor(T["accent"])
              c.rect(0, 0, 6*mm, self.page_h, fill=1, stroke=0)
      
              lx = 25*mm  # left text x
              title_y = self.page_h * 0.58
              c.setFillColor(T["ink"])
              btm = _draw_mixed(c, lx, title_y, self.cfg.get("title", "Document"), 34, anchor="left", max_w=self.page_w - lx - 20*mm)
      
              ver = self.cfg.get("version", "")
              if ver:
                  c.setFillColor(T["accent"]); c.setFont("Sans", 12)
                  c.drawString(lx, btm - 28, ver)
      
              # Accent underline
              c.setStrokeColor(T["accent"]); c.setLineWidth(2)
              c.line(lx, btm - 42, lx + 50*mm, btm - 42)
      
              sub = self.cfg.get("subtitle", "")
              sub_segs = self.cfg.get("subtitle_segs")
              if sub_segs:
                  c.setFillColor(T["ink_faded"]); _draw_mixed_segs(c, lx + 40*mm, btm - 62, sub_segs)
              elif sub:
                  c.setFillColor(T["ink_faded"]); _draw_mixed(c, lx, btm - 62, sub, 16, anchor="left")
      
              stats = self.cfg.get("stats_line", "")
              stats2 = self.cfg.get("stats_line2", "")
              if stats or stats2:
                  c.setFillColor(T["ink_faded"]); stats_y = btm - 100
                  if stats: _draw_mixed(c, lx, stats_y, stats, 9, anchor="left")
                  if stats2: _draw_mixed(c, lx, stats_y - 16, stats2, 9, anchor="left")
      
              # Bottom left info block
              author = self.cfg.get("author", "")
              if author:
                  c.setFillColor(T["ink_faded"]); _draw_mixed(c, lx, 38*mm, author, 10, anchor="left")
              dt = self.cfg.get("date", str(date.today()))
              c.setFillColor(T["ink_faded"]); _draw_mixed(c, lx, 28*mm, dt, 9, anchor="left")
      
              edition = self.cfg.get("edition_line", "")
              if edition:
                  c.setFillColor(T["ink_faded"]); _draw_mixed(c, lx, 20*mm, edition, 7.5, anchor="left")
      
          def _cover_minimal(self, c, T, cx):
              """Minimal cover (Tufte/ink-wash style) — lots of whitespace, no bars."""
              title_y = self.page_h * 0.50
              c.setFillColor(T["ink"])
              btm = _draw_mixed(c, cx, title_y, self.cfg.get("title", "Document"), 32, anchor="center", max_w=self.page_w - 50*mm)
      
              sub = self.cfg.get("subtitle", "")
              sub_segs = self.cfg.get("subtitle_segs")
              if sub_segs:
                  c.setFillColor(T["ink_faded"]); _draw_mixed_segs(c, cx, btm - 36, sub_segs)
              elif sub:
                  c.setFillColor(T["ink_faded"]); _draw_mixed(c, cx, btm - 36, sub, 16, anchor="center")
      
              ver = self.cfg.get("version", "")
              if ver:
                  c.setFillColor(T["ink_faded"]); c.setFont("Sans", 10)
                  c.drawCentredString(cx, btm - 60, ver)
      
              # Simple thin rule
              c.setStrokeColor(T["border"]); c.setLineWidth(0.3)
              c.line(cx - 25*mm, btm - 75, cx + 25*mm, btm - 75)
      
              author = self.cfg.get("author", "")
              if author:
                  c.setFillColor(T["ink_faded"]); _draw_mixed(c, cx, 35*mm, author, 10, anchor="center")
              dt = self.cfg.get("date", str(date.today()))
              c.setFillColor(T["ink_faded"]); _draw_mixed(c, cx, 25*mm, dt, 9, anchor="center")
      
          def _cover_consulting_block(self, c, T, cx):
              """Consulting/research-report cover: top half is a solid navy block
              with white title; bottom half holds a meta strip + author/date.
              Inspired by McKinsey / BCG / Deloitte deep-research reports."""
              # Solid navy hero block — top ~55% of the page
              block_h = self.page_h * 0.55
              block_y = self.page_h - block_h
              c.setFillColor(T["accent"])
              c.rect(0, block_y, self.page_w, block_h, fill=1, stroke=0)
      
              # Title (white) — left-aligned, near bottom of the navy block
              lx = 22*mm
              title = self.cfg.get("title", "Document")
              c.setFillColor(white)
              # Reserve a top spacer inside the block, then place title near bottom
              title_y = block_y + 70*mm
              btm = _draw_mixed(c, lx, title_y, title, 26, anchor="left",
                                max_w=self.page_w - lx - 22*mm)
      
              # Subtitle / meta line (light gray on navy)
              sub = self.cfg.get("subtitle", "")
              sub_segs = self.cfg.get("subtitle_segs")
              meta_y = block_y + 30*mm
              if sub_segs:
                  c.setFillColor(HexColor("#C9D2DD")); _draw_mixed_segs(c, lx, meta_y, sub_segs)
              elif sub:
                  c.setFillColor(HexColor("#C9D2DD"))
                  _draw_mixed(c, lx, meta_y, sub, 11, anchor="left",
                              max_w=self.page_w - lx - 22*mm)
      
              # Date · sources line just above bottom of the block
              dt = self.cfg.get("date", str(date.today()))
              stats = self.cfg.get("stats_line", "")
              bullet = "  \u2022  "
              bottom_meta = dt
              if stats:
                  bottom_meta = f"{dt}{bullet}{stats}"
              c.setFillColor(HexColor("#A8B4C2"))
              _draw_mixed(c, lx, block_y + 14*mm, bottom_meta, 9, anchor="left",
                          max_w=self.page_w - lx - 22*mm)
      
              # ── Below the navy block: light meta-strip with key facts ──
              strip_y = block_y - 18*mm
              c.setFillColor(T["ink"])
      
              # Render up to 4 columns: Mode / Sources / Confidence / Date
              # Source: cfg["stats_line"] formatted as "Mode: Deep · Sources: 28 · ..."
              if stats:
                  # Split on either · or 3+ spaces
                  parts = [p.strip() for p in re.split(r'\s*[\u00b7\u2022]\s*|\s{3,}', stats) if p.strip()]
                  if not parts:
                      parts = [stats]
                  col_w = (self.page_w - 2*lx) / max(1, len(parts))
                  for idx, part in enumerate(parts):
                      cxn = lx + idx * col_w
                      m = re.match(r'^([^::]+)[::]\s*(.*)$', part)
                      if m:
                          label, val = m.group(1), m.group(2)
                          c.setFillColor(T["ink_faded"]); c.setFont("SansBold", 8.5)
                          c.drawString(cxn, strip_y, label.strip().upper())
                          c.setFillColor(T["ink"])
                          _draw_mixed(c, cxn, strip_y - 14, val.strip(), 11, anchor="left")
                      else:
                          c.setFillColor(T["ink"])
                          _draw_mixed(c, cxn, strip_y - 6, part, 10, anchor="left")
      
              # ── Author at bottom-left ──
              author = self.cfg.get("author", "")
              if author:
                  c.setFillColor(T["ink_faded"])
                  _draw_mixed(c, lx, 22*mm, author, 10, anchor="left")
      
          def _frontispiece_page(self, c, doc):
              """Full-page image page after cover."""
              self._configure_viewer(c)
              c.saveState(); self._draw_bg(c)
              fp = self.cfg.get("frontispiece", "")
              if fp and os.path.exists(fp):
                  image_cover = self.cfg.get("image_cover", False)
                  if image_cover:
                      margin = 0
                  else:
                      margin = 18*mm
                  avail_w = self.page_w - 2 * margin
                  avail_h = self.page_h - 2 * margin
                  try:
                      c.drawImage(fp, margin, margin, width=avail_w, height=avail_h,
                                  preserveAspectRatio=not image_cover, anchor='c', mask='auto')
                  except Exception:
                      pass
              c.restoreState()
      
          def _backcover_page(self, c, doc):
              """Back cover with banner branding."""
              c.saveState(); self._draw_bg(c)
              T = self.T; cx = self.page_w / 2
      
              # Top accent
              c.setFillColor(T["accent"])
              c.rect(0, self.page_h - 3*mm, self.page_w, 3*mm, fill=1, stroke=0)
      
              # Banner image — centered
              banner = self.cfg.get("banner", "")
              if banner and os.path.exists(banner):
                  cy = self.page_h / 2
                  banner_w = 150*mm
                  banner_h = banner_w / 2.57
                  banner_x = (self.page_w - banner_w) / 2
                  banner_y = cy - banner_h / 2 + 15*mm
                  try:
                      c.drawImage(banner, banner_x, banner_y, width=banner_w,
                                  height=banner_h, preserveAspectRatio=True, mask='auto')
                  except Exception:
                      pass
      
              # Bottom disclaimer
              disclaimer = self.cfg.get("disclaimer", "")
              if disclaimer:
                  c.setFillColor(T["ink_faded"])
                  _draw_mixed(c, cx, 32*mm, disclaimer, 8.5, anchor="center")
      
              # Copyright
              copyright_text = self.cfg.get("copyright", "")
              if copyright_text:
                  c.setFillColor(T["ink_faded"])
                  _draw_mixed(c, cx, 20*mm, copyright_text, 8.5, anchor="center")
      
              # Bottom accent
              c.setFillColor(T["accent"])
              c.rect(0, 0, self.page_w, 3*mm, fill=1, stroke=0)
      
              c.restoreState()
      
          def _draw_page_decoration(self, c):
              """Draw theme-specific page decorations visible even at thumbnail size."""
              T = self.T; deco = self.L.get("page_decoration", "none")
              if deco == "top-bar":
                  # Thin accent bar at very top of page
                  c.setFillColor(T["accent"])
                  c.rect(0, self.page_h - 2.5*mm, self.page_w, 2.5*mm, fill=1, stroke=0)
              elif deco == "left-stripe":
                  # Thick colored stripe on left edge
                  c.setFillColor(T["accent"])
                  c.rect(0, 0, 5*mm, self.page_h, fill=1, stroke=0)
              elif deco == "side-rule":
                  # Thin vertical rule on left side (Tufte-style margin line)
                  c.setStrokeColor(T["border"]); c.setLineWidth(0.4)
                  c.line(self.lm - 5*mm, self.bm, self.lm - 5*mm, self.page_h - self.tm + 5*mm)
              elif deco == "corner-marks":
                  # Decorative corner brackets
                  c.setStrokeColor(T["accent"]); c.setLineWidth(0.8)
                  m = 12*mm; clen = 12*mm
                  # Top-left
                  c.line(m, self.page_h - m, m + clen, self.page_h - m)
                  c.line(m, self.page_h - m, m, self.page_h - m - clen)
                  # Top-right
                  c.line(self.page_w - m, self.page_h - m, self.page_w - m - clen, self.page_h - m)
                  c.line(self.page_w - m, self.page_h - m, self.page_w - m, self.page_h - m - clen)
                  # Bottom-left
                  c.line(m, m, m + clen, m)
                  c.line(m, m, m, m + clen)
                  # Bottom-right
                  c.line(self.page_w - m, m, self.page_w - m - clen, m)
                  c.line(self.page_w - m, m, self.page_w - m, m + clen)
              elif deco == "top-band":
                  # Wide accent band at top (IEEE-style)
                  c.setFillColor(T["accent"])
                  c.rect(0, self.page_h - 8*mm, self.page_w, 8*mm, fill=1, stroke=0)
                  # White text header inside band
                  header_title = self.cfg.get("header_title", "")
                  if header_title:
                      c.setFillColor(white)
                      _draw_mixed(c, self.lm, self.page_h - 6*mm, header_title, 7.5)
                  doc_title = self.cfg.get("title", "")
                  if doc_title:
                      c.setFillColor(white)
                      _draw_mixed(c, self.page_w - self.rm, self.page_h - 6*mm, doc_title[:40], 7.5, anchor="right")
              elif deco == "double-rule":
                  # Double horizontal rules at top and bottom (elegant book style)
                  c.setStrokeColor(T["accent"]); c.setLineWidth(0.6)
                  y_top = self.page_h - 14*mm
                  c.line(self.lm, y_top, self.page_w - self.rm, y_top)
                  c.line(self.lm, y_top - 2*mm, self.page_w - self.rm, y_top - 2*mm)
                  y_bot = self.bm - 4*mm
                  c.line(self.lm, y_bot, self.page_w - self.rm, y_bot)
                  c.line(self.lm, y_bot + 2*mm, self.page_w - self.rm, y_bot + 2*mm)
      
          def _normal_page(self, c, doc):
              self._configure_viewer(c)
              self._draw_bg(c); pg = c.getPageNumber()
              c.saveState()
              T = self.T; hs = self.L["header_style"]
      
              # Page decoration (drawn first, behind content)
              self._draw_page_decoration(c)
      
              # Watermark
              wm = self.cfg.get("watermark", "")
              if wm:
                  wm_angle = self.cfg.get("wm_angle", 35)
                  wm_opacity = self.cfg.get("wm_opacity")
                  if wm_opacity is not None:
                      wm_color = Color(T["wm_color"].red, T["wm_color"].green, T["wm_c
  • tests
    • test_songti_reading.py 1.4 KB
      #!/usr/bin/env python3
      """Focused regressions for the Songti + sans hierarchy reading theme."""
      
      import importlib.util
      from pathlib import Path
      import unittest
      
      
      SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "md2pdf.py"
      SPEC = importlib.util.spec_from_file_location("any2pdf_md2pdf", SCRIPT)
      MODULE = importlib.util.module_from_spec(SPEC)
      assert SPEC.loader is not None
      SPEC.loader.exec_module(MODULE)
      
      
      class SongtiReadingThemeTests(unittest.TestCase):
          def test_theme_preserves_reading_contract(self):
              theme = MODULE.load_theme("songti-reading")
              layout = theme["layout"]
              self.assertEqual(layout["body_font"], "Serif")
              self.assertEqual(layout["heading_font"], "SansBold")
              self.assertEqual(layout["cjk_bold_style"], "sans")
              self.assertEqual(layout["table_body_font"], "Serif")
              self.assertTrue(layout["continuous_headings"])
              self.assertTrue(layout["fit_width_on_open"])
      
          def test_songti_regular_is_not_black_face(self):
              self.assertEqual(
                  MODULE._FONT_CANDIDATES["CJK"][0],
                  ("/System/Library/Fonts/Supplemental/Songti.ttc", 6),
              )
      
          def test_bold_cjk_and_common_symbols_use_explicit_roles(self):
              wrapped = MODULE._font_wrap("<b>当前状态</b> ✓ ✗ →")
              self.assertIn("name='CJKBold'", wrapped)
              self.assertEqual(wrapped.count("name='Symbols'"), 3)
      
      
      if __name__ == "__main__":
          unittest.main()
      
  • CHANGELOG.md 5.1 KB
    # Changelog
    
    All notable changes to this skill are documented here.
    Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · Versioning: [SemVer](https://semver.org/)
    
    ## [1.5.0] - 2026-08-24
    
    ### Added
    
    - add the shared feedback-classification and approval-invalidation gate used by every LovStudio Skill
    
    ## [1.4.1] - 2026-08-22
    
    ### Fixed
    
    - fix Skill Publisher source validation contract
    - use portable frontmatter and explicit trigger/non-trigger rules
    - replace broken external preview links with a self-contained theme index
    
    ## [1.4.0] - 2026-08-22
    
    ### Added
    
    - add Songti reading theme for long Chinese reports
    - use Songti SC Regular with sans headings and explicit symbol fallback
    - add continuous headings, readable tables, and fit-width opening behavior
    - add focused typography regression tests
    - keep release tags aligned with the version declared across README, SKILL.md, and skill.yaml
    
    ## [1.3.4] - 2026-05-08
    
    ### Added
    
    - Obsidian callout rendering for `> [!NOTE]`, tips, warnings, quotes, and related callout types.
    - Emoji fallback rendering using cached Twemoji images when available, with local emoji-font fallback.
    - LaTeX-style formula handling for inline `$...$` and display `$$...$$` / `\[...\]`; `matplotlib` is optional for rendered formula images.
    - YAML-style frontmatter support for document options such as title, author, theme, watermark, cover, TOC, and watermark settings. Explicit CLI arguments take precedence.
    - Broader Linux font discovery and built-in PDF font fallbacks for lean environments.
    
    ### Fixed
    
    - H5/H6 headings are now consumed as compact subsections instead of falling through parser edge cases.
    - Code blocks preserve mid-line alignment by escaping all spaces, not only leading indentation.
    - Cover-page de-duplication no longer drops body content that appears under the first H1.
    - Full/top-band headers use a stable document title instead of page-lagged chapter state.
    
    ## [1.3.2] - 2026-04-20
    
    ### Changed
    
    - Watermark defaults: single centered mark per page at 0.1 opacity, auto-sized so text width ≈ half page width (previously a tiled grid at theme-dependent opacity with fixed font size 52). Override with `--wm-size / --wm-opacity / --wm-spacing-x / --wm-spacing-y` as before.
    - Back cover banner defaults to the bundled `assets/backcover-banner.jpg` (品牌方 brand banner). Pass `--banner none` to disable, or `--banner <path>` to use your own.
    
    ### Added
    
    - `assets/backcover-banner.jpg` — bundled 品牌方 branding banner so the default works for any user who clones the repo.
    - `.github/workflows/auto-tag.yml` — on push to `main`, CI auto-bumps the patch version in SKILL.md, tags it, and pushes the tag (which triggers the existing `release.yml`).
    
    ## [1.3.1] - 2026-04-20
    
    ### Fixed
    
    - Watermark: when `--wm-spacing-x` and `--wm-spacing-y` are both ≥ 2000, render a single horizontally + vertically centered watermark (with baseline compensation) instead of tiling. Enables large single-mark watermarks (e.g. `--wm-size 200 --wm-spacing-x 9999 --wm-spacing-y 9999`).
    
    ## [1.3.0] - 2026-04-20
    
    ### Added
    
    - New theme `consulting-navy` — McKinsey / BCG / Deloitte deep-research-report aesthetic: navy hero cover block, ALL-CAPS section banners with thick left bar, left page-stripe, white canvas + serif body
    - New `cover_style: consulting-block` — solid navy hero with white title + meta column strip (MODE / SOURCES / CONFIDENCE / DATE)
    - New `heading_decoration: banner` — H2 renders as tinted box with thick accent left bar (inline, no page break)
    - New `SectionBanner` flowable supporting CJK + Latin in section titles
    - Theme picker option `l) 咨询深蓝` and pandoc preset row for the new theme
    
    ### Fixed
    
    - `_preprocess_md` now strips full leading YAML frontmatter (`--- ... ---`); previously only `title:`/`subtitle:`/`author:`/`date:` lines were filtered, so fields like `mode:` / `tagline:` leaked into the body as prose
    
    ## [1.2.0] - 2026-04-13
    
    ### Added
    
    - Add latex-clean as official theme option (k) using pandoc+XeLaTeX engine
    - New theme: latex-clean — pure LaTeX typography, no cover/decoration, clean academic style
    - Pandoc is now a first-class engine choice, not just a fallback
    
    ## [1.1.1] - 2026-04-13
    
    ### Fixed
    
    - Expand pandoc theme presets from link-color-only to full parameter sets
    - Add complete pandoc -V flags table for 7 themes (linkcolor, toccolor, urlcolor, watermark)
    - Add chinese-red full working example from 一滕 project
    
    ## [1.1.0] - 2026-04-13
    
    ### Added
    
    - Add pandoc + XeLaTeX fallback engine for complex documents
    - Document pandoc fallback with watermark, headers/footers, and theme-specific link colors
    - Fallback triggered when reportlab hangs on wide/complex tables
    - Updated skill description to mention dual-engine support
    
    ## [1.0.1] - 2026-04-10
    
    ### Fixed
    
    - fix silent image drops: resolve relative paths against input md dir
    - resolve relative image paths against the input markdown's directory (not cwd)
    - warn on missing images instead of silently dropping them (stderr)
    - collapse multi-line image refs in _preprocess_md so pandoc --wrap=auto output parses correctly
    - SKILL.md: add Input Format section (markdown-only), document pandoc --wrap=none tip
    - README.md: add version badge
    
  • README.md 2.4 KB
    # PDF大师 · PDF Master
    
    ![Version](https://img.shields.io/badge/version-1.5.0-CC785C)
    
    Markdown to professionally typeset PDF with [reportlab](https://docs.reportlab.com/). CJK/Latin mixed text, code blocks, tables, images, Obsidian callouts, emoji fallback, formulas, cover pages, TOC, bookmarks, watermarks, and 16 themes.
    
    Part of [skill-publisher/skills](https://example.com/skills/skills) &mdash; by [example.com](https://example.com)
    
    ## Install
    
    ```bash
    npx skills add any2pdf -g -y
    ```
    
    Requires: Python 3.8+ and `pip install reportlab`
    
    Optional:
    
    ```bash
    pip install matplotlib
    sudo apt install fonts-dejavu-core fonts-liberation fonts-freefont-ttf fonts-noto fonts-noto-cjk fonts-noto-color-emoji
    ```
    
    ## Usage
    
    ```bash
    python md2pdf.py --input report.md --output report.pdf --theme warm-academic
    ```
    
    For long Chinese reports and dense tables, use the publication-style reading preset:
    
    ```bash
    python md2pdf.py --input report.md --output report.pdf --theme songti-reading
    ```
    
    `songti-reading` pairs Songti body text with sans-serif headings/emphasis, Palatino
    Latin prose, Menlo code, roomier leading, symbol fallback, and a fit-width opening view.
    
    You can also keep options in top-of-file frontmatter:
    
    ```markdown
    ---
    title: My Report
    author: Author Name
    theme: warm-academic
    watermark: DRAFT
    ---
    ```
    
    | Option | Default | Description |
    |--------|---------|-------------|
    | `--input` | (required) | Markdown file path |
    | `--output` | `output.pdf` | Output PDF path |
    | `--title` | From H1 | Cover page title |
    | `--subtitle` | | Subtitle |
    | `--author` | | Author name |
    | `--theme` | `warm-academic` | Color theme |
    | `--watermark` | | Watermark text |
    | `--cover` | `true` | Generate cover page |
    | `--toc` | `true` | Generate table of contents |
    | `--frontispiece` | | Full-page image after cover |
    | `--code-max-lines` | `30` | Max lines per code block |
    | `--image-cover` | `false` | Use frontispiece image as full-bleed cover |
    
    ## Themes
    
    `songti-reading` is the recommended preset when sustained Chinese reading matters
    more than compact page count.
    
    | Reading and print | Technical and modern | Specialized |
    |---|---|---|
    | songti-reading | github-light | consulting-navy |
    | warm-academic | nord-frost | ieee-journal |
    | paper-classic | ocean-breeze | elegant-book |
    | classic-thesis | solarized-light | chinese-red |
    | tufte | monokai-warm / dracula-soft | ink-wash |
    
    ## License
    
    MIT
    
  • SKILL.md 19.2 KB
    ---
    name: lov-any2pdf
    description: >
      Convert Markdown to polished PDF with CJK typography, code, tables, images,
      covers, TOC, bookmarks, and reading themes. Trigger on md2pdf, Chinese report
      typesetting, or print-ready PDF requests.
    license: MIT
    compatibility: >
      Requires Python 3.8+ and reportlab (`pip install reportlab`).
      Optional: matplotlib (`pip install matplotlib`) for rendered display formulas.
      macOS: uses Palatino, Songti SC, Menlo (pre-installed).
      Linux: uses DejaVu/Liberation/FreeFont/Noto, Noto CJK, Droid Sans Fallback,
      DejaVu Sans Mono, and Noto Emoji when available.
    metadata:
      author: contributors
      version: "1.5.0"
      tags:
        - markdown
        - pdf
        - cjk
        - reportlab
        - typesetting
    ---
    
    # PDF大师 · PDF Master
    
    This skill converts any Markdown file into a publication-quality PDF using Python's
    reportlab library. It was developed through extensive iteration on real Chinese
    technical reports and solves several hard problems that naive MD→PDF converters
    get wrong.
    
    ## Triggers
    
    ### Activate when
    
    - User wants to convert `.md` → `.pdf`
    - User has a markdown report/document and wants professional typesetting
    - Document contains CJK characters (Chinese/Japanese/Korean) mixed with Latin text
    - Document has fenced code blocks, markdown tables, or nested lists
    - Document has local/remote images, Obsidian callouts, emoji, or math formulas
    - User wants a cover page, table of contents, or watermark in their PDF
    
    ### Do not activate when
    
    - The user only wants to revise Markdown content and does not need a PDF output.
    - The source is HTML, DOCX, or another format and the user has not authorized a Markdown conversion step.
    - The user needs a slide deck, editable Word document, or image export rather than a PDF.
    
    ## Quick Start
    
    ```bash
    python md2pdf/scripts/md2pdf.py \
      --input report.md \
      --output report.pdf \
      --title "My Report" \
      --author "Author Name" \
      --theme warm-academic
    ```
    
    All parameters except `--input` are optional — sensible defaults are applied.
    
    ## Pre-Conversion Options (MANDATORY)
    
    **IMPORTANT: You MUST use the `AskUserQuestion` tool to ask these questions BEFORE
    running the conversion. Do NOT list options as plain text — use the tool so the user
    gets a proper interactive prompt. Ask all options in a SINGLE `AskUserQuestion` call.**
    
    Use `AskUserQuestion` with the following template. The tone should be friendly and
    concise — like a design assistant, not a config form:
    
    ```
    开始转 PDF!先帮你确认几个选项 👇
    
    ━━━ 📐 设计风格 ━━━
     a) 暖学术    — 陶土色调,温润典雅,适合人文/社科报告
     b) 经典论文  — 棕色调,灵感源自 LaTeX classicthesis,适合学术论文
     c) Tufte     — 极简留白,深红点缀,适合数据叙事/技术写作
     d) 期刊蓝    — 藏蓝严谨,灵感源自 IEEE,适合正式发表风格
     e) 精装书    — 咖啡色调,书卷气,适合长篇专著/技术书
     f) 中国红    — 朱红配暖纸,适合中文正式报告/白皮书
     g) 水墨      — 纯灰黑,素雅克制,适合文学/设计类内容
     h) GitHub    — 蓝白极简,程序员熟悉的风格
     i) Nord 冰霜 — 蓝灰北欧风,清爽现代
     j) 海洋      — 青绿色调,清新自然
     k) LaTeX 清爽 — pandoc+XeLaTeX 原生排版,无封面无装饰,干净学术风(需装 pandoc+texlive)
     l) 咨询深蓝  — 深海军蓝色块 + 白底 + 大写左对齐标题,麦肯锡 / BCG / Deloitte 研究报告风格
     m) 宋黑阅读  — 宋体正文、黑体层级、舒展行距与适宽打开,适合中文长报告和密集表格
    
    ━━━ 🖼 扉页图片(封面之后的全页插图) ━━━
     1) 跳过
     2) 我提供本地图片路径
     3) AI 根据内容自动生成一张
    
    ━━━ 💧 水印 ━━━
     1) 不加
     2) 自定义文字(如 "DRAFT"、"内部资料")
        可选调整:字号(默认52)、透明度(0-1)、角度(默认35°)、间距
    
    ━━━ 📇 封底物料(名片/二维码/品牌) ━━━
     1) 跳过
     2) 我提供图片
     3) 纯文字信息
    
    示例回复:"a, 扉页跳过, 水印:仅供学习参考, 封底图片:/path/qr.png"
    直接说人话就行,不用记编号 😄
    ```
    
    ### Mapping User Choices to CLI Args
    
    | Choice | CLI arg |
    |--------|---------|
    | Design style a-m | `--theme` with value from table below (k uses pandoc engine) |
    | Frontispiece local | `--frontispiece <path>` |
    | Frontispiece AI | Generate image first, then `--frontispiece /tmp/frontispiece.png` |
    | Watermark text | `--watermark "文字"` |
    | Watermark style | `--wm-size 30 --wm-opacity 0.1 --wm-angle 45` (all optional) |
    | Image as cover | `--image-cover true` (requires `--frontispiece`) |
    | Back cover image | `--banner <path>` |
    | Back cover text | `--disclaimer "声明"` and/or `--copyright "© 信息"` |
    
    ### Theme Name Mapping
    
    | Choice | `--theme` value | Inspiration |
    |--------|----------------|-------------|
    | a) 暖学术 | `warm-academic` | Skill Publisher design system |
    | b) 经典论文 | `classic-thesis` | LaTeX classicthesis |
    | c) Tufte | `tufte` | Edward Tufte's books |
    | d) 期刊蓝 | `ieee-journal` | IEEE journal format |
    | e) 精装书 | `elegant-book` | LaTeX ElegantBook |
    | f) 中国红 | `chinese-red` | Chinese formal documents |
    | g) 水墨 | `ink-wash` | 水墨画 / ink wash painting |
    | h) GitHub | `github-light` | GitHub Markdown style |
    | i) Nord | `nord-frost` | Nord color scheme |
    | j) 海洋 | `ocean-breeze` | — |
    | k) LaTeX 清爽 | `latex-clean` | pandoc+XeLaTeX 原生排版,无封面 |
    | l) 咨询深蓝 | `consulting-navy` | McKinsey / BCG / Deloitte deep-research report |
    | m) 宋黑阅读 | `songti-reading` | 中文出版物常见的宋体正文 + 黑体标题层级 |
    
    ### Handling AI-Generated Frontispiece
    
    If user chose AI generation: read the document title + first paragraphs, use an
    image generation tool to create a themed illustration matching the chosen design
    style, show for approval, then pass via `--frontispiece /path/to/image.png`
    
    ## Architecture
    
    ```
    Markdown → Preprocess (split merged headings) → Parse (code-fence-aware) → Story (reportlab flowables) → PDF build
    ```
    
    Key components:
    1. **Font system**: Palatino (Latin body), Songti SC Regular (CJK body), selectable serif/sans CJK emphasis, Menlo (code) on macOS; auto-fallback on Linux/Windows
    2. **CJK wrapper**: `_font_wrap()` wraps CJK character runs in `<font>` tags for automatic font switching
    3. **Mixed text renderer**: `_draw_mixed()` handles CJK/Latin mixed text on canvas (cover, headers, footers)
    4. **Code block handler**: `esc_code()` preserves indentation, mid-line alignment, and line breaks in reportlab Paragraphs
    5. **Smart table widths**: Proportional column widths based on content length, with 18mm minimum
    6. **Bookmark system**: `ChapterMark` flowable creates PDF sidebar bookmarks + named anchors
    7. **Heading preprocessor**: `_preprocess_md()` splits merged headings like `# Part## Chapter` into separate lines
    8. **Image handler**: local, relative, `file://`, and remote markdown images are scaled into the body frame with fallback text on errors
    9. **Callout renderer**: Obsidian-style `> [!NOTE]` blocks render as themed boxed callouts
    10. **Formula renderer**: display formulas use optional matplotlib mathtext images, with styled text fallback
    11. **Emoji fallback**: emoji are rendered as cached Twemoji PNGs when available, or with a local emoji font fallback
    
    ## Hard-Won Lessons
    
    ### CJK Characters Rendering as □
    
    reportlab's `Paragraph` only uses the font in ParagraphStyle. If `fontName="Mono"` but
    text contains Chinese, they render as □. **Fix**: Always apply `_font_wrap()` to ALL text
    that might contain CJK, including code blocks.
    
    ### Code Blocks Losing Line Breaks
    
    reportlab treats `\n` as whitespace. **Fix**: `esc_code()` converts `\n` → `<br/>` and
    all spaces → `&nbsp;`, preserving indentation and mid-line alignment before `_font_wrap()`.
    
    ### CJK/Latin Word Wrapping
    
    Default reportlab breaks lines only at spaces, causing ugly splits like "Claude\nCode".
    **Fix**: Set `wordWrap='CJK'` on body/bullet styles to allow breaks at CJK character boundaries.
    
    ### Songti Reading Needs a Sans Hierarchy
    
    Using one sans family everywhere gives dense Chinese reports a uniform dark texture,
    while using Songti everywhere weakens headings and table headers. **Fix**: use the
    `songti-reading` theme for Songti SC Regular body text, Palatino Latin prose,
    sans-serif Chinese headings/emphasis, Menlo code, wider leading, and serif table
    bodies. Keep this as an explicit theme rather than silently changing every report.
    
    On macOS, `Songti.ttc` index 0 is Songti SC Black, not Regular. The readable face is
    index 6. Common report symbols such as `✓`, `✗`, and `→` need an explicit symbol-font
    fallback or they may render as `□` even when the surrounding Chinese is correct.
    
    ### Canvas Text with CJK (Cover/Footer)
    
    `drawString()` / `drawCentredString()` with a Latin font can't render 年/月/日 etc.
    **Fix**: Use `_draw_mixed()` for ALL user-content canvas text (dates, stats, disclaimers).
    
    ### Images Silently Dropped (Relative Paths)
    
    An image reference to `charts/chart_01.png` in a markdown file used to get skipped without warning
    because the image path was resolved against the current working directory, not the
    markdown's directory. **Fix**: `main()` now passes `input_dir` (the .md's directory)
    into the builder, and the image handler resolves relative paths against it. Missing
    images now also emit a `WARN: image not found: ...` to stderr instead of silently
    dropping.
    
    ### Multi-Line Image References (pandoc `--wrap=auto`)
    
    When feeding pandoc's output into md2pdf, pandoc's default `--wrap=auto` (72 cols)
    wraps long image references to paths such as `path.png` across multiple lines, which defeated
    the single-line image regex. **Fix**: `_preprocess_md()` now collapses multi-line
    image references into one line (outside code fences) before parsing.
    
    **Pipeline tip:** If you're piping HTML → markdown via pandoc, use
    `pandoc --wrap=none input.html -o output.md` to avoid wrap-related parsing issues
    for images and tables alike.
    
    ## Input Format
    
    This skill takes **Markdown files only** as input. If you have HTML, DOCX, or
    other formats, convert them to markdown first (e.g. `pandoc --wrap=none`).
    Embedded HTML blocks in markdown are passed through as text — pre-process any
    visual content (charts, complex tables) into plain markdown tables or image
    references before invoking md2pdf.
    
    ## Configuration Reference
    
    Most options can also be set in top-of-file YAML-style frontmatter. Explicit CLI
    arguments take precedence over frontmatter values.
    
    | Argument | Frontmatter Key | Default | Description |
    |----------|----------------|---------|-------------|
    | `--input` | — | (required) | Path to markdown file |
    | `--output` | — | `output.pdf` | Output PDF path |
    | `--title` | `title` | From first H1 | Document title for cover page |
    | `--subtitle` | `subtitle` | `""` | Subtitle text |
    | `--author` | `author` | `""` | Author name |
    | `--date` | `date` | Today | Date string |
    | `--version` | `version` | `""` | Version string for cover |
    | `--watermark` | `watermark` | `""` | Watermark text (empty = none) |
    | `--theme` | `theme` | `warm-academic` | Color theme name |
    | `--theme-file` | — | `""` | Custom theme JSON file path |
    | `--cover` | `cover` | `true` | Generate cover page |
    | `--toc` | `toc` | `true` | Generate table of contents |
    | `--page-size` | `page-size` | `A4` | Page size (A4 or Letter) |
    | `--frontispiece` | `frontispiece` | `""` | Full-page image after cover |
    | `--banner` | `banner` | bundled `assets/backcover-banner.jpg` | Back cover banner image (pass `none` to disable) |
    | `--header-title` | `header-title` | `""` | Report title in page header |
    | `--footer-left` | `footer-left` | author | Brand/author in footer |
    | `--stats-line` | `stats-line` | `""` | Stats on cover |
    | `--stats-line2` | `stats-line2` | `""` | Second stats line |
    | `--edition-line` | `edition-line` | `""` | Edition line at cover bottom |
    | `--disclaimer` | `disclaimer` | `""` | Back cover disclaimer |
    | `--copyright` | `copyright` | `""` | Back cover copyright |
    | `--code-max-lines` | `code-max-lines` | `30` | Max lines per code block |
    | `--image-cover` | `image-cover` | `false` | Use frontispiece as full-bleed cover (page 1), text cover becomes page 2 |
    | `--heading-top-spacer` | `heading-top-spacer` | `5` | Top spacer before H1/H2 chapter titles in mm |
    | `--wm-size` | `wm-size` | auto (half page width) | Watermark font size; auto-scales so text width ≈ 50% of page width |
    | `--wm-opacity` | `wm-opacity` | `0.1` | Watermark opacity (0.0–1.0) |
    | `--wm-angle` | `wm-angle` | `35` | Watermark rotation angle in degrees |
    | `--wm-spacing-x` | `wm-spacing-x` | `9999` | Watermark horizontal spacing in pt (≥2000 = single centered per page) |
    | `--wm-spacing-y` | `wm-spacing-y` | `9999` | Watermark vertical spacing in pt (≥2000 = single centered per page) |
    
    ## Themes
    
    Available: `songti-reading`, `warm-academic`, `nord-frost`, `github-light`,
    `solarized-light`, `paper-classic`, `ocean-breeze`, `consulting-navy`.
    
    Each theme defines: page background, ink color, accent color, faded text, border, code background, watermark tint.
    
    ## Fallback: pandoc + XeLaTeX
    
    When reportlab fails (e.g. wide/complex tables cause infinite loops in table width
    calculation), fall back to pandoc with XeLaTeX. This produces high-quality CJK PDFs
    with proper table handling.
    
    ### When to use pandoc engine
    
    - User chose `latex-clean` theme (k) — pandoc is the primary engine, not a fallback
    - Document has many wide multi-column tables (reportlab's table layout may hang)
    - Document needs LaTeX-quality typesetting (justified text, hyphenation)
    - reportlab md2pdf.py hangs or crashes on the input
    
    ### Basic command
    
        pandoc input.md -o output.pdf \
          --pdf-engine=xelatex \
          -V CJKmainfont="Songti SC" -V mainfont="Palatino" -V monofont="Menlo" \
          -V geometry:margin=2.5cm -V fontsize=11pt \
          --toc -V toc-title="目录" -V documentclass=article
    
    ### Adding watermark + headers/footers
    
        pandoc input.md -o output.pdf \
          --pdf-engine=xelatex \
          -V CJKmainfont="Songti SC" -V mainfont="Palatino" -V monofont="Menlo" \
          -V geometry:margin=2.5cm -V fontsize=11pt \
          -V colorlinks=true -V linkcolor=red -V toccolor=red -V urlcolor=red \
          --toc -V toc-title="目录" -V documentclass=article \
          -V header-includes='
        \usepackage{fancyhdr}
        \pagestyle{fancy}
        \fancyhf{}
        \fancyhead[L]{\small 页眉左侧文字}
        \fancyhead[R]{\small 页眉右侧文字}
        \fancyfoot[C]{\thepage}
        \usepackage{draftwatermark}
        \SetWatermarkText{水印文字}
        \SetWatermarkScale{0.5}
        \SetWatermarkColor[gray]{0.9}
        '
    
    ### Pandoc theme presets
    
    Each preset defines a complete set of pandoc `-V` flags. Use the full command from
    "Adding watermark + headers/footers" above, replacing the color/link flags per preset.
    
    | Theme | linkcolor | toccolor | urlcolor | Watermark color | Notes |
    |-------|-----------|----------|----------|-----------------|-------|
    | chinese-red | `red` | `red` | `red` | `[gray]{0.9}` | 朱红正式,适合政企报告、白皮书 |
    | warm-academic | `brown` | `brown` | `brown` | `[gray]{0.9}` | 陶土色调,温润学术风 |
    | classic-thesis | `brown` | `brown` | `brown` | `[gray]{0.85}` | LaTeX classicthesis 灵感 |
    | ieee-journal | `blue` | `blue` | `blue` | `[gray]{0.9}` | 藏蓝严谨,期刊风格 |
    | github-light | `blue` | `blue` | `blue` | `[gray]{0.92}` | 极简蓝白,程序员友好 |
    | ink-wash | `black` | `black` | `black` | `[gray]{0.92}` | 水墨素雅,文学/设计类 |
    | nord-frost | `teal` | `teal` | `teal` | `[gray]{0.9}` | 北欧冰霜蓝灰 |
    | consulting-navy | `blue` | `blue` | `blue` | `[gray]{0.88}` | 咨询深蓝,研究报告风 |
    
    #### chinese-red 完整示例(本次一滕项目实际使用)
    
        pandoc input.md -o output.pdf \
          --pdf-engine=xelatex \
          -V CJKmainfont="Songti SC" -V mainfont="Palatino" -V monofont="Menlo" \
          -V geometry:margin=2.5cm -V fontsize=11pt \
          -V colorlinks=true -V linkcolor=red -V toccolor=red -V urlcolor=red \
          --toc -V toc-title="目录" -V documentclass=article \
          -V header-includes='
        \usepackage{fancyhdr}
        \pagestyle{fancy}
        \fancyhf{}
        \fancyhead[L]{\small 报告标题 | 品牌名}
        \fancyhead[R]{\small 商业机密}
        \fancyfoot[C]{\thepage}
        \usepackage{draftwatermark}
        \SetWatermarkText{商业机密}
        \SetWatermarkScale{0.5}
        \SetWatermarkColor[gray]{0.9}
        '
    
    #### latex-clean 完整示例(最简洁的 LaTeX 学术风格)
    
    选择 `latex-clean` 时,**跳过 reportlab**,直接使用 pandoc 生成。特点:无封面、无扉页、
    无装饰色块,纯 LaTeX 排版 + 点线 TOC 引导符,适合内容为王的学术/技术文档。
    
        pandoc input.md -o output.pdf \
          --pdf-engine=xelatex \
          -V CJKmainfont="Songti SC" -V mainfont="Palatino" -V monofont="Menlo" \
          -V geometry:margin=2.5cm -V fontsize=11pt \
          -V colorlinks=true -V linkcolor=brown -V toccolor=brown -V urlcolor=brown \
          --toc -V toc-title="目录" -V documentclass=article
    
    如需添加页眉页脚或水印,参考上方 "Adding watermark + headers/footers" 追加 `-V header-includes`。
    
    ### Known limitations (pandoc fallback)
    
    - No cover page (pandoc article class has no built-in cover — use `--include-before-body` with a LaTeX snippet if needed)
    - Frontispiece/back cover not supported (use md2pdf.py for these)
    - `→` `★` `☆` symbols may warn in Palatino — they render via CJK font fallback, safe to ignore
    - ASCII art diagrams render as code blocks (same as md2pdf.py)
    
    ### Dependencies (pandoc fallback)
    
    Requires `pandoc` and a TeX distribution with XeLaTeX:
    
        brew install pandoc
        brew install --cask mactex-no-gui   # or basictex
    
    ## Dependencies
    
    ```bash
    pip install reportlab --break-system-packages
    # Optional formula rendering:
    pip install matplotlib --break-system-packages
    ```
    
    Recommended Ubuntu/Debian fonts:
    
    ```bash
    sudo apt install fonts-dejavu-core fonts-liberation fonts-freefont-ttf fonts-noto fonts-noto-cjk fonts-noto-color-emoji
    ```
    
    ## Runtime context (shared)
    
    运行前读取本 Skill 包的 `skill.yaml`,由宿主提供 `skill-runtime/v1` 上下文。字段解析顺序为:当前请求、项目上下文、个人 Preferences、品牌 Profile、通用默认值。
    
    - 只使用 Manifest 声明的字段;Profile 保存公开品牌事实,Preferences 保存个人工作偏好。
    - `required: true` 字段缺失时,按 Manifest 的问题配置向用户提出一个聚焦问题;用户明确同意后再保存回答。
    - 报错提供可复制的 `context_id`、字段路径与来源,诊断内容避开秘密、完整私人路径和原始配置。
    
    ## 通用反馈闭环
    
    用户在 Skill 驱动任务中提出修改意见时,继续当前产物前必须执行:
    
    1. 先判断意见是 `task-specific`(仅本次)还是 `reusable`(可跨任务复用)。
    2. `task-specific` 只修改当前任务,不改 Skill。
    3. `reusable` 先确定作用域:领域规则先更新对应 canonical Skill;适用于所有 Skill 的规则先更新共享规范。
    4. 完成规则更新、版本、lint 与分发核验后,再把修改应用到当前任务。
    5. `reusable` 修改会使此前的“确认”“继续”“发吧”失效;完成当前产物修改和回读后必须停下,等待用户下一步指示,不自动进入发布、提交或其他外部写入。
    
  • skill.yaml 828 B
    schema: skill-manifest/v1
    id: lov-any2pdf
    version: "1.5.0"
    runtime: skill-runtime/v1
    context:
      profile:
        fields:
        - path: identity.name
          required: true
          question: 如果本次输出需要品牌身份,请提供品牌名称。
        - path: identity.logo
          required: false
          question: 如果需要使用品牌 Logo,请提供 Logo 地址或文件路径。
        - path: brand.tone
          required: false
          question: 如果已有品牌语气或审美关键词,请提供它们。
      preferences:
        namespace: lov_any2pdf
        fields:
        - path: user.language
          required: false
          question: 希望使用哪种语言输出?
        - path: user.timezone
          required: false
          question: 需要使用哪个时区处理日期和时间?
      interaction:
        ask_missing: true
        max_questions: 1
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related