Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .yamllint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
extends: default
rules:
truthy: disable
line-length:
max: 120
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Changelog

All notable changes to this project will be documented in this file.

## [0.2.0] - 2026-03-23

### Fixed

- Prevent orphaned headings at page breaks — headings no longer render alone at the bottom of a page with their content flowing to the next page. Uses `CondPageBreak`, `KeepTogether`, and `keepWithNext` for robust prevention. Thanks to [@0xlaveen](https://github.com/0xlaveen) for identifying this issue and proposing the fix in [#1](https://github.com/araa47/markpdf/pull/1).

### Added

- Tests for heading orphan prevention (structural and integration).

## [0.1.0] - 2026-03-22

### Added

- Initial release: markdown to PDF with light/dark themes, code blocks, tables, lists, images, blockquotes, task lists, extended formatting, and async remote image fetching.
64 changes: 53 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,32 +1,54 @@
# markpdf
<p align="center">
<img src="assets/logo.png" alt="markpdf" width="200" />
</p>

Beautiful PDFs from markdown. One command, zero config.
<h1 align="center">markpdf</h1>

<p align="center">
Beautiful PDFs from markdown. One command, zero config.
</p>

<p align="center">
<a href="https://github.com/araa47/markpdf/actions"><img src="https://img.shields.io/github/actions/workflow/status/araa47/markpdf/ci.yml?branch=main&style=flat-square" alt="CI" /></a>
<a href="https://pypi.org/project/markpdf"><img src="https://img.shields.io/pypi/v/markpdf?style=flat-square" alt="PyPI" /></a>
<a href="https://github.com/araa47/markpdf/blob/main/LICENSE"><img src="https://img.shields.io/github/license/araa47/markpdf?style=flat-square" alt="License" /></a>
<a href="https://pypi.org/project/markpdf"><img src="https://img.shields.io/pypi/pyversions/markpdf?style=flat-square" alt="Python" /></a>
</p>

---

```bash
markpdf report.md
```

## Install

Agent skill (Claude Code, Cursor, Codex, Gemini CLI):
**Agent skill** (Claude Code, Cursor, Codex, Gemini CLI):

```bash
npx skills add araa47/markpdf
```

CLI:
**CLI**:

```bash
uv tool install git+https://github.com/araa47/markpdf
```

Or with pip:

```bash
pip install markpdf
```

## Usage

```bash
markpdf report.md # creates report.pdf
markpdf report.md --dark # dark mode
markpdf report.md -o final.pdf # custom output path
markpdf report.md -k # keep sections on same page
markpdf report.md -v # verbose output
```

## Output
Expand All @@ -48,13 +70,33 @@ markpdf report.md -k # keep sections on same page

> Source: [`tests/fixtures/showcase.md`](tests/fixtures/showcase.md) | Full PDFs: [`examples/`](examples/)

## Features

- **Full markdown** -- headers, lists, tables, code blocks, blockquotes, images, task lists
- **Extended syntax** -- `==highlight==`, `^super^`, `~sub~`, `~~strike~~`
- **Light & dark themes** -- shadcn/ui zinc palette
- **Smart page breaks** -- headings stay with their content, no orphans
- **Remote images** fetched concurrently
- **Async I/O** with optional uvloop
- **Single command**, agent-friendly -- no browser, no LaTeX, no config

## Why markpdf?

Most messaging apps (Slack, Discord, Teams, WhatsApp, email) don't render markdown. `markpdf` turns it into a polished PDF — no browser, no LaTeX, no config.
Most messaging apps (Slack, Discord, Teams, WhatsApp, email) don't render markdown. `markpdf` turns it into a polished PDF -- no browser, no LaTeX, no config.

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup.

```bash
uv sync --all-extras --dev
uv run pytest
```

## Changelog

See [CHANGELOG.md](CHANGELOG.md) for release history.

## License

- Full markdown — headers, lists, tables, code blocks, blockquotes, images, task lists
- Extended syntax — `==highlight==`, `^super^`, `~sub~`, `~~strike~~`
- Light & dark themes — shadcn/ui zinc palette
- Remote images fetched concurrently
- Async I/O with optional uvloop
- Single binary-style command, agent-friendly
[MIT](LICENSE)
Binary file added assets/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Empty file added ignore-spelling-words.txt
Empty file.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "markpdf"
version = "0.1.0"
version = "0.2.0"
description = "Agent-friendly markdown to PDF. Beautiful docs from the terminal."
readme = "README.md"
license = "MIT"
Expand Down
18 changes: 5 additions & 13 deletions src/markpdf/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import aiohttp
import typer

from .parser import BLOCK_IMAGE, HEADER_BLOCKS, parse_markdown
from .parser import BLOCK_IMAGE, parse_markdown
from .renderer import build_story, create_styles, group_sections, render_pdf
from .themes import THEME_DARK, THEME_LIGHT

Expand All @@ -23,13 +23,9 @@
)


async def fetch_remote_image(
url: str, session: aiohttp.ClientSession
) -> str | None:
async def fetch_remote_image(url: str, session: aiohttp.ClientSession) -> str | None:
try:
async with session.get(
url, timeout=aiohttp.ClientTimeout(total=15)
) as resp:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp:
if resp.status != 200:
return None
data = await resp.read()
Expand Down Expand Up @@ -57,9 +53,7 @@ async def prefetch_images(
return {}

if verbose:
print(
f" Fetching {len(remote_urls)} remote image(s) concurrently..."
)
print(f" Fetching {len(remote_urls)} remote image(s) concurrently...")

resolved: dict[str, str | None] = {}
async with aiohttp.ClientSession() as session:
Expand Down Expand Up @@ -110,9 +104,7 @@ async def convert(

remote_cache = await prefetch_images(blocks, verbose)
styles = create_styles(theme)
story = build_story(
blocks, styles, theme, md_path, remote_cache, verbose
)
story = build_story(blocks, styles, theme, md_path, remote_cache, verbose)

if keep_together:
story = group_sections(story)
Expand Down
16 changes: 4 additions & 12 deletions src/markpdf/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,7 @@ def parse_markdown(content: str, verbose: bool = False) -> list[tuple[str, Any]]
while i < len(lines) and not lines[i].strip().startswith("```"):
code_lines.append(lines[i])
i += 1
blocks.append(
(BLOCK_CODE, {"lang": lang, "code": "\n".join(code_lines)})
)
blocks.append((BLOCK_CODE, {"lang": lang, "code": "\n".join(code_lines)}))
if verbose:
print(f" [Code] {len(code_lines)} lines ({lang or 'plain'})")
i += 1
Expand All @@ -91,16 +89,12 @@ def parse_markdown(content: str, verbose: bool = False) -> list[tuple[str, Any]]
if "|" in stripped and stripped.startswith("|"):
table_lines = [line]
i += 1
while (
i < len(lines) and "|" in lines[i].strip() and lines[i].strip()
):
while i < len(lines) and "|" in lines[i].strip() and lines[i].strip():
table_lines.append(lines[i])
i += 1
if len(table_lines) >= 2:
headers, rows = parse_table(table_lines)
blocks.append(
(BLOCK_TABLE, {"headers": headers, "rows": rows})
)
blocks.append((BLOCK_TABLE, {"headers": headers, "rows": rows}))
continue

if stripped.startswith(">"):
Expand All @@ -116,9 +110,7 @@ def parse_markdown(content: str, verbose: bool = False) -> list[tuple[str, Any]]
while i < len(lines):
item_line = lines[i].strip()
if re.match(r"^[-*+]\s", item_line):
task_match = re.match(
r"^[-*+]\s+\[([ xX])\]\s*(.*)$", item_line
)
task_match = re.match(r"^[-*+]\s+\[([ xX])\]\s*(.*)$", item_line)
if task_match:
checked = task_match.group(1).lower() == "x"
prefix = "\u2611 " if checked else "\u2610 "
Expand Down
Loading
Loading