reportlab-smart-pagination 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- reportlab_smart_pagination-1.0.0/LICENSE +21 -0
- reportlab_smart_pagination-1.0.0/PKG-INFO +143 -0
- reportlab_smart_pagination-1.0.0/README.md +114 -0
- reportlab_smart_pagination-1.0.0/pyproject.toml +38 -0
- reportlab_smart_pagination-1.0.0/setup.cfg +4 -0
- reportlab_smart_pagination-1.0.0/src/reportlab_smart_pagination.egg-info/PKG-INFO +143 -0
- reportlab_smart_pagination-1.0.0/src/reportlab_smart_pagination.egg-info/SOURCES.txt +11 -0
- reportlab_smart_pagination-1.0.0/src/reportlab_smart_pagination.egg-info/dependency_links.txt +1 -0
- reportlab_smart_pagination-1.0.0/src/reportlab_smart_pagination.egg-info/requires.txt +1 -0
- reportlab_smart_pagination-1.0.0/src/reportlab_smart_pagination.egg-info/top_level.txt +1 -0
- reportlab_smart_pagination-1.0.0/src/smart_pagination/__init__.py +32 -0
- reportlab_smart_pagination-1.0.0/src/smart_pagination/paginator.py +277 -0
- reportlab_smart_pagination-1.0.0/tests/test_paginator.py +148 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 WriterDreams
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: reportlab-smart-pagination
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Height-aware heading protection for ReportLab PDF generation
|
|
5
|
+
Author-email: Hamdy El-Shamha <support@writersdream.ai>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/WriterDreams/reportlab-smart-pagination
|
|
8
|
+
Project-URL: Documentation, https://github.com/WriterDreams/reportlab-smart-pagination#readme
|
|
9
|
+
Project-URL: Issues, https://github.com/WriterDreams/reportlab-smart-pagination/issues
|
|
10
|
+
Project-URL: Source, https://github.com/WriterDreams/reportlab-smart-pagination
|
|
11
|
+
Keywords: reportlab,pdf,pagination,headings,typesetting
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Classifier: Topic :: Printing
|
|
23
|
+
Classifier: Topic :: Text Processing :: Markup
|
|
24
|
+
Requires-Python: >=3.8
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: reportlab>=3.6
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# ReportLab Smart Pagination
|
|
31
|
+
|
|
32
|
+
**Height-aware heading protection for ReportLab PDF generation.**
|
|
33
|
+
|
|
34
|
+
Prevents orphaned headings, text overflow, and cascade failures that ReportLab's built-in `keepWithNext` cannot handle.
|
|
35
|
+
|
|
36
|
+
Created by **Hamdy El-Shamha** — developed for [Writer's Dream AI](https://writersdream.ai), a book writing and publishing platform.
|
|
37
|
+
|
|
38
|
+
## The Problem
|
|
39
|
+
|
|
40
|
+
ReportLab's `keepWithNext=True` is a rigid boolean — it forces headings to stay with their content without checking whether they'll actually fit. This causes:
|
|
41
|
+
|
|
42
|
+
- **Text overlapping** when carried content exceeds the next page's height
|
|
43
|
+
- **Overflow cascades** when multiple headings chain together
|
|
44
|
+
- **Silent failures** — no errors, just broken PDFs
|
|
45
|
+
|
|
46
|
+
## The Solution
|
|
47
|
+
|
|
48
|
+
Smart Pagination replaces the rigid `keepWithNext` approach with **height-aware heading protection** using two percentage-based safety thresholds:
|
|
49
|
+
|
|
50
|
+
| | Built-in `keepWithNext` | Smart Pagination |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| Prevents orphaned headings | Yes | Yes |
|
|
53
|
+
| Prevents overflow | No | Yes — caps carry at 50% of page |
|
|
54
|
+
| Prevents empty pages | No | Yes — aborts if page < 15% filled |
|
|
55
|
+
| Handles chained headings | Breaks silently | Gracefully degrades |
|
|
56
|
+
| Splits large paragraphs | No | Yes — fills pages optimally |
|
|
57
|
+
|
|
58
|
+
## Installation
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install reportlab-smart-pagination
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Quick Start
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from smart_pagination import paginate
|
|
68
|
+
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
|
|
69
|
+
from reportlab.lib.styles import getSampleStyleSheet
|
|
70
|
+
from reportlab.lib.pagesizes import letter
|
|
71
|
+
from reportlab.lib.units import inch
|
|
72
|
+
|
|
73
|
+
styles = getSampleStyleSheet()
|
|
74
|
+
|
|
75
|
+
# Build your flowables as usual
|
|
76
|
+
flowables = [
|
|
77
|
+
Paragraph("Chapter 1: The Beginning", styles['Heading1']),
|
|
78
|
+
Paragraph("This is the first paragraph of content...", styles['Normal']),
|
|
79
|
+
Paragraph("Chapter 2: The Middle", styles['Heading1']),
|
|
80
|
+
Paragraph("More content follows here...", styles['Normal']),
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
# Calculate frame dimensions (letter page with 1-inch margins)
|
|
84
|
+
page_w, page_h = letter
|
|
85
|
+
frame_height = page_h - 2 * inch
|
|
86
|
+
frame_width = page_w - 2 * inch
|
|
87
|
+
|
|
88
|
+
# Paginate with heading protection
|
|
89
|
+
story = paginate(flowables, frame_height=frame_height, frame_width=frame_width)
|
|
90
|
+
|
|
91
|
+
# Build PDF
|
|
92
|
+
doc = SimpleDocTemplate("output.pdf", pagesize=letter)
|
|
93
|
+
doc.build(story)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Advanced Usage
|
|
97
|
+
|
|
98
|
+
Use `SmartPaginator` for full control over thresholds:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from smart_pagination import SmartPaginator
|
|
102
|
+
|
|
103
|
+
paginator = SmartPaginator(
|
|
104
|
+
frame_height=720,
|
|
105
|
+
frame_width=468,
|
|
106
|
+
carry_max=0.5, # Max 50% of page height can be carried
|
|
107
|
+
min_page_fill=0.15, # Page must be at least 15% filled after carry
|
|
108
|
+
search_depth=10, # Search last 10 items for orphaned headings
|
|
109
|
+
split_enabled=True, # Split large paragraphs across pages
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
story = paginator.paginate(flowables)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Parameters
|
|
116
|
+
|
|
117
|
+
| Parameter | Default | Description |
|
|
118
|
+
|-----------|---------|-------------|
|
|
119
|
+
| `carry_max` | `0.5` | Maximum fraction of page height that can be carried to the next page. Increase for layouts with very large headings. |
|
|
120
|
+
| `min_page_fill` | `0.15` | Minimum fraction of page that must remain filled after carrying a heading. Decrease if you prefer heading protection over page aesthetics. |
|
|
121
|
+
| `search_depth` | `10` | How many flowables to search backward for an orphaned heading. |
|
|
122
|
+
| `split_enabled` | `True` | Whether to split large paragraphs across pages to avoid blank gaps. |
|
|
123
|
+
| `split_min_space` | `0.15` | Minimum available page fraction before attempting a paragraph split. |
|
|
124
|
+
| `safety_margin` | `10` | Extra points reserved to prevent tight-fit overflow. |
|
|
125
|
+
|
|
126
|
+
## How It Works
|
|
127
|
+
|
|
128
|
+
When a flowable would overflow the current page:
|
|
129
|
+
|
|
130
|
+
1. **Search backward** through the last few items for a heading with `keepWithNext=True`
|
|
131
|
+
2. **Measure the carried content** — heading + spacer + any items after it
|
|
132
|
+
3. **Safety check 1**: If carrying would leave the current page less than 15% filled, abort — an orphaned heading looks better than an empty page
|
|
133
|
+
4. **Safety check 2**: If the carried content exceeds 50% of the page height, abort — it would likely overflow the next page too
|
|
134
|
+
5. **If safe**, remove the heading from the current page, insert a page break, and place the heading at the top of the next page
|
|
135
|
+
6. **If not safe**, try splitting the overflowing paragraph across pages instead
|
|
136
|
+
|
|
137
|
+
## Origin
|
|
138
|
+
|
|
139
|
+
This algorithm was developed while building the PDF export engine for [Writer's Dream AI](https://writersdream.ai). Manuscripts with dozens of sub-headings and footnotes routinely triggered ReportLab's overflow bugs. The standard `keepWithNext` approach was the *cause* of the overlapping text, not the cure.
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT License — see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# ReportLab Smart Pagination
|
|
2
|
+
|
|
3
|
+
**Height-aware heading protection for ReportLab PDF generation.**
|
|
4
|
+
|
|
5
|
+
Prevents orphaned headings, text overflow, and cascade failures that ReportLab's built-in `keepWithNext` cannot handle.
|
|
6
|
+
|
|
7
|
+
Created by **Hamdy El-Shamha** — developed for [Writer's Dream AI](https://writersdream.ai), a book writing and publishing platform.
|
|
8
|
+
|
|
9
|
+
## The Problem
|
|
10
|
+
|
|
11
|
+
ReportLab's `keepWithNext=True` is a rigid boolean — it forces headings to stay with their content without checking whether they'll actually fit. This causes:
|
|
12
|
+
|
|
13
|
+
- **Text overlapping** when carried content exceeds the next page's height
|
|
14
|
+
- **Overflow cascades** when multiple headings chain together
|
|
15
|
+
- **Silent failures** — no errors, just broken PDFs
|
|
16
|
+
|
|
17
|
+
## The Solution
|
|
18
|
+
|
|
19
|
+
Smart Pagination replaces the rigid `keepWithNext` approach with **height-aware heading protection** using two percentage-based safety thresholds:
|
|
20
|
+
|
|
21
|
+
| | Built-in `keepWithNext` | Smart Pagination |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| Prevents orphaned headings | Yes | Yes |
|
|
24
|
+
| Prevents overflow | No | Yes — caps carry at 50% of page |
|
|
25
|
+
| Prevents empty pages | No | Yes — aborts if page < 15% filled |
|
|
26
|
+
| Handles chained headings | Breaks silently | Gracefully degrades |
|
|
27
|
+
| Splits large paragraphs | No | Yes — fills pages optimally |
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install reportlab-smart-pagination
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick Start
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from smart_pagination import paginate
|
|
39
|
+
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
|
|
40
|
+
from reportlab.lib.styles import getSampleStyleSheet
|
|
41
|
+
from reportlab.lib.pagesizes import letter
|
|
42
|
+
from reportlab.lib.units import inch
|
|
43
|
+
|
|
44
|
+
styles = getSampleStyleSheet()
|
|
45
|
+
|
|
46
|
+
# Build your flowables as usual
|
|
47
|
+
flowables = [
|
|
48
|
+
Paragraph("Chapter 1: The Beginning", styles['Heading1']),
|
|
49
|
+
Paragraph("This is the first paragraph of content...", styles['Normal']),
|
|
50
|
+
Paragraph("Chapter 2: The Middle", styles['Heading1']),
|
|
51
|
+
Paragraph("More content follows here...", styles['Normal']),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
# Calculate frame dimensions (letter page with 1-inch margins)
|
|
55
|
+
page_w, page_h = letter
|
|
56
|
+
frame_height = page_h - 2 * inch
|
|
57
|
+
frame_width = page_w - 2 * inch
|
|
58
|
+
|
|
59
|
+
# Paginate with heading protection
|
|
60
|
+
story = paginate(flowables, frame_height=frame_height, frame_width=frame_width)
|
|
61
|
+
|
|
62
|
+
# Build PDF
|
|
63
|
+
doc = SimpleDocTemplate("output.pdf", pagesize=letter)
|
|
64
|
+
doc.build(story)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Advanced Usage
|
|
68
|
+
|
|
69
|
+
Use `SmartPaginator` for full control over thresholds:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from smart_pagination import SmartPaginator
|
|
73
|
+
|
|
74
|
+
paginator = SmartPaginator(
|
|
75
|
+
frame_height=720,
|
|
76
|
+
frame_width=468,
|
|
77
|
+
carry_max=0.5, # Max 50% of page height can be carried
|
|
78
|
+
min_page_fill=0.15, # Page must be at least 15% filled after carry
|
|
79
|
+
search_depth=10, # Search last 10 items for orphaned headings
|
|
80
|
+
split_enabled=True, # Split large paragraphs across pages
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
story = paginator.paginate(flowables)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Parameters
|
|
87
|
+
|
|
88
|
+
| Parameter | Default | Description |
|
|
89
|
+
|-----------|---------|-------------|
|
|
90
|
+
| `carry_max` | `0.5` | Maximum fraction of page height that can be carried to the next page. Increase for layouts with very large headings. |
|
|
91
|
+
| `min_page_fill` | `0.15` | Minimum fraction of page that must remain filled after carrying a heading. Decrease if you prefer heading protection over page aesthetics. |
|
|
92
|
+
| `search_depth` | `10` | How many flowables to search backward for an orphaned heading. |
|
|
93
|
+
| `split_enabled` | `True` | Whether to split large paragraphs across pages to avoid blank gaps. |
|
|
94
|
+
| `split_min_space` | `0.15` | Minimum available page fraction before attempting a paragraph split. |
|
|
95
|
+
| `safety_margin` | `10` | Extra points reserved to prevent tight-fit overflow. |
|
|
96
|
+
|
|
97
|
+
## How It Works
|
|
98
|
+
|
|
99
|
+
When a flowable would overflow the current page:
|
|
100
|
+
|
|
101
|
+
1. **Search backward** through the last few items for a heading with `keepWithNext=True`
|
|
102
|
+
2. **Measure the carried content** — heading + spacer + any items after it
|
|
103
|
+
3. **Safety check 1**: If carrying would leave the current page less than 15% filled, abort — an orphaned heading looks better than an empty page
|
|
104
|
+
4. **Safety check 2**: If the carried content exceeds 50% of the page height, abort — it would likely overflow the next page too
|
|
105
|
+
5. **If safe**, remove the heading from the current page, insert a page break, and place the heading at the top of the next page
|
|
106
|
+
6. **If not safe**, try splitting the overflowing paragraph across pages instead
|
|
107
|
+
|
|
108
|
+
## Origin
|
|
109
|
+
|
|
110
|
+
This algorithm was developed while building the PDF export engine for [Writer's Dream AI](https://writersdream.ai). Manuscripts with dozens of sub-headings and footnotes routinely triggered ReportLab's overflow bugs. The standard `keepWithNext` approach was the *cause* of the overlapping text, not the cure.
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
MIT License — see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "reportlab-smart-pagination"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Height-aware heading protection for ReportLab PDF generation"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "Hamdy El-Shamha", email = "support@writersdream.ai"},
|
|
14
|
+
]
|
|
15
|
+
keywords = ["reportlab", "pdf", "pagination", "headings", "typesetting"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 5 - Production/Stable",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.8",
|
|
22
|
+
"Programming Language :: Python :: 3.9",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Topic :: Software Development :: Libraries",
|
|
27
|
+
"Topic :: Printing",
|
|
28
|
+
"Topic :: Text Processing :: Markup",
|
|
29
|
+
]
|
|
30
|
+
dependencies = [
|
|
31
|
+
"reportlab>=3.6",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/WriterDreams/reportlab-smart-pagination"
|
|
36
|
+
Documentation = "https://github.com/WriterDreams/reportlab-smart-pagination#readme"
|
|
37
|
+
Issues = "https://github.com/WriterDreams/reportlab-smart-pagination/issues"
|
|
38
|
+
Source = "https://github.com/WriterDreams/reportlab-smart-pagination"
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: reportlab-smart-pagination
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Height-aware heading protection for ReportLab PDF generation
|
|
5
|
+
Author-email: Hamdy El-Shamha <support@writersdream.ai>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/WriterDreams/reportlab-smart-pagination
|
|
8
|
+
Project-URL: Documentation, https://github.com/WriterDreams/reportlab-smart-pagination#readme
|
|
9
|
+
Project-URL: Issues, https://github.com/WriterDreams/reportlab-smart-pagination/issues
|
|
10
|
+
Project-URL: Source, https://github.com/WriterDreams/reportlab-smart-pagination
|
|
11
|
+
Keywords: reportlab,pdf,pagination,headings,typesetting
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
22
|
+
Classifier: Topic :: Printing
|
|
23
|
+
Classifier: Topic :: Text Processing :: Markup
|
|
24
|
+
Requires-Python: >=3.8
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: reportlab>=3.6
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# ReportLab Smart Pagination
|
|
31
|
+
|
|
32
|
+
**Height-aware heading protection for ReportLab PDF generation.**
|
|
33
|
+
|
|
34
|
+
Prevents orphaned headings, text overflow, and cascade failures that ReportLab's built-in `keepWithNext` cannot handle.
|
|
35
|
+
|
|
36
|
+
Created by **Hamdy El-Shamha** — developed for [Writer's Dream AI](https://writersdream.ai), a book writing and publishing platform.
|
|
37
|
+
|
|
38
|
+
## The Problem
|
|
39
|
+
|
|
40
|
+
ReportLab's `keepWithNext=True` is a rigid boolean — it forces headings to stay with their content without checking whether they'll actually fit. This causes:
|
|
41
|
+
|
|
42
|
+
- **Text overlapping** when carried content exceeds the next page's height
|
|
43
|
+
- **Overflow cascades** when multiple headings chain together
|
|
44
|
+
- **Silent failures** — no errors, just broken PDFs
|
|
45
|
+
|
|
46
|
+
## The Solution
|
|
47
|
+
|
|
48
|
+
Smart Pagination replaces the rigid `keepWithNext` approach with **height-aware heading protection** using two percentage-based safety thresholds:
|
|
49
|
+
|
|
50
|
+
| | Built-in `keepWithNext` | Smart Pagination |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| Prevents orphaned headings | Yes | Yes |
|
|
53
|
+
| Prevents overflow | No | Yes — caps carry at 50% of page |
|
|
54
|
+
| Prevents empty pages | No | Yes — aborts if page < 15% filled |
|
|
55
|
+
| Handles chained headings | Breaks silently | Gracefully degrades |
|
|
56
|
+
| Splits large paragraphs | No | Yes — fills pages optimally |
|
|
57
|
+
|
|
58
|
+
## Installation
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install reportlab-smart-pagination
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Quick Start
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from smart_pagination import paginate
|
|
68
|
+
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
|
|
69
|
+
from reportlab.lib.styles import getSampleStyleSheet
|
|
70
|
+
from reportlab.lib.pagesizes import letter
|
|
71
|
+
from reportlab.lib.units import inch
|
|
72
|
+
|
|
73
|
+
styles = getSampleStyleSheet()
|
|
74
|
+
|
|
75
|
+
# Build your flowables as usual
|
|
76
|
+
flowables = [
|
|
77
|
+
Paragraph("Chapter 1: The Beginning", styles['Heading1']),
|
|
78
|
+
Paragraph("This is the first paragraph of content...", styles['Normal']),
|
|
79
|
+
Paragraph("Chapter 2: The Middle", styles['Heading1']),
|
|
80
|
+
Paragraph("More content follows here...", styles['Normal']),
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
# Calculate frame dimensions (letter page with 1-inch margins)
|
|
84
|
+
page_w, page_h = letter
|
|
85
|
+
frame_height = page_h - 2 * inch
|
|
86
|
+
frame_width = page_w - 2 * inch
|
|
87
|
+
|
|
88
|
+
# Paginate with heading protection
|
|
89
|
+
story = paginate(flowables, frame_height=frame_height, frame_width=frame_width)
|
|
90
|
+
|
|
91
|
+
# Build PDF
|
|
92
|
+
doc = SimpleDocTemplate("output.pdf", pagesize=letter)
|
|
93
|
+
doc.build(story)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Advanced Usage
|
|
97
|
+
|
|
98
|
+
Use `SmartPaginator` for full control over thresholds:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from smart_pagination import SmartPaginator
|
|
102
|
+
|
|
103
|
+
paginator = SmartPaginator(
|
|
104
|
+
frame_height=720,
|
|
105
|
+
frame_width=468,
|
|
106
|
+
carry_max=0.5, # Max 50% of page height can be carried
|
|
107
|
+
min_page_fill=0.15, # Page must be at least 15% filled after carry
|
|
108
|
+
search_depth=10, # Search last 10 items for orphaned headings
|
|
109
|
+
split_enabled=True, # Split large paragraphs across pages
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
story = paginator.paginate(flowables)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Parameters
|
|
116
|
+
|
|
117
|
+
| Parameter | Default | Description |
|
|
118
|
+
|-----------|---------|-------------|
|
|
119
|
+
| `carry_max` | `0.5` | Maximum fraction of page height that can be carried to the next page. Increase for layouts with very large headings. |
|
|
120
|
+
| `min_page_fill` | `0.15` | Minimum fraction of page that must remain filled after carrying a heading. Decrease if you prefer heading protection over page aesthetics. |
|
|
121
|
+
| `search_depth` | `10` | How many flowables to search backward for an orphaned heading. |
|
|
122
|
+
| `split_enabled` | `True` | Whether to split large paragraphs across pages to avoid blank gaps. |
|
|
123
|
+
| `split_min_space` | `0.15` | Minimum available page fraction before attempting a paragraph split. |
|
|
124
|
+
| `safety_margin` | `10` | Extra points reserved to prevent tight-fit overflow. |
|
|
125
|
+
|
|
126
|
+
## How It Works
|
|
127
|
+
|
|
128
|
+
When a flowable would overflow the current page:
|
|
129
|
+
|
|
130
|
+
1. **Search backward** through the last few items for a heading with `keepWithNext=True`
|
|
131
|
+
2. **Measure the carried content** — heading + spacer + any items after it
|
|
132
|
+
3. **Safety check 1**: If carrying would leave the current page less than 15% filled, abort — an orphaned heading looks better than an empty page
|
|
133
|
+
4. **Safety check 2**: If the carried content exceeds 50% of the page height, abort — it would likely overflow the next page too
|
|
134
|
+
5. **If safe**, remove the heading from the current page, insert a page break, and place the heading at the top of the next page
|
|
135
|
+
6. **If not safe**, try splitting the overflowing paragraph across pages instead
|
|
136
|
+
|
|
137
|
+
## Origin
|
|
138
|
+
|
|
139
|
+
This algorithm was developed while building the PDF export engine for [Writer's Dream AI](https://writersdream.ai). Manuscripts with dozens of sub-headings and footnotes routinely triggered ReportLab's overflow bugs. The standard `keepWithNext` approach was the *cause* of the overlapping text, not the cure.
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT License — see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/reportlab_smart_pagination.egg-info/PKG-INFO
|
|
5
|
+
src/reportlab_smart_pagination.egg-info/SOURCES.txt
|
|
6
|
+
src/reportlab_smart_pagination.egg-info/dependency_links.txt
|
|
7
|
+
src/reportlab_smart_pagination.egg-info/requires.txt
|
|
8
|
+
src/reportlab_smart_pagination.egg-info/top_level.txt
|
|
9
|
+
src/smart_pagination/__init__.py
|
|
10
|
+
src/smart_pagination/paginator.py
|
|
11
|
+
tests/test_paginator.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
reportlab>=3.6
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
smart_pagination
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ReportLab Smart Pagination
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
Height-aware heading protection for ReportLab PDF generation.
|
|
6
|
+
Prevents orphaned headings and overflow cascades that ReportLab's
|
|
7
|
+
built-in keepWithNext cannot handle.
|
|
8
|
+
|
|
9
|
+
Created by Hamdy El-Shamha for Writer's Dream AI (https://writersdream.ai)
|
|
10
|
+
|
|
11
|
+
Basic usage::
|
|
12
|
+
|
|
13
|
+
from smart_pagination import paginate
|
|
14
|
+
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
|
|
15
|
+
from reportlab.lib.styles import getSampleStyleSheet
|
|
16
|
+
|
|
17
|
+
styles = getSampleStyleSheet()
|
|
18
|
+
flowables = [
|
|
19
|
+
Paragraph("Chapter 1", styles['Heading1']),
|
|
20
|
+
Paragraph("Body text here...", styles['Normal']),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
doc = SimpleDocTemplate("output.pdf")
|
|
24
|
+
story = paginate(flowables, frame_height=720, frame_width=468)
|
|
25
|
+
doc.build(story)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from .paginator import paginate, SmartPaginator
|
|
29
|
+
|
|
30
|
+
__version__ = "1.0.0"
|
|
31
|
+
__author__ = "Hamdy El-Shamha"
|
|
32
|
+
__all__ = ["paginate", "SmartPaginator"]
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Smart Pagination Engine
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
Replaces ReportLab's rigid keepWithNext with height-aware heading
|
|
6
|
+
protection that prevents orphaned headings, overflow cascades, and
|
|
7
|
+
empty pages.
|
|
8
|
+
|
|
9
|
+
The core insight: instead of blindly forcing headings to stay with
|
|
10
|
+
their content (which causes silent overflow when content is too tall),
|
|
11
|
+
measure heights and only carry headings forward when it's safe.
|
|
12
|
+
|
|
13
|
+
Two threshold checks prevent the most common failures:
|
|
14
|
+
|
|
15
|
+
1. Don't carry more than 50% of a page — prevents overflow on next page
|
|
16
|
+
2. Don't leave a page less than 15% filled — prevents ugly empty pages
|
|
17
|
+
|
|
18
|
+
Created by Hamdy El-Shamha for Writer's Dream AI.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from reportlab.platypus import (
|
|
22
|
+
PageBreak,
|
|
23
|
+
Spacer,
|
|
24
|
+
Flowable,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _measure_flowable(flowable, frame_width):
|
|
29
|
+
"""Measure the height a flowable will occupy at the given width."""
|
|
30
|
+
if isinstance(flowable, Spacer):
|
|
31
|
+
return flowable.height if hasattr(flowable, 'height') else flowable._height
|
|
32
|
+
if isinstance(flowable, PageBreak):
|
|
33
|
+
return 0
|
|
34
|
+
try:
|
|
35
|
+
w, h = flowable.wrap(frame_width, 999999)
|
|
36
|
+
return h
|
|
37
|
+
except Exception:
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SmartPaginator:
|
|
42
|
+
"""
|
|
43
|
+
Height-aware paginator that protects headings from being orphaned
|
|
44
|
+
at the bottom of pages without causing overflow cascades.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
frame_height : float
|
|
49
|
+
The usable height of each page frame in points.
|
|
50
|
+
frame_width : float
|
|
51
|
+
The usable width of each page frame in points.
|
|
52
|
+
carry_max : float, optional
|
|
53
|
+
Maximum fraction of page height that can be carried to the next
|
|
54
|
+
page (default 0.5). Increase for layouts with very large headings.
|
|
55
|
+
min_page_fill : float, optional
|
|
56
|
+
Minimum fraction of page that must remain filled after carrying
|
|
57
|
+
a heading (default 0.15). Decrease if you prefer heading protection
|
|
58
|
+
over page fill.
|
|
59
|
+
search_depth : int, optional
|
|
60
|
+
How many items to search backward for an orphaned heading
|
|
61
|
+
(default 10).
|
|
62
|
+
safety_margin : float, optional
|
|
63
|
+
Extra points reserved to prevent tight-fit overflow (default 10).
|
|
64
|
+
split_enabled : bool, optional
|
|
65
|
+
Whether to split large paragraphs across pages instead of
|
|
66
|
+
leaving blank gaps (default True).
|
|
67
|
+
split_min_space : float, optional
|
|
68
|
+
Minimum fraction of page that must be available before attempting
|
|
69
|
+
a paragraph split (default 0.15).
|
|
70
|
+
|
|
71
|
+
Example
|
|
72
|
+
-------
|
|
73
|
+
>>> from smart_pagination import SmartPaginator
|
|
74
|
+
>>> paginator = SmartPaginator(frame_height=720, frame_width=468)
|
|
75
|
+
>>> story = paginator.paginate(flowables)
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
frame_height,
|
|
81
|
+
frame_width,
|
|
82
|
+
carry_max=0.5,
|
|
83
|
+
min_page_fill=0.15,
|
|
84
|
+
search_depth=10,
|
|
85
|
+
safety_margin=10,
|
|
86
|
+
split_enabled=True,
|
|
87
|
+
split_min_space=0.15,
|
|
88
|
+
):
|
|
89
|
+
self.frame_height = frame_height
|
|
90
|
+
self.frame_width = frame_width
|
|
91
|
+
self.carry_max = carry_max
|
|
92
|
+
self.min_page_fill = min_page_fill
|
|
93
|
+
self.search_depth = search_depth
|
|
94
|
+
self.safety_margin = safety_margin
|
|
95
|
+
self.split_enabled = split_enabled
|
|
96
|
+
self.split_min_space = split_min_space
|
|
97
|
+
|
|
98
|
+
def paginate(self, flowables):
|
|
99
|
+
"""
|
|
100
|
+
Process a list of flowables and return a new list with intelligent
|
|
101
|
+
page breaks inserted.
|
|
102
|
+
|
|
103
|
+
Parameters
|
|
104
|
+
----------
|
|
105
|
+
flowables : list[Flowable]
|
|
106
|
+
ReportLab flowables to paginate.
|
|
107
|
+
|
|
108
|
+
Returns
|
|
109
|
+
-------
|
|
110
|
+
list[Flowable]
|
|
111
|
+
New list with PageBreak flowables inserted at optimal positions.
|
|
112
|
+
"""
|
|
113
|
+
story_out = []
|
|
114
|
+
page_body = []
|
|
115
|
+
page_body_h = 0.0
|
|
116
|
+
|
|
117
|
+
def flush_page(force_break=True):
|
|
118
|
+
nonlocal page_body, page_body_h
|
|
119
|
+
if not page_body:
|
|
120
|
+
return
|
|
121
|
+
story_out.extend(page_body)
|
|
122
|
+
if force_break:
|
|
123
|
+
story_out.append(PageBreak())
|
|
124
|
+
page_body = []
|
|
125
|
+
page_body_h = 0.0
|
|
126
|
+
|
|
127
|
+
for flowable in flowables:
|
|
128
|
+
# Skip existing page breaks — we manage breaks ourselves
|
|
129
|
+
if isinstance(flowable, PageBreak):
|
|
130
|
+
flush_page()
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
f_h = _measure_flowable(flowable, self.frame_width)
|
|
134
|
+
|
|
135
|
+
# Check if adding this flowable would overflow the page
|
|
136
|
+
if page_body_h + f_h + self.safety_margin > self.frame_height and page_body:
|
|
137
|
+
carried = self._find_carry(page_body, page_body_h)
|
|
138
|
+
|
|
139
|
+
if carried:
|
|
140
|
+
# Remove carried items from current page
|
|
141
|
+
carried_h = sum(
|
|
142
|
+
_measure_flowable(f, self.frame_width) for f in carried
|
|
143
|
+
)
|
|
144
|
+
page_body = page_body[: len(page_body) - len(carried)]
|
|
145
|
+
page_body_h -= carried_h
|
|
146
|
+
else:
|
|
147
|
+
carried = []
|
|
148
|
+
|
|
149
|
+
# Try splitting large paragraphs across pages
|
|
150
|
+
if not carried and self.split_enabled:
|
|
151
|
+
split_result = self._try_split(
|
|
152
|
+
flowable, f_h, page_body_h
|
|
153
|
+
)
|
|
154
|
+
if split_result:
|
|
155
|
+
first_part, second_part = split_result
|
|
156
|
+
page_body.append(first_part)
|
|
157
|
+
page_body_h += _measure_flowable(
|
|
158
|
+
first_part, self.frame_width
|
|
159
|
+
)
|
|
160
|
+
flush_page()
|
|
161
|
+
# Continue with second part
|
|
162
|
+
flowable = second_part
|
|
163
|
+
f_h = _measure_flowable(flowable, self.frame_width)
|
|
164
|
+
page_body.append(flowable)
|
|
165
|
+
page_body_h += f_h
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
flush_page()
|
|
169
|
+
|
|
170
|
+
# Re-add carried heading to the new page
|
|
171
|
+
for cf in carried:
|
|
172
|
+
page_body.append(cf)
|
|
173
|
+
page_body_h += _measure_flowable(cf, self.frame_width)
|
|
174
|
+
|
|
175
|
+
page_body.append(flowable)
|
|
176
|
+
page_body_h += f_h
|
|
177
|
+
|
|
178
|
+
# Flush the final page without a trailing PageBreak
|
|
179
|
+
flush_page(force_break=False)
|
|
180
|
+
|
|
181
|
+
return story_out
|
|
182
|
+
|
|
183
|
+
def _find_carry(self, page_body, page_body_h):
|
|
184
|
+
"""
|
|
185
|
+
Search backward through the current page for an orphaned heading
|
|
186
|
+
(one with keepWithNext=True) and determine if it's safe to carry
|
|
187
|
+
it to the next page.
|
|
188
|
+
|
|
189
|
+
Returns the list of flowables to carry, or empty list if unsafe.
|
|
190
|
+
"""
|
|
191
|
+
search_limit = min(len(page_body), self.search_depth)
|
|
192
|
+
kwn_pos = None
|
|
193
|
+
|
|
194
|
+
# Search backward for a keepWithNext heading
|
|
195
|
+
for i in range(1, search_limit + 1):
|
|
196
|
+
item = page_body[-i]
|
|
197
|
+
if hasattr(item, 'style') and getattr(
|
|
198
|
+
item.style, 'keepWithNext', False
|
|
199
|
+
):
|
|
200
|
+
kwn_pos = len(page_body) - i
|
|
201
|
+
break
|
|
202
|
+
|
|
203
|
+
if kwn_pos is None:
|
|
204
|
+
return []
|
|
205
|
+
|
|
206
|
+
# Also grab the spacer before the heading (visual spacing)
|
|
207
|
+
if kwn_pos > 0 and isinstance(page_body[kwn_pos - 1], Spacer):
|
|
208
|
+
kwn_pos -= 1
|
|
209
|
+
|
|
210
|
+
carried = page_body[kwn_pos:]
|
|
211
|
+
carried_h = sum(
|
|
212
|
+
_measure_flowable(f, self.frame_width) for f in carried
|
|
213
|
+
)
|
|
214
|
+
remaining_h = page_body_h - carried_h
|
|
215
|
+
|
|
216
|
+
# SAFETY CHECK 1: Don't leave a nearly-empty page
|
|
217
|
+
if remaining_h < self.frame_height * self.min_page_fill:
|
|
218
|
+
return []
|
|
219
|
+
|
|
220
|
+
# SAFETY CHECK 2: Don't carry more than allowed fraction
|
|
221
|
+
if carried_h > self.frame_height * self.carry_max:
|
|
222
|
+
return []
|
|
223
|
+
|
|
224
|
+
return carried
|
|
225
|
+
|
|
226
|
+
def _try_split(self, flowable, f_h, page_body_h):
|
|
227
|
+
"""
|
|
228
|
+
Try to split a large flowable across pages to avoid blank gaps.
|
|
229
|
+
|
|
230
|
+
Returns (first_part, second_part) tuple, or None if split failed.
|
|
231
|
+
"""
|
|
232
|
+
avail = (
|
|
233
|
+
self.frame_height
|
|
234
|
+
- page_body_h
|
|
235
|
+
- self.safety_margin
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
if avail <= self.frame_height * self.split_min_space:
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
if not hasattr(flowable, 'split'):
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
parts = flowable.split(self.frame_width, avail)
|
|
245
|
+
if parts and len(parts) == 2:
|
|
246
|
+
return (parts[0], parts[1])
|
|
247
|
+
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def paginate(flowables, frame_height, frame_width, **kwargs):
|
|
252
|
+
"""
|
|
253
|
+
Convenience function for one-shot pagination.
|
|
254
|
+
|
|
255
|
+
Parameters
|
|
256
|
+
----------
|
|
257
|
+
flowables : list[Flowable]
|
|
258
|
+
ReportLab flowables to paginate.
|
|
259
|
+
frame_height : float
|
|
260
|
+
Usable page height in points.
|
|
261
|
+
frame_width : float
|
|
262
|
+
Usable page width in points.
|
|
263
|
+
**kwargs
|
|
264
|
+
Additional arguments passed to SmartPaginator.
|
|
265
|
+
|
|
266
|
+
Returns
|
|
267
|
+
-------
|
|
268
|
+
list[Flowable]
|
|
269
|
+
Paginated flowables with intelligent page breaks.
|
|
270
|
+
|
|
271
|
+
Example
|
|
272
|
+
-------
|
|
273
|
+
>>> from smart_pagination import paginate
|
|
274
|
+
>>> story = paginate(my_flowables, frame_height=720, frame_width=468)
|
|
275
|
+
"""
|
|
276
|
+
p = SmartPaginator(frame_height, frame_width, **kwargs)
|
|
277
|
+
return p.paginate(flowables)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Tests for the smart pagination engine."""
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
from reportlab.platypus import Paragraph, Spacer, PageBreak
|
|
5
|
+
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
|
6
|
+
|
|
7
|
+
from smart_pagination import paginate, SmartPaginator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
styles = getSampleStyleSheet()
|
|
11
|
+
|
|
12
|
+
# A heading style with keepWithNext
|
|
13
|
+
heading_style = ParagraphStyle(
|
|
14
|
+
'TestHeading',
|
|
15
|
+
parent=styles['Heading1'],
|
|
16
|
+
keepWithNext=True,
|
|
17
|
+
fontSize=14,
|
|
18
|
+
leading=18,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
body_style = styles['Normal']
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _make_body(text="Sample paragraph text. " * 10):
|
|
25
|
+
return Paragraph(text, body_style)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _make_heading(text="Test Heading"):
|
|
29
|
+
return Paragraph(text, heading_style)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestBasicPagination(unittest.TestCase):
|
|
33
|
+
"""Test that basic pagination works without errors."""
|
|
34
|
+
|
|
35
|
+
def test_empty_input(self):
|
|
36
|
+
result = paginate([], frame_height=720, frame_width=468)
|
|
37
|
+
self.assertEqual(result, [])
|
|
38
|
+
|
|
39
|
+
def test_single_paragraph(self):
|
|
40
|
+
flowables = [_make_body()]
|
|
41
|
+
result = paginate(flowables, frame_height=720, frame_width=468)
|
|
42
|
+
# Should contain the paragraph, no page break needed
|
|
43
|
+
self.assertTrue(len(result) >= 1)
|
|
44
|
+
self.assertNotIsInstance(result[-1], PageBreak)
|
|
45
|
+
|
|
46
|
+
def test_content_fits_single_page(self):
|
|
47
|
+
flowables = [
|
|
48
|
+
_make_heading("Chapter 1"),
|
|
49
|
+
_make_body("Short text."),
|
|
50
|
+
]
|
|
51
|
+
result = paginate(flowables, frame_height=720, frame_width=468)
|
|
52
|
+
# No page breaks needed if everything fits
|
|
53
|
+
page_breaks = [f for f in result if isinstance(f, PageBreak)]
|
|
54
|
+
self.assertEqual(len(page_breaks), 0)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TestHeadingProtection(unittest.TestCase):
|
|
58
|
+
"""Test that headings are carried to the next page when orphaned."""
|
|
59
|
+
|
|
60
|
+
def test_orphaned_heading_is_carried(self):
|
|
61
|
+
# Fill a page almost completely, then add a heading + body
|
|
62
|
+
flowables = []
|
|
63
|
+
# Add enough body text to nearly fill a page
|
|
64
|
+
for _ in range(20):
|
|
65
|
+
flowables.append(_make_body())
|
|
66
|
+
# Add a heading at the bottom — should be carried
|
|
67
|
+
flowables.append(_make_heading("Should Be Carried"))
|
|
68
|
+
flowables.append(_make_body("This follows the heading."))
|
|
69
|
+
|
|
70
|
+
result = paginate(flowables, frame_height=400, frame_width=468)
|
|
71
|
+
|
|
72
|
+
# There should be at least one page break
|
|
73
|
+
page_breaks = [i for i, f in enumerate(result) if isinstance(f, PageBreak)]
|
|
74
|
+
self.assertTrue(len(page_breaks) >= 1)
|
|
75
|
+
|
|
76
|
+
def test_carry_aborted_if_too_large(self):
|
|
77
|
+
# If the heading + content is more than 50% of page, don't carry
|
|
78
|
+
paginator = SmartPaginator(
|
|
79
|
+
frame_height=200,
|
|
80
|
+
frame_width=468,
|
|
81
|
+
carry_max=0.5,
|
|
82
|
+
)
|
|
83
|
+
flowables = [
|
|
84
|
+
_make_body("Short."),
|
|
85
|
+
_make_heading("Big Heading"),
|
|
86
|
+
# This alone nearly fills a page
|
|
87
|
+
_make_body("Very long content. " * 50),
|
|
88
|
+
]
|
|
89
|
+
# Should not crash
|
|
90
|
+
result = paginator.paginate(flowables)
|
|
91
|
+
self.assertTrue(len(result) >= 1)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TestParagraphSplitting(unittest.TestCase):
|
|
95
|
+
"""Test that large paragraphs are split across pages."""
|
|
96
|
+
|
|
97
|
+
def test_large_paragraph_splits(self):
|
|
98
|
+
flowables = [
|
|
99
|
+
_make_body("First page content."),
|
|
100
|
+
_make_body("Very long paragraph that should split. " * 100),
|
|
101
|
+
]
|
|
102
|
+
result = paginate(
|
|
103
|
+
flowables, frame_height=300, frame_width=468, split_enabled=True
|
|
104
|
+
)
|
|
105
|
+
page_breaks = [f for f in result if isinstance(f, PageBreak)]
|
|
106
|
+
self.assertTrue(len(page_breaks) >= 1)
|
|
107
|
+
|
|
108
|
+
def test_splitting_disabled(self):
|
|
109
|
+
flowables = [
|
|
110
|
+
_make_body("First page."),
|
|
111
|
+
_make_body("Long text. " * 100),
|
|
112
|
+
]
|
|
113
|
+
# With splitting disabled, should still paginate without error
|
|
114
|
+
result = paginate(
|
|
115
|
+
flowables, frame_height=300, frame_width=468, split_enabled=False
|
|
116
|
+
)
|
|
117
|
+
self.assertTrue(len(result) >= 1)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class TestSmartPaginatorConfig(unittest.TestCase):
|
|
121
|
+
"""Test that configuration parameters are respected."""
|
|
122
|
+
|
|
123
|
+
def test_custom_thresholds(self):
|
|
124
|
+
paginator = SmartPaginator(
|
|
125
|
+
frame_height=720,
|
|
126
|
+
frame_width=468,
|
|
127
|
+
carry_max=0.3,
|
|
128
|
+
min_page_fill=0.25,
|
|
129
|
+
search_depth=5,
|
|
130
|
+
)
|
|
131
|
+
self.assertEqual(paginator.carry_max, 0.3)
|
|
132
|
+
self.assertEqual(paginator.min_page_fill, 0.25)
|
|
133
|
+
self.assertEqual(paginator.search_depth, 5)
|
|
134
|
+
|
|
135
|
+
def test_convenience_function_kwargs(self):
|
|
136
|
+
# paginate() should pass kwargs to SmartPaginator
|
|
137
|
+
result = paginate(
|
|
138
|
+
[_make_body()],
|
|
139
|
+
frame_height=720,
|
|
140
|
+
frame_width=468,
|
|
141
|
+
carry_max=0.6,
|
|
142
|
+
min_page_fill=0.1,
|
|
143
|
+
)
|
|
144
|
+
self.assertTrue(len(result) >= 1)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == '__main__':
|
|
148
|
+
unittest.main()
|