Install
$ agentstack add skill-vlebert-markdown-to-pdf-agent-skill-markdown-to-pdf ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
markdown-to-pdf
LaTeX-free PDF generation pipeline:
note.md ──[Pandoc + Lua filters]──▶ HTML ──[WeasyPrint + CSS]──▶ PDF
Pandoc converts the content (Markdown → HTML) and injects the frontmatter into the template. WeasyPrint handles layout exclusively: pagination, headers/footers, cover page, styles. Everything is bundled in this skill (template, Lua filters, CSS, Docker image): no external dependency.
The skill stays agnostic: standard Markdown converts cleanly, and the Obsidian transformations are only applied when the corresponding syntax is present.
1. Prerequisite: the Docker image
Before generating, make sure the image exists — do not rely on session memory (each session starts cold). The source of truth is Docker:
bash scripts/build-image.sh
This script is idempotent: it runs docker image inspect markdown-to-pdf:latest and returns immediately if the image already exists (it does not rebuild). It only builds (a few minutes) if the image is missing. Running it systematically before a generation is therefore safe and nearly free.
Manual equivalent to decide: docker image inspect markdown-to-pdf:latest (exit 0 = present, nothing to do; exit ≠ 0 = needs building).
When to rebuild with --force: only if the Dockerfile changes (WeasyPrint version, fonts). Templates, CSS and Lua filters are mounted at runtime (-v …:/skill), so modifying them requires no rebuild.
The image produces markdown-to-pdf:latest (Pandoc + WeasyPrint + fonts: system Arial/Verdana/… + Google/open-source Inter, Roboto, Montserrat, etc.).
2. Prepare the frontmatter BEFORE generating
Inspect the note's YAML frontmatter. The keys feed the template; an incomplete frontmatter yields a degraded PDF (no cover page, empty headers).
If useful keys are missing, add them to the note (without overwriting what's there):
- at minimum
title; - as needed:
subtitle,author,date,lang; - for a cover page / table of contents:
titlepage,titlepage-logo,
logo-width, titlepage-rule-color, toc, toc-depth, toc-title.
Infer values from the content and context when obvious (e.g. title = H1 heading or file name, date = today). Ask the user for confirmation when in doubt rather than guessing silently: whether a cover page is wanted, which logo to use, author, subtitle. See the full key table in references/weasyprint-notes.md.
3. Choose the Lua filters based on content
Only add --lua-filter for syntax actually present in the note (preserves agnosticity — pure Markdown undergoes no Obsidian transformation):
| Filter | Add if the note contains… | |---|---| | assets/lua/callouts.lua | callouts > [!TYPE] | | assets/lua/embeds.lua | image embeds ![[image.png]], ![[image.png\|200]], ![[image.png\|Caption\|200]] | | assets/lua/mdlinks.lua | text wikilinks [[note]] / [[note\|alias]] | | assets/lua/images.lua | standard Markdown images with width in the caption ` | | assets/lua/headers_shift.lua` | the "H1 = title" convention to shift (H2→H1, etc.) — otherwise do NOT add it |
Detect quickly via grep on the note (e.g. grep -E '> \[!' note.md for callouts, grep -E '!\[\[' note.md for image embeds, grep -E '\[\[' note.md for wikilinks).
Order matters: load embeds.lua BEFORE mdlinks.lua. embeds.lua converts ![[image]] into images; otherwise mdlinks.lua would see the inner [[…]] and treat it as a text link.
embeds.lua resolves the image path itself by searching for it by name under the mounted folder (see step 5), including in any subfolder: it reproduces Obsidian's behavior (finding an attachment wherever it is) and makes --resource-path unnecessary for these embeds, regardless of the attachments convention. The segments after | are classified automatically: number → width (px), WxH → width+height, text → caption. An image alone in its paragraph with a caption becomes a ` with (styled in the template's CSS). mdlinks.lua` renders text wikilinks as a plain label (alias if present, otherwise the note name) — no dead link in the PDF.
4. Choose the PDF location
- Default (agnostic): next to the source
.mdfile. - Obsidian vault detected (a
.obsidian/folder exists when walking up the
tree from the note): place the PDF in the attachments folder used by the note — infer it from the note's existing embeds (e.g. _attachments, attachments/). If relevant, insert a wikilink embed/link to the PDF in the source note (![[my-doc.pdf]]). "If relevant": judge by context, ask when in doubt.
5. Generate the PDF
Mount two volumes: the root folder (for the note AND its attachments) and the skill folder (template/CSS/filters). Choose the mounted root based on step 4:
- Obsidian vault: mount the vault root on
/dataand reference the note
by its relative path (e.g. projects//quote.md). This way embeds.lua can find any attachment in the vault, regardless of its storage convention;
- outside a vault: mount the folder containing the note (and its images).
SKILL_DIR="/path/to/this-skill" # this skill's folder (markdown-to-pdf)
ROOT_DIR="/path/to/root" # vault root, or the note's folder
NOTE="projects/.../note.md" # note path RELATIVE to ROOT_DIR
docker run --rm \
-v "$ROOT_DIR:/data" \
-v "$SKILL_DIR:/skill:ro" \
--workdir /data \
--user "$(id -u):$(id -g)" \
markdown-to-pdf:latest \
"$NOTE" \
--from markdown+lists_without_preceding_blankline \
--pdf-engine=weasyprint \
--template=/skill/assets/templates/document/template.html \
--css=/skill/assets/templates/document/style.css \
--lua-filter=/skill/assets/lua/callouts.lua \
--lua-filter=/skill/assets/lua/embeds.lua \
--lua-filter=/skill/assets/lua/mdlinks.lua \
--number-sections \
--toc --toc-depth=3 \
--embed-resources \
-o "projects/.../note.pdf"
Notes:
- Only include the
--lua-filterflags selected in step 3, in order (callouts,
embeds, mdlinks, images, headers_shift).
- No need for
--resource-pathfor![[…]]embeds:embeds.luaresolves the
path by searching. Only add it for standard Markdown images `` whose path is not resolved otherwise.
- Heading numbering:
--number-sectionsis enabled by default (headings1,
2, 2.1…). Pandoc does not read this from the frontmatter; it's a CLI flag. Remove it for unnumbered headings (see step 6 for adjusting).
- Table of contents: add
--toc(+--toc-depth=N) if the note wants a
table of contents; omit it otherwise. The title/depth can also come from the frontmatter (toc-title, toc-depth) via the template.
--embed-resourcesinlines images and CSS into the intermediate HTML
(self-contained PDF).
- The warning
Ticker: poll failed: Interrupted system callis benign.
6. After generation: offer adjustments
Once the PDF is produced, don't stop there: present the result then ask the user whether anything needs adjusting, explicitly listing the available levers (they may not know them). If an adjustment is requested, change the relevant flag/key and regenerate.
Levers to offer:
- Heading numbering — enabled by default; remove
--number-sectionsfor
headings without numbers.
- Table of contents — presence (
--toc), depth (--toc-depth=N), title
(toc-title in the frontmatter).
- Cover page — enable/disable (
titlepage), logo (titlepage-logo,
logo-width), accent color (titlepage-rule-color).
- Template / visual identity — switch template (
--template+--css), hence
font, colors, margins (see step 7).
- Header/footer metadata —
title,author,date,lang(frontmatter).
For frontmatter-driven levers, edit the note; for CLI flags (numbering, toc), adjust the command and regenerate.
7. Create a new template
A template = a visual identity, i.e. a pair template.html + style.css. The Lua filters are shared (generic Obsidian transformations) and need not be duplicated.
For a new template:
- Copy
assets/templates/document/toassets/templates//. - Adapt
template.html: cover page structure, running elements
(headers/footers), exposed $key$ frontmatter variables.
- Adapt
style.css: colors (--accent…), fonts,@pagemargins,
callout/table styles. Many Google/open-source fonts are bundled in the image (Inter, Roboto, Montserrat, Merriweather, JetBrains Mono…): usable directly by name in font-family. Full list and how to add new fonts: references/weasyprint-notes.md.
- Point
--templateand--cssto the new folder at generation time.
Remember the template for this project. A custom template only has value if it gets reused. Since each session starts cold, after creating it write the association in the CLAUDE.md (or AGENTS.md) at the root of the user's vault/site — not in this repo: absolute path of the template folder, and the associated options (cover page, toc, logo). Example: "PDF export: template …/assets/templates/acme/, titlepage enabled, logo assets/logo.png". If it doesn't exist, create it. This way a future session reuses the right template without asking again.
The separation of content (Pandoc + Lua) and layout (template + CSS) is the guiding principle: appearance is changed without touching conversion. For layout pitfalls (running elements, cover page, tables, WeasyPrint 63 limitations), see references/weasyprint-notes.md.
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: vlebert
- Source: vlebert/markdown-to-pdf-agent-skill
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.