Skip to content

Commit d7e26ca

Browse files
araa47claude
andcommitted
fix: prevent orphaned headings at page breaks + improve README
- Add CondPageBreak before headings to avoid orphans at page bottom - Wrap heading + decorators in KeepTogether for atomic rendering - Set keepWithNext=True on all heading styles - Add structural and integration tests for orphan prevention - Bump version to 0.2.0 - Add CHANGELOG.md - Improve README with badges, features section, and better structure Thanks to @0xlaveen for identifying the orphaned heading issue in #1. Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent c80cb23 commit d7e26ca

6 files changed

Lines changed: 151 additions & 23 deletions

File tree

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)

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/renderer.py

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
1212
from reportlab.lib.units import inch
1313
from reportlab.platypus import (
14+
CondPageBreak,
1415
HRFlowable,
1516
Image,
1617
KeepTogether,
@@ -114,6 +115,7 @@ def create_styles(theme: dict[str, str | None]) -> dict:
114115
spaceBefore=hd["sb"],
115116
leading=hd["ld"],
116117
fontName="Helvetica-Bold",
118+
keepWithNext=True,
117119
)
118120
)
119121

@@ -298,22 +300,33 @@ def build_story(
298300
):
299301
level = int(btype[1])
300302
text = _fmt(content, theme)
301-
story.append(
302-
Paragraph(text, styles[f"Heading{level}Custom"])
303+
# Avoid orphaned headings: break page if insufficient space
304+
story.append(CondPageBreak(1.2 * inch))
305+
heading_para = Paragraph(
306+
text, styles[f"Heading{level}Custom"]
303307
)
304308
if level == 2:
305-
story.append(Spacer(1, 2))
306309
story.append(
307-
HRFlowable(
308-
width="100%",
309-
thickness=0.5,
310-
color=colors.HexColor(theme["border"]),
311-
spaceBefore=0,
312-
spaceAfter=8,
310+
KeepTogether(
311+
[
312+
heading_para,
313+
Spacer(1, 2),
314+
HRFlowable(
315+
width="100%",
316+
thickness=0.5,
317+
color=colors.HexColor(
318+
theme["border"]
319+
),
320+
spaceBefore=0,
321+
spaceAfter=8,
322+
),
323+
]
313324
)
314325
)
315326
else:
316-
story.append(Spacer(1, 4))
327+
story.append(
328+
KeepTogether([heading_para, Spacer(1, 4)])
329+
)
317330

318331
elif btype == BLOCK_PARA:
319332
story.append(

tests/test_markpdf.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,57 @@ def test_empty_file(tmp_path: Path):
8383
result = run_markpdf(FIXTURES_DIR / "empty.md", output)
8484
assert result.returncode == 0
8585
assert output.exists()
86+
87+
88+
def test_heading_orphan_prevention():
89+
"""Headings should be wrapped with CondPageBreak and KeepTogether to avoid orphans."""
90+
from reportlab.platypus import CondPageBreak, KeepTogether
91+
92+
from markpdf.parser import parse_markdown
93+
from markpdf.renderer import build_story, create_styles
94+
from markpdf.themes import THEME_LIGHT
95+
96+
md = "# Title\n\nSome text.\n\n## Section\n\nMore text.\n\n### Subsection\n\nDetails."
97+
blocks = parse_markdown(md)
98+
styles = create_styles(THEME_LIGHT)
99+
story = build_story(blocks, styles, THEME_LIGHT, Path("."), {})
100+
101+
cond_breaks = [f for f in story if isinstance(f, CondPageBreak)]
102+
keep_togethers = [f for f in story if isinstance(f, KeepTogether)]
103+
104+
# 3 headings -> 3 CondPageBreak + 3 KeepTogether (one per heading block)
105+
assert len(cond_breaks) == 3, f"Expected 3 CondPageBreak, got {len(cond_breaks)}"
106+
assert len(keep_togethers) >= 3, f"Expected >=3 KeepTogether, got {len(keep_togethers)}"
107+
108+
109+
def test_heading_keepwithnext_style():
110+
"""All heading styles should have keepWithNext=True."""
111+
from markpdf.renderer import create_styles
112+
from markpdf.themes import THEME_LIGHT
113+
114+
styles = create_styles(THEME_LIGHT)
115+
for level in range(1, 7):
116+
style = styles[f"Heading{level}Custom"]
117+
assert style.keepWithNext is True, f"Heading{level}Custom missing keepWithNext"
118+
119+
120+
def test_heading_orphan_long_document(tmp_path: Path):
121+
"""A long document with many sections should produce a valid PDF with orphan prevention."""
122+
# Generate a markdown document with many sections to force page breaks
123+
sections = []
124+
for i in range(20):
125+
sections.append(f"## Section {i + 1}")
126+
sections.append("")
127+
# Add enough content per section to push headings near page boundaries
128+
for j in range(8):
129+
sections.append(f"This is paragraph {j + 1} of section {i + 1}. " * 4)
130+
sections.append("")
131+
md_content = "\n".join(sections)
132+
133+
md_file = tmp_path / "long_headings.md"
134+
md_file.write_text(md_content)
135+
output = tmp_path / "long_headings.pdf"
136+
result = run_markpdf(md_file, output)
137+
assert result.returncode == 0
138+
assert output.exists()
139+
assert output.stat().st_size > 1000

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)