Skip to content

Commit 5cbdbe6

Browse files
Nathan PythonNathanPython2002
authored andcommitted
feat(pdf): improve PDFGenerator with better layout and image handling
- Adds custom PDF generation with headers, footers, titles, authors, summaries, paragraphs and optional graphs. - Implements PDFGenerator class. - Supports loading custom fonts. - Randomly selects logos and graphs from predefined folders. - Verifies PDF can be generated with standard and empty data. - Checks that header and footer are correctly rendered. - Covers random file selection, paragraph rendering and graphic placement. Co-Authored-by: Nathan Python <nathan.python@hes-so.ch>
1 parent ad6d89f commit 5cbdbe6

File tree

12 files changed

+252
-38
lines changed

12 files changed

+252
-38
lines changed

rero_invenio_files/pdf/__init__.py

Lines changed: 167 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -13,77 +13,209 @@
1313
# You should have received a copy of the GNU Affero General Public License
1414
# along with this program. If not, see <http://www.gnu.org/licenses/>.
1515

16-
"""Pdf support for the RERO invenio instances."""
16+
"""PDF support module for RERO Invenio instances."""
1717

1818
import os
19+
import random
20+
import textwrap
21+
from pathlib import Path
1922

2023
from fpdf import FPDF
24+
from PIL import Image
2125

2226

2327
class PDFGenerator(FPDF):
24-
"""Generate a PDF file from a given data."""
28+
"""Class to generate PDFs for RERO Invenio with custom headers, footers, text, and images."""
2529

2630
def __init__(self, data, *arg, **kwargs):
27-
"""Create a PDFGenerator object.
31+
"""Initialize the PDFGenerator instance with content, fonts, logo, and graph.
2832
2933
Example of input data:
30-
.. code-block:: python
31-
data = dict(
32-
header='Header',
33-
title='Simple Title',
34-
authors=['Author1, Author2'],
35-
summary='Summary'
36-
)
34+
PDFGenerator(
35+
data={
36+
'title': 'Example document',
37+
'authors': ['Author1', 'Author2'],
38+
'summary': 'Summary text here'
39+
}
40+
)
41+
3742
:param data: dict - the given data.
3843
"""
3944
self.data = data
40-
font_dir = os.path.join(os.path.dirname(__file__), "fonts")
45+
46+
current_dir = Path(__file__).parent
47+
font_dir = current_dir / "fonts"
48+
4149
super().__init__(*arg, **kwargs)
42-
self.add_font(
43-
"NotoSans",
44-
style="",
45-
fname=os.path.join(font_dir, "NotoSans-Regular.ttf"),
46-
uni=True,
47-
)
48-
self.add_font(
49-
"NotoSans",
50-
style="I",
51-
fname=os.path.join(font_dir, "NotoSans-Italic.ttf"),
52-
uni=True,
53-
)
50+
51+
# Load custom fonts
52+
self.add_font("NotoSans", style="", fname=str(font_dir / "NotoSans-Regular.ttf"))
53+
self.add_font("NotoSans", style="I", fname=str(font_dir / "NotoSans-Italic.ttf"))
54+
self.add_font("NotoSans", style="B", fname=str(font_dir / "NotoSans-Bold.ttf"))
55+
self.add_font("NotoSans", style="BI", fname=str(font_dir / "NotoSans-BoldItalic.ttf"))
56+
57+
logo_folder = current_dir / "logos"
58+
graph_folder = current_dir / "graphs"
59+
60+
self.logo_path = self._select_random_file(logo_folder, [".png", ".jpg", ".jpeg"])
61+
self.graph_path = self._select_random_file(graph_folder, [".png", ".jpg", ".jpeg"])
62+
63+
def _select_random_file(self, folder_path, extensions):
64+
"""Select a random file from a folder with the specified extensions."""
65+
if not folder_path.exists():
66+
return None
67+
files = [f for f in folder_path.iterdir() if f.is_file() and f.suffix.lower() in extensions]
68+
return str(random.choice(files)) if files else None
5469

5570
def header(self):
56-
"""Generate the header page."""
71+
"""Draw the PDF header including logo and header text."""
72+
if self.logo_path and os.path.exists(self.logo_path):
73+
self.image(self.logo_path, x=self.l_margin, y=10, w=20)
5774
self.set_font("NotoSans", size=14)
75+
self.set_xy(self.l_margin + 25, 10)
5876
self.cell(
5977
0,
6078
10,
6179
self.data.get("header", "Generated using RERO Invenio Files"),
62-
align="R",
80+
align="L",
6381
border="B",
6482
)
6583
self.ln(20)
6684

85+
def footer(self):
86+
"""Draw the PDF footer with a separating line and page numbers."""
87+
self.set_y(-15)
88+
self.set_font("NotoSans", "I", 8)
89+
self.set_draw_color(200, 200, 200)
90+
self.set_line_width(0.2)
91+
self.line(self.l_margin, self.get_y(), self.w - self.r_margin, self.get_y())
92+
self.cell(0, 10, f"Page {self.page_no()}/{{nb}}", align="R")
93+
6794
def render(self):
68-
"""Render the main pdf page body."""
95+
"""Generate the main content of the PDF including title, authors, summary, paragraph, and graph."""
6996
self.add_page()
97+
98+
# Add title
7099
if title := self.data.get("title"):
71100
self.set_font("NotoSans", size=24)
72101
self.multi_cell(0, 8, title, align="C")
73102
self.ln(4)
74103

104+
# Add authors
75105
if authors := self.data.get("authors"):
76-
self.set_font("NotoSans", "I", size=14)
77-
self.multi_cell(0, 6, "; ".join(authors), padding=(0, 50, 0), align="C")
106+
self.set_font("NotoSans", "I", 14)
107+
self.multi_cell(0, 6, "; ".join(authors), align="C")
78108
self.ln(4)
79109

110+
# Add summary text with a background box
111+
summary_height = 0
80112
if summary := self.data.get("summary"):
113+
page_width = self.w - 2 * self.l_margin
114+
x = self.l_margin
115+
y = self.get_y()
116+
padding_top = 5
117+
padding_bottom = 5
118+
padding_sides = 10
119+
line_height = 6
120+
81121
self.set_font("NotoSans", size=12)
82-
self.multi_cell(0, 5, summary, padding=(0, 20, 0))
122+
self.set_xy(x + padding_sides, y + padding_top)
123+
start_y = self.get_y()
124+
self.multi_cell(page_width - 2 * padding_sides, line_height, summary, align="J")
125+
end_y = self.get_y()
83126

84-
def footer(self):
85-
"""Generate the page footer."""
86-
self.set_y(-15)
87-
self.set_font("NotoSans", "I", 8)
88-
# printing the page number
89-
self.cell(0, 10, f"Page {self.page_no()}/{{nb}}", align="C", border="T")
127+
text_height = end_y - start_y
128+
summary_height = text_height + padding_top + padding_bottom
129+
130+
# Draw background rectangle for summary
131+
self.set_fill_color(230, 230, 230)
132+
self.rect(x, y, page_width, summary_height, style="F")
133+
134+
self.set_xy(x + padding_sides, y + padding_top)
135+
self.multi_cell(page_width - 2 * padding_sides, line_height, summary, align="J")
136+
137+
# Calculate available space for paragraph
138+
graph_h = (self.w - 2 * self.l_margin - 20) * 0.5
139+
graph_y = self.h - self.b_margin - graph_h - 10
140+
top_y = self.get_y()
141+
available_height = graph_y - top_y
142+
143+
# Add paragraph with identical top and bottom spacing, justified
144+
self._add_paragraph_fixed_height(max_height=available_height, top_padding=5)
145+
146+
# Add the graph at the bottom if available
147+
self._add_graphic_fixed_bottom()
148+
149+
def _add_paragraph_fixed_height(self, max_height, top_padding=5):
150+
"""Add a paragraph block with fixed height, justified, and invisible background.
151+
152+
:param max_height: int - maximum height (in points) allowed for the paragraph block.
153+
:param top_padding: int - optional top padding (default: 5).
154+
"""
155+
self.set_font("NotoSans", size=11)
156+
text = self.data.get(
157+
"paragraph",
158+
"Lorem ipsum placeholder text.",
159+
)
160+
161+
page_width = self.w - 2 * self.l_margin
162+
x = self.l_margin
163+
y = self.get_y()
164+
padding_sides = 10
165+
line_height = 6
166+
padding_bottom = top_padding
167+
168+
# Set cursor at top of paragraph
169+
self.set_xy(x + padding_sides, y + top_padding)
170+
171+
# Wrap text to fit width
172+
wrapped_lines = textwrap.wrap(text, width=95)
173+
174+
# Calculate max lines to fit the available height
175+
max_lines = int((max_height - top_padding - padding_bottom) / line_height)
176+
177+
# Truncate one line earlier to avoid partial lines
178+
if max_lines > 0:
179+
max_lines -= 1
180+
181+
wrapped_lines = wrapped_lines[:max_lines]
182+
183+
# Justified text
184+
self.multi_cell(page_width - 2 * padding_sides, line_height, " ".join(wrapped_lines), align="J")
185+
186+
# Move Y to the bottom of paragraph space
187+
self.set_y(y + max_height)
188+
189+
def _add_graphic_fixed_bottom(self):
190+
"""Add a graphic image at a fixed position near the bottom of the page."""
191+
if not self.graph_path or not os.path.exists(self.graph_path):
192+
return
193+
194+
chart_w = self.w - 2 * self.l_margin - 20
195+
chart_h = chart_w * 0.5
196+
chart_x = self.l_margin + 10
197+
chart_y = self.h - self.b_margin - chart_h - 10
198+
199+
# Draw background rectangle for graph
200+
self.set_fill_color(245, 245, 245)
201+
self.rect(chart_x - 3, chart_y - 3, chart_w + 6, chart_h + 6, style="F")
202+
203+
# Open image and preserve aspect ratio
204+
with Image.open(self.graph_path) as img:
205+
orig_w, orig_h = img.size
206+
ratio = orig_h / orig_w
207+
new_h = chart_w * ratio
208+
if new_h > chart_h:
209+
new_h = chart_h
210+
new_w = chart_h / ratio
211+
else:
212+
new_w = chart_w
213+
214+
# Center image inside rectangle
215+
img_x = chart_x + (chart_w - new_w) / 2
216+
img_y = chart_y + (chart_h - new_h) / 2
217+
self.image(self.graph_path, x=img_x, y=img_y, w=new_w, h=new_h)
218+
219+
# Ensure text cursor does not overlap graph
220+
if self.get_y() < chart_y - 5:
221+
self.set_y(chart_y - 5)
25.5 KB
Loading
28.4 KB
Loading
19.2 KB
Loading
31.2 KB
Loading
47.4 KB
Loading
42.7 KB
Loading
38.9 KB
Loading
43.7 KB
Loading

test.pdf

84.5 KB
Binary file not shown.

0 commit comments

Comments
 (0)