Skip to content

Commit c96c629

Browse files
araa47claude
andauthored
fix: prevent orphaned headings at page breaks (#2)
Co-authored-by: Claude Opus 4.6 <[email protected]>
1 parent c80cb23 commit c96c629

12 files changed

Lines changed: 197 additions & 88 deletions

File tree

.yamllint.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
extends: default
3+
rules:
4+
truthy: disable
5+
line-length:
6+
max: 120

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
## [0.2.0] - 2026-03-23
6+
7+
### Fixed
8+
9+
- 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).
10+
11+
### Added
12+
13+
- Tests for heading orphan prevention (structural and integration).
14+
15+
## [0.1.0] - 2026-03-22
16+
17+
### Added
18+
19+
- Initial release: markdown to PDF with light/dark themes, code blocks, tables, lists, images, blockquotes, task lists, extended formatting, and async remote image fetching.

README.md

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,54 @@
1-
# markpdf
1+
<p align="center">
2+
<img src="assets/logo.png" alt="markpdf" width="200" />
3+
</p>
24

3-
Beautiful PDFs from markdown. One command, zero config.
5+
<h1 align="center">markpdf</h1>
6+
7+
<p align="center">
8+
Beautiful PDFs from markdown. One command, zero config.
9+
</p>
10+
11+
<p align="center">
12+
<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>
13+
<a href="https://pypi.org/project/markpdf"><img src="https://img.shields.io/pypi/v/markpdf?style=flat-square" alt="PyPI" /></a>
14+
<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>
15+
<a href="https://pypi.org/project/markpdf"><img src="https://img.shields.io/pypi/pyversions/markpdf?style=flat-square" alt="Python" /></a>
16+
</p>
17+
18+
---
419

520
```bash
621
markpdf report.md
722
```
823

924
## Install
1025

11-
Agent skill (Claude Code, Cursor, Codex, Gemini CLI):
26+
**Agent skill** (Claude Code, Cursor, Codex, Gemini CLI):
1227

1328
```bash
1429
npx skills add araa47/markpdf
1530
```
1631

17-
CLI:
32+
**CLI**:
1833

1934
```bash
2035
uv tool install git+https://github.com/araa47/markpdf
2136
```
2237

38+
Or with pip:
39+
40+
```bash
41+
pip install markpdf
42+
```
43+
2344
## Usage
2445

2546
```bash
2647
markpdf report.md # creates report.pdf
2748
markpdf report.md --dark # dark mode
2849
markpdf report.md -o final.pdf # custom output path
2950
markpdf report.md -k # keep sections on same page
51+
markpdf report.md -v # verbose output
3052
```
3153

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

4971
> Source: [`tests/fixtures/showcase.md`](tests/fixtures/showcase.md) | Full PDFs: [`examples/`](examples/)
5072
73+
## Features
74+
75+
- **Full markdown** -- headers, lists, tables, code blocks, blockquotes, images, task lists
76+
- **Extended syntax** -- `==highlight==`, `^super^`, `~sub~`, `~~strike~~`
77+
- **Light & dark themes** -- shadcn/ui zinc palette
78+
- **Smart page breaks** -- headings stay with their content, no orphans
79+
- **Remote images** fetched concurrently
80+
- **Async I/O** with optional uvloop
81+
- **Single command**, agent-friendly -- no browser, no LaTeX, no config
82+
5183
## Why markpdf?
5284

53-
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.
85+
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.
86+
87+
## Contributing
88+
89+
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup.
90+
91+
```bash
92+
uv sync --all-extras --dev
93+
uv run pytest
94+
```
95+
96+
## Changelog
97+
98+
See [CHANGELOG.md](CHANGELOG.md) for release history.
99+
100+
## License
54101

55-
- Full markdown — headers, lists, tables, code blocks, blockquotes, images, task lists
56-
- Extended syntax — `==highlight==`, `^super^`, `~sub~`, `~~strike~~`
57-
- Light & dark themes — shadcn/ui zinc palette
58-
- Remote images fetched concurrently
59-
- Async I/O with optional uvloop
60-
- Single binary-style command, agent-friendly
102+
[MIT](LICENSE)

assets/logo.png

34 KB
Loading

ignore-spelling-words.txt

Whitespace-only changes.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "markpdf"
3-
version = "0.1.0"
3+
version = "0.2.0"
44
description = "Agent-friendly markdown to PDF. Beautiful docs from the terminal."
55
readme = "README.md"
66
license = "MIT"

src/markpdf/cli.py

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import aiohttp
1111
import typer
1212

13-
from .parser import BLOCK_IMAGE, HEADER_BLOCKS, parse_markdown
13+
from .parser import BLOCK_IMAGE, parse_markdown
1414
from .renderer import build_story, create_styles, group_sections, render_pdf
1515
from .themes import THEME_DARK, THEME_LIGHT
1616

@@ -23,13 +23,9 @@
2323
)
2424

2525

26-
async def fetch_remote_image(
27-
url: str, session: aiohttp.ClientSession
28-
) -> str | None:
26+
async def fetch_remote_image(url: str, session: aiohttp.ClientSession) -> str | None:
2927
try:
30-
async with session.get(
31-
url, timeout=aiohttp.ClientTimeout(total=15)
32-
) as resp:
28+
async with session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp:
3329
if resp.status != 200:
3430
return None
3531
data = await resp.read()
@@ -57,9 +53,7 @@ async def prefetch_images(
5753
return {}
5854

5955
if verbose:
60-
print(
61-
f" Fetching {len(remote_urls)} remote image(s) concurrently..."
62-
)
56+
print(f" Fetching {len(remote_urls)} remote image(s) concurrently...")
6357

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

111105
remote_cache = await prefetch_images(blocks, verbose)
112106
styles = create_styles(theme)
113-
story = build_story(
114-
blocks, styles, theme, md_path, remote_cache, verbose
115-
)
107+
story = build_story(blocks, styles, theme, md_path, remote_cache, verbose)
116108

117109
if keep_together:
118110
story = group_sections(story)

src/markpdf/parser.py

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,7 @@ def parse_markdown(content: str, verbose: bool = False) -> list[tuple[str, Any]]
8080
while i < len(lines) and not lines[i].strip().startswith("```"):
8181
code_lines.append(lines[i])
8282
i += 1
83-
blocks.append(
84-
(BLOCK_CODE, {"lang": lang, "code": "\n".join(code_lines)})
85-
)
83+
blocks.append((BLOCK_CODE, {"lang": lang, "code": "\n".join(code_lines)}))
8684
if verbose:
8785
print(f" [Code] {len(code_lines)} lines ({lang or 'plain'})")
8886
i += 1
@@ -91,16 +89,12 @@ def parse_markdown(content: str, verbose: bool = False) -> list[tuple[str, Any]]
9189
if "|" in stripped and stripped.startswith("|"):
9290
table_lines = [line]
9391
i += 1
94-
while (
95-
i < len(lines) and "|" in lines[i].strip() and lines[i].strip()
96-
):
92+
while i < len(lines) and "|" in lines[i].strip() and lines[i].strip():
9793
table_lines.append(lines[i])
9894
i += 1
9995
if len(table_lines) >= 2:
10096
headers, rows = parse_table(table_lines)
101-
blocks.append(
102-
(BLOCK_TABLE, {"headers": headers, "rows": rows})
103-
)
97+
blocks.append((BLOCK_TABLE, {"headers": headers, "rows": rows}))
10498
continue
10599

106100
if stripped.startswith(">"):
@@ -116,9 +110,7 @@ def parse_markdown(content: str, verbose: bool = False) -> list[tuple[str, Any]]
116110
while i < len(lines):
117111
item_line = lines[i].strip()
118112
if re.match(r"^[-*+]\s", item_line):
119-
task_match = re.match(
120-
r"^[-*+]\s+\[([ xX])\]\s*(.*)$", item_line
121-
)
113+
task_match = re.match(r"^[-*+]\s+\[([ xX])\]\s*(.*)$", item_line)
122114
if task_match:
123115
checked = task_match.group(1).lower() == "x"
124116
prefix = "\u2611 " if checked else "\u2610 "

0 commit comments

Comments
 (0)