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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,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()