|
13 | 13 | # You should have received a copy of the GNU Affero General Public License |
14 | 14 | # along with this program. If not, see <http://www.gnu.org/licenses/>. |
15 | 15 |
|
16 | | -"""Pdf support for the RERO invenio instances.""" |
| 16 | +"""PDF support module for RERO Invenio instances.""" |
17 | 17 |
|
18 | 18 | import os |
| 19 | +import random |
| 20 | +import textwrap |
| 21 | +from pathlib import Path |
19 | 22 |
|
20 | 23 | from fpdf import FPDF |
| 24 | +from PIL import Image |
21 | 25 |
|
22 | 26 |
|
23 | 27 | 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.""" |
25 | 29 |
|
26 | 30 | def __init__(self, data, *arg, **kwargs): |
27 | | - """Create a PDFGenerator object. |
| 31 | + """Initialize the PDFGenerator instance with content, fonts, logo, and graph. |
28 | 32 |
|
29 | 33 | 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 | +
|
37 | 42 | :param data: dict - the given data. |
38 | 43 | """ |
39 | 44 | 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 | + |
41 | 49 | 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 |
54 | 69 |
|
55 | 70 | 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) |
57 | 74 | self.set_font("NotoSans", size=14) |
| 75 | + self.set_xy(self.l_margin + 25, 10) |
58 | 76 | self.cell( |
59 | 77 | 0, |
60 | 78 | 10, |
61 | 79 | self.data.get("header", "Generated using RERO Invenio Files"), |
62 | | - align="R", |
| 80 | + align="L", |
63 | 81 | border="B", |
64 | 82 | ) |
65 | 83 | self.ln(20) |
66 | 84 |
|
| 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 | + |
67 | 94 | 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.""" |
69 | 96 | self.add_page() |
| 97 | + |
| 98 | + # Add title |
70 | 99 | if title := self.data.get("title"): |
71 | 100 | self.set_font("NotoSans", size=24) |
72 | 101 | self.multi_cell(0, 8, title, align="C") |
73 | 102 | self.ln(4) |
74 | 103 |
|
| 104 | + # Add authors |
75 | 105 | 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") |
78 | 108 | self.ln(4) |
79 | 109 |
|
| 110 | + # Add summary text with a background box |
| 111 | + summary_height = 0 |
80 | 112 | 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 | + |
81 | 121 | 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() |
83 | 126 |
|
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) |
0 commit comments