Contributing¶
Any contribution is welcome! This document provides guidelines for contributing to the obsidian-pdf-exporter project.
Table of Contents¶
- Getting Started
- Development Workflow
- Architecture
- Adding support for an Obsidian plugin
- Adding a PDF template
- Patches for WeasyPrint
- Testing
- Code Quality
- Documentation
- Release Process
- Pull Request Guidelines
Getting Started¶
Prerequisites¶
- Python 3.10 or higher
- Git
uv(Python package manager)jq(for JSON processing)
Install jq¶
Install uv¶
Following the uv installation guide:
Add shell completion (optional):
Project Setup¶
- Fork and Clone the Repository
- Install Dependencies
This will: - Create a virtual environment - Install all dependencies (including development dependencies via dependency groups) - Install the project in editable mode
- Verify Installation
Development Workflow¶
Running the Application¶
# Run with uv (recommended)
uv run obsidian-pdf-exporter [commands]
uv run ope [commands]
# Or activate the virtual environment
source .venv/bin/activate
obsidian-pdf-exporter [commands]
Adding Dependencies¶
# Add runtime dependency
uv add package-name
# Add development dependency (to dev group)
uv add --group dev package-name
# Add to custom dependency group
uv add --group group-name package-name
Updating Dependencies¶
# Update all dependencies
uv sync --upgrade
# Update specific dependency
uv sync --upgrade-package package-name
Architecture¶
An export reads a folder of notes and turns it into one PDF. The work is split into three layers. Each layer has one place where its parts are listed.
- Markdown dialect (
core/dialect.py). The pandoc reader extensions and pandoc arguments. The base is pandoc'smarkdownreader withraw_htmlandlists_without_preceding_blanklineon andraw_texoff. Steps add what they need, for examplemarkfor highlights,hard_line_breaksfor line breaks or--mathmlfor math. - Obsidian core syntax (
core/steps/). Every piece of syntax that Obsidian itself supports is a small step: frontmatter, comments, Bases, PDF embeds, embeds, stray HTML tags, block IDs, callouts, heading links, Markdown images, wiki links, image captions, headings, warnings about plugin code blocks, highlights, line breaks, tasks, math, footnotes, Mermaid, landscape diagrams, SVG images and table fixes. All steps are listed inCORE_STEPSincore/steps/__init__.py. - Community plugins (
plugins/). Support for syntax from community Obsidian plugins and other tools: Folder Notes, Dataview, Excalidraw, Meta Bind, Block Link Plus, draw.io and pandoc citations. Plugins are found through an entry point, listed bylist-pluginsand can be turned off with--disable-plugin. They run inside thepluginsstep of layer 2.
obsidian_pdf_exporter/
├── core/
│ ├── exporter.py # export and redline: tree → markdown → HTML → PDF
│ ├── redline.py # redline: the block and word diff of two assembled documents
│ ├── redline_html.py # redline: summary, TOC marks, moves, change groups, styles
│ ├── redline_side_by_side.py, redline_collapse.py # the side-by-side style, collapse
│ ├── blame.py # --blame: match blocks to git blame, the strip next to the page
│ ├── htmltree.py # parse and write the HTML as a tree (lxml)
│ ├── build_files.py # copy images into the build folder, named by their content
│ ├── assets/ # CSS of the redline marks, side by side and the blame strip
│ ├── assemble.py # runs the markdown hooks on every page
│ ├── pipeline.py # the loops over the steps, one per stage
│ ├── dialect.py # layer 1: pandoc reader extensions and arguments
│ ├── pandoc.py # runs pandoc with a dialect
│ ├── steps/ # layer 2: one module per piece of core syntax
│ │ ├── __init__.py # CORE_STEPS, the ordered list of steps
│ │ ├── base.py # Step, PageContext, DocumentContext
│ │ ├── frontmatter.py, plugins.py, comments.py, bases.py, embeds.py, raw_html.py,
│ │ ├── block_ids.py, callouts.py, heading_links.py, wiki_links.py, image_captions.py,
│ │ ├── headings.py, code_blocks.py, highlights.py, line_breaks.py, tasks.py, math.py,
│ │ ├── footnotes.py, landscape.py, pdf_embeds.py,
│ │ └── mermaid.py, svg_images.py, tables.py, base_css.py
│ ├── bases/ # Bases: expression parser, evaluator and views
│ ├── code.py # outside_code: keeps fenced and inline code as written
│ └── embeds.py, callouts.py, wiki_links.py, mermaid.py, ... # the work behind the steps
├── compat/ # patches for WeasyPrint, until a release has the fix
├── plugins/ # layer 3
│ ├── base.py # ObsidianPlugin, PluginContext, DocumentContext
│ ├── registry.py # entry-point + runtime discovery
│ ├── folder_notes/, dataview/, meta_bind/
│ ├── excalidraw/ # Excalidraw drawings drawn as SVG
│ ├── block_link_plus/ # ![[Note#^id-id]] ranges
│ ├── drawio/ # draw.io SVG labels
│ └── citations/ # [@key] citations with pandoc citeproc
└── templates/ # PDF templates (CSS, header, footer)
Stages¶
A step overrides the hooks it needs:
| Hook | Runs on | Order |
|---|---|---|
process_markdown |
each page, and embedded notes if embedded = True |
priority |
process_document |
the assembled markdown of the whole document | priority |
reader_extensions, pandoc_args |
builds the dialect for pandoc | priority |
process_html |
the HTML that pandoc wrote | html_priority (defaults to priority) |
An export runs them in this order: the markdown hooks on every page, the
document hooks, the template's process_markdown, pandoc, the HTML hooks,
the template's process_html, and WeasyPrint. With --debug, the build
folder keeps assembled.md (what pandoc reads) and final.html (what
WeasyPrint reads).
The order of the markdown steps matters. For example, embeds runs after
html_comments, so an embed inside an HTML comment is not expanded. It
runs before block_ids, so the block IDs in embedded text are removed
too. bracket_links runs after wiki_links, so real wiki links are
already resolved. The table in the docstring of
core/steps/__init__.py
gives the reason for each position.
Embedded notes (![[Note]]) run through the steps marked embedded
(plugins, html_comments, bases, pdf_embeds, embeds, footnotes and wiki_links) with the context of
the embedded note, so links and images resolve from its folder. The
embedded text then becomes part of the page and passes the remaining steps.
Adding a core syntax step¶
Add a step only for syntax that Obsidian supports without a community plugin. Everything else is a plugin (see below).
- Put the step in a new module under
core/steps/. Keep it thin and put larger parsing code in a module of its own undercore/:
from obsidian_pdf_exporter.core.steps.base import PageContext
from obsidian_pdf_exporter.core.steps.base import Step
class SubscriptStep(Step):
name = "subscript"
description = "Render ~sub~ as subscript"
priority = 115 # after image_captions (110), before title_heading (120)
def reader_extensions(self, context):
return ("subscript",)
def process_markdown(self, content: str, context: PageContext) -> str:
return content
- Add the class to
CORE_STEPSincore/steps/__init__.py, at the position of its priority, and to the table in the module docstring. - Leave code alone. Obsidian shows fenced code blocks and inline code
as written, so wrap the rewrite in
outside_codefromcore/code.py. - Never drop content silently. When the step sees syntax it can not
render, it keeps it in a simple form and reports it with
context.warn(...), which prints a line starting withWarning:. - Add tests.
test_steps.pychecks the order of all steps, so update the expected lists there. - Describe the syntax in the docs (see Documentation).
Adding support for an Obsidian plugin¶
Obsidian plugins (Dataview, Meta Bind, Tasks, Templater, Excalidraw …)
introduce non-core syntax into markdown notes. To make those notes export
cleanly, the project carries one plugin support module per Obsidian
plugin. They all live under obsidian_pdf_exporter/plugins/,
one subpackage per plugin, so contributors have an obvious place to add a
new one.
File layout¶
obsidian_pdf_exporter/plugins/
├── base.py # ObsidianPlugin base class + PluginContext, DocumentContext
├── registry.py # entry-point + runtime discovery
├── dataview/
│ ├── __init__.py # re-exports DataviewPlugin
│ ├── plugin.py # the plugin class (thin)
│ ├── parse.py # DQL grammar, on parsimmon.py
│ ├── index.py # pages, fields, tasks of the vault
│ ├── engine.py # query execution
│ ├── render.py # Markdown output of the views
│ └── js/ # DataviewJS: dv API, Luxon, embedded V8
├── drawio/
│ ├── __init__.py
│ ├── plugin.py
│ └── svg.py # draw.io label conversion
└── meta_bind/
├── __init__.py
└── plugin.py
Keep the plugin class itself thin and put any non-trivial parsing, indexing or rendering in sibling modules so it stays easy to test in isolation. The Dataview package is the canonical example.
The currently bundled plugins are:
| Plugin | What it covers |
|---|---|
folder_notes |
Map Folder/Folder.md onto its containing folder |
dataview |
Run Dataview queries, inline fields and DataviewJS |
excalidraw |
Turn embedded Excalidraw drawings into images |
meta_bind |
Resolve Meta Bind VIEW widgets, strip INPUT/BUTTON |
block_link_plus |
Resolve ![[Note#^id-id]] range embeds |
drawio |
Turn draw.io HTML labels in SVG images into SVG text |
citations |
Render [@key] citations with pandoc --citeproc |
Walkthrough — adding a new plugin¶
Suppose you want to add support for the Tasks plugin so its
- [ ] do thing 📅 2026-05-10 syntax renders as a clean checkbox list.
1. Create the package¶
__init__.py:
"""Support for the Obsidian `Tasks` plugin."""
from obsidian_pdf_exporter.plugins.tasks.plugin import TasksPlugin
__all__ = ["TasksPlugin"]
2. Implement the plugin class¶
Every plugin subclasses
ObsidianPlugin and overrides
process_markdown(content, context). context is a
PluginContext carrying the page's vault root, page folder,
frontmatter, and the user's --option key=value settings.
plugin.py:
from __future__ import annotations
import re
from obsidian_pdf_exporter.plugins.base import ObsidianPlugin
from obsidian_pdf_exporter.plugins.base import PluginContext
_TASK_LINE = re.compile(r"^(\s*-\s\[)([ x/\-])(\]\s.+)$", re.MULTILINE)
class TasksPlugin(ObsidianPlugin):
"""Strip Tasks emoji-suffixes (📅, ⏫, …) and normalise checkbox state."""
name = "tasks"
description = "Render Tasks-plugin checkboxes without emoji metadata"
priority = 50 # runs after dataview (10) and meta_bind (20)
def process_markdown(self, content: str, context: PluginContext) -> str:
# Drop the trailing emoji-tagged metadata that Tasks renders.
content = re.sub(r"\s+[⏩-⏺✅❌\U0001F4C5][^\n]*", "", content)
return _TASK_LINE.sub(lambda m: m.group(1) + m.group(2).lower() + m.group(3), content)
The pipeline guarantees:
- Frontmatter is already parsed and stripped before your plugin runs.
- Plugins run before embeds, callouts, wiki link resolution and image promotion, so anything your plugin produces flows through the rest of the pipeline like authored markdown would.
- Plugins also run on the content of embedded notes, with the context of the embedded note.
context.vault_rootmay beNonefor ad-hoc exports outside a git repository — gracefully no-op in that case if you need vault-wide data (see the Dataview plugin for the pattern).
3. Register it via an entry point¶
Add the new class to pyproject.toml:
[project.entry-points."obsidian_pdf_exporter.obsidian_plugins"]
dataview = "obsidian_pdf_exporter.plugins.dataview:DataviewPlugin"
meta_bind = "obsidian_pdf_exporter.plugins.meta_bind:MetaBindPlugin"
tasks = "obsidian_pdf_exporter.plugins.tasks:TasksPlugin"
Re-install the project in editable mode so the entry point is picked up:
Verify:
4. Add unit tests¶
Drop a file in tests/unit/ that imports your plugin directly and feeds
it a few representative markdown snippets. Mirror the style in
test_plugins.py.
5. Document it¶
Add a page under docs/plugins/, an example note under examples/ and
its screenshot (see Documentation), and list the plugin
in the table above.
Plugin priority¶
priority is an integer — lower runs earlier. The built-ins use:
| Plugin | Priority |
|---|---|
folder_notes |
5 |
dataview |
10 |
excalidraw |
15 |
meta_bind |
20 |
block_link_plus |
30 |
drawio |
40 |
citations |
50 |
Pick a value that respects your dependencies (e.g. if your plugin substitutes frontmatter fields like Meta Bind, run after Dataview so any tables it generated are visible).
Hooks¶
A plugin can override any of these methods on
ObsidianPlugin. All have
no-op defaults so you only implement what you need. A plugin that only
implements process_markdown keeps working:
| Hook | Stage | Used by |
|---|---|---|
find_folder_note |
Tree building | folder_notes |
process_markdown |
Per-page markdown rewrite | dataview, meta_bind, excalidraw |
extract_block |
Resolving ![[Note#^id]] embeds |
block_link_plus |
process_document |
Assembled markdown of the whole document | citations |
reader_extensions, pandoc_args |
Building the pandoc call (after process_document) |
citations |
process_svg |
Inlining SVG images into the HTML | drawio |
process_html |
HTML of the whole document | none yet |
find_folder_note(folder) returns the markdown file that represents a
folder (or None). Plugins are consulted in priority order; the
first non-None result wins. Use this when the Obsidian plugin you are
mirroring affects which file is the "page" for a folder. Examples:
- The bundled
folder_notesplugin returnsFolder/Folder.md. - A
readme_folder_notesplugin could returnFolder/README.md. - A frontmatter-driven plugin could return whichever file in the
folder has
folder-note: true.
process_markdown(content, context) runs after frontmatter has been
stripped and before callouts / wiki links / image promotion. The
context dataclass exposes vault_root, page_folder,
frontmatter and the user's --option key=value settings.
extract_block(body, block_id) returns the text that ![[Note#^block_id]]
selects in the note body, or None. Plugins are asked in priority
order before core looks for a block marked ^block_id.
process_document(markdown, context) gets the assembled markdown of the
whole document, after the markdown hook ran on every page. The plugin can
then return pandoc reader extensions and arguments from
reader_extensions(context) and pandoc_args(context), for example
--citeproc when it found citations.
process_svg(svg, context) rewrites each SVG image while it is inlined
into the HTML. process_html(html, context) gets the HTML of the whole
document after the core HTML steps and before the template. Both get a
DocumentContext with the build folder, the options, the export
metadata and the template.
Disabling and configuring plugins¶
Users can opt out of a single plugin per export with
--disable-plugin <name> (repeatable). Free-form configuration is
forwarded through the existing --option key=value CLI flag and
exposed to every hook via context.options.
Third-party plugins¶
Plugins are not required to live in this repository. Anyone can publish
a separate Python distribution that registers a plugin under the same
obsidian_pdf_exporter.obsidian_plugins entry point group; installing
it makes it instantly available to obsidian-pdf-exporter without
code changes here. Only add a plugin to this repository if it covers a
broadly used Obsidian plugin and is not better maintained as a separate
package.
Adding a PDF template¶
PDF appearance (layout, header, footer, colour palette) is provided by templates, a separate extension point that lives next to the plugin system but is not the same thing. Templates plug into rendering, plugins plug into markdown.
End users do not need to touch this repository to ship a custom
look. Templates and styling in the docs covers the
no-Python options — drop-in CSS file, template directory with a
template.yaml manifest, or a named directory under
~/.config/obsidian-pdf-exporter/templates/. Reach for the steps below
only when your template needs to mutate markdown or HTML (i.e. requires
Python code).
Templates that ship inside this project live under
obsidian_pdf_exporter/templates/builtin/;
each one ships a Template subclass plus its CSS file. To add one:
- Drop a new package next to
defaultandredline. - Subclass
Templateand overrideget_css()(return CSS source) and optionallydecorations(ctx)to inject running header/footer HTML and@pagerules. - Register the class under the
obsidian_pdf_exporter.templatesentry point group inpyproject.tomland reinstall.
Branded or company-specific templates should usually be published as a separate distribution rather than added here.
Patches for WeasyPrint¶
compat/weasyprint_patches.py
holds small monkey patches for fixes that are merged or proposed upstream
but not in a WeasyPrint release yet. Each patch checks the WeasyPrint
version and the signature of the function it wraps, and is skipped (with a
debug message) when either does not match. Every export applies them once
before rendering; -O weasyprint.patches=off turns them off.
- Elements with the same
styleattribute share one computed style (Kozea/WeasyPrint#2981, issue #2980). Applied to WeasyPrint 70.0. Remove the patch when WeasyPrint >= the first release that includes #2981 is the oldest supported version. When a new WeasyPrint release still lacks the fix, raiseLAST_VERSION.
Testing¶
We use pytest for testing. Tests are located in the tests/ directory.
Running Tests¶
# Run all tests
uv run pytest
# Run tests with verbose output
uv run pytest -v
# Run specific test file
uv run pytest tests/test_basic.py
# Run specific test
uv run pytest tests/test_basic.py::test_package_imports
Writing Tests¶
- Create test files in the
tests/directory with the prefixtest_ - Follow naming conventions:
test_*.pyfiles,test_*functions - Use descriptive test names that explain what is being tested
- Add docstrings to explain complex test scenarios
Example test structure:
def test_feature_description() -> None:
"""Test that the feature works as expected."""
# Arrange
input_data = "test input"
# Act
result = function_under_test(input_data)
# Assert
assert result == expected_output
Code Quality¶
Linting with Ruff¶
We use ruff for Python linting and code formatting.
# Check code quality
uv run ruff check
# Auto-fix issues where possible
uv run ruff check --fix
# Check specific files or directories
uv run ruff check obsidian_pdf_exporter/
uv run ruff check tests/
Code Style Guidelines¶
- Line length: Maximum 100 characters
- Docstring style: Google docstring convention
- Import formatting: One import per line (enforced by ruff)
- Type hints: Use type annotations for new code
Pre-commit Workflow¶
Before committing:
- Run linting:
uv run ruff check - Run tests:
uv run pytest - Fix any issues before committing
Documentation¶
The documentation site is built with Zensical
from mkdocs.yml and docs/, and published to GitHub Pages by
.github/workflows/docs.yml on every push to main.
uv sync --group docs
uv run zensical serve # live preview on http://localhost:8000
uv run zensical build --strict # what CI runs
Generated content:
docs/reference/cli.mdcomes from the Typer app. Runuv run python scripts/gen_cli_docs.pyafter changing a command or flag. CI fails when the page is out of date.docs/img/features/*.pnganddocs/img/templates/*.pngare rendered from the example vault inexamples/. Each folder there (not starting with_) is one example and becomesdocs/img/features/<folder>.png. Runuv run python scripts/render_examples.py [name ...]after a change that affects the output. It needspdftoppm(poppler). Commit the images.docs/img/redline/*.pngcome fromexamples/_redline/. Each case folder there holds abefore/and anafter/version of a small vault.uv run python scripts/render_redline_examples.py [case ...]commits both into a temporary git repository (fixed dates, so the hashes do not change), runsope redlinein both styles and writes the images. The casestylesrendersexamples/_redline/_stylesin every combination of style, collapse and--blame, plus whole pages. Commit the images.docs/img/home/*.webpare the images of the home page.uv run python scripts/render_home.pyexports the small handbook inexamples/_showcase/Handbook(the note in the hero is included from there) and makes WebP thumbnails of the feature and template screenshots. Run it afterrender_examples.py. The images have fixed sizes, so thewidthandheightindocs/index.mdstay right.docs/llms.txtanddocs/llms-full.txtcome from the pages. Runuv run python scripts/gen_llms_txt.pyafter adding or renaming a page. CI fails when they are out of date.
The home page (docs/index.md with overrides/home.html, styles under
"Home page" in docs/stylesheets/extra.css) follows what works on the
home pages of developer tools: the value in one line, the install and
first command with copy buttons above the fold, visual proof (a note next
to its PDF page), real output images instead of text-only cards, and a
clear next step at the end. Keep the basic export first and Redline as the
featured section after it. Images below the fold get loading="lazy",
every image gets width and height, and the page must work at 390px and
in the dark theme.
A feature page shows the example source next to its image with the
ope-example block, see any page under docs/obsidian/. The source is
included with --8<-- "examples/<name>/<name>.md", so it cannot drift
from the note that was rendered.
Release Process¶
[!NOTE] Only relevant for maintainers.
Automated Release¶
We use GitHub Actions for automated releases:
- Trigger Release Workflow
- Go to GitHub Actions tab
- Run "Release" workflow
-
Choose version bump type (patch/minor/major) or specify custom version
-
Automated Steps
- Updates version in
pyproject.toml - Runs tests and builds
- Creates Git tag
- Publishes to PyPI
- Creates GitHub release with auto-generated notes
Pull Request Guidelines¶
Before Submitting¶
- Create a feature branch
- Run the full test suite
- Update documentation if needed
PR Requirements¶
- All tests pass (verified by CI)
- Code passes linting (ruff check)
- Descriptive PR title and description
- Reference related issues if applicable
- Update tests for new functionality
- Update documentation for user-facing changes
Development Environment¶
Recommended Tools¶
- IDE: VS Code with Python extension
- Git client: Command line or your preferred GUI
- Terminal: Any modern terminal with shell completion
VS Code Extensions¶
Recommended extensions for development:
- Python (Microsoft)
- Ruff (Astral Software)
Project Structure¶
obsidian-pdf-exporter/
├── .github/workflows/ # CI/CD workflows
├── obsidian_pdf_exporter/ # Main package, see Architecture
│ ├── __init__.py
│ ├── main.py # CLI entry point
│ ├── core/ # Export pipeline, dialect and core syntax steps
│ ├── plugins/ # Obsidian community plugin support
│ └── templates/ # PDF templates
├── tests/ # Test suite
├── pyproject.toml # Project configuration
├── uv.lock # Dependency lock file
└── CONTRIBUTING.md # This file
Getting Help¶
- GitHub Issues: For bug reports and feature requests
- GitHub Discussions: For questions and general discussion
- Documentation: https://spenhouet.com/obsidian-pdf-exporter/