codeannex 0.1.1__py3-none-any.whl
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.
- codeannex/__init__.py +0 -0
- codeannex/__main__.py +157 -0
- codeannex/config.py +65 -0
- codeannex/file_utils.py +116 -0
- codeannex/fonts.py +271 -0
- codeannex/highlight.py +21 -0
- codeannex/pdf_builder.py +446 -0
- codeannex/text_utils.py +128 -0
- codeannex-0.1.1.dist-info/METADATA +147 -0
- codeannex-0.1.1.dist-info/RECORD +14 -0
- codeannex-0.1.1.dist-info/WHEEL +5 -0
- codeannex-0.1.1.dist-info/entry_points.txt +2 -0
- codeannex-0.1.1.dist-info/licenses/LICENSE +21 -0
- codeannex-0.1.1.dist-info/top_level.txt +1 -0
codeannex/pdf_builder.py
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from PIL import Image as PilImage
|
|
6
|
+
from pygments.lexers import get_lexer_for_filename, TextLexer
|
|
7
|
+
from pygments.util import ClassNotFound
|
|
8
|
+
from reportlab.lib import colors
|
|
9
|
+
from reportlab.lib.utils import ImageReader
|
|
10
|
+
from reportlab.lib.units import mm
|
|
11
|
+
from reportlab.pdfbase import pdfmetrics
|
|
12
|
+
from reportlab.pdfgen import canvas
|
|
13
|
+
|
|
14
|
+
from .config import (
|
|
15
|
+
PAGE_W, PAGE_H, GUTTER_W,
|
|
16
|
+
CODE_FONT_SIZE, CODE_LINE_H,
|
|
17
|
+
COLOR_PAGE_BG, COLOR_TEXT_MAIN, COLOR_CODE_BG, COLOR_GUTTER_BG,
|
|
18
|
+
COLOR_HEADER_BG, COLOR_HEADER_FG, COLOR_ACCENT,
|
|
19
|
+
PDFConfig,
|
|
20
|
+
)
|
|
21
|
+
from .fonts import get_digit_sprites
|
|
22
|
+
from .highlight import get_token_color
|
|
23
|
+
from .text_utils import (
|
|
24
|
+
sanitize_text, get_safe_string_width,
|
|
25
|
+
draw_text_with_fallback, draw_centred_text_with_fallback,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _make_bookmark_key(display_name: str) -> str:
|
|
30
|
+
return re.sub(r"[/()\s]", lambda m: "_" if m.group() in "/ \t" else "", display_name)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ModernAnnexPDF:
|
|
34
|
+
def __init__(self, output_path_or_buffer, project_root: Path,
|
|
35
|
+
mono_font: str, emoji_font: str | None, config: PDFConfig | None = None):
|
|
36
|
+
self.c = canvas.Canvas(output_path_or_buffer, pagesize=(PAGE_W, PAGE_H))
|
|
37
|
+
self.project_root = project_root
|
|
38
|
+
self.mono_font = mono_font
|
|
39
|
+
self.emoji_font = emoji_font
|
|
40
|
+
self.config = config or PDFConfig()
|
|
41
|
+
# Use config fonts if specified
|
|
42
|
+
if self.config.mono_font:
|
|
43
|
+
self.mono_font = self.config.mono_font
|
|
44
|
+
if self.config.emoji_font:
|
|
45
|
+
self.emoji_font = self.config.emoji_font
|
|
46
|
+
self.page_num = self.config.start_page_num
|
|
47
|
+
self._first_page = True
|
|
48
|
+
self.y = 0
|
|
49
|
+
self.summary_data: dict = {}
|
|
50
|
+
self._registered_bookmarks: set = set()
|
|
51
|
+
self.is_simulation = False
|
|
52
|
+
|
|
53
|
+
# Define the project name
|
|
54
|
+
project_name = self.config.project_name or self.project_root.name
|
|
55
|
+
self.c.setTitle(f"Source code: {project_name}")
|
|
56
|
+
|
|
57
|
+
# ── Atalhos de desenho ───────────────────────
|
|
58
|
+
def _dtf(self, x, y, text, font, size, color=None):
|
|
59
|
+
draw_text_with_fallback(self.c, x, y, text, font, size, self.emoji_font, color,
|
|
60
|
+
emoji_description=self.config.emoji_description)
|
|
61
|
+
|
|
62
|
+
def _dctf(self, x, y, text, font, size, color=None):
|
|
63
|
+
draw_centred_text_with_fallback(self.c, x, y, text, font, size, self.emoji_font, color,
|
|
64
|
+
emoji_description=self.config.emoji_description)
|
|
65
|
+
|
|
66
|
+
def _gsw(self, text, font, size) -> float:
|
|
67
|
+
return get_safe_string_width(text, font, size, self.emoji_font,
|
|
68
|
+
emoji_description=self.config.emoji_description)
|
|
69
|
+
|
|
70
|
+
# ── Paginação ────────────────────────────────
|
|
71
|
+
def start_new_page(self):
|
|
72
|
+
if not self._first_page:
|
|
73
|
+
self.c.showPage()
|
|
74
|
+
self.page_num += 1
|
|
75
|
+
|
|
76
|
+
self._first_page = False
|
|
77
|
+
self.y = PAGE_H - self.config.margin_top
|
|
78
|
+
if not self.is_simulation:
|
|
79
|
+
self.c.setFillColor(COLOR_PAGE_BG)
|
|
80
|
+
self.c.rect(0, 0, PAGE_W, PAGE_H, fill=1, stroke=0)
|
|
81
|
+
self._draw_page_info()
|
|
82
|
+
|
|
83
|
+
def _draw_page_info(self):
|
|
84
|
+
self.c.setFont(self.config.normal_font, self.config.page_number_size)
|
|
85
|
+
self.c.setFillColor(COLOR_TEXT_MAIN)
|
|
86
|
+
self.c.drawRightString(PAGE_W - self.config.margin_right, 10*mm, f"{self.page_num}")
|
|
87
|
+
if self.config.show_project_name:
|
|
88
|
+
self._dtf(self.config.margin_left, 10*mm, f"Projeto: {self.config.project_name or self.project_root.name}",
|
|
89
|
+
self.config.normal_font, 8, COLOR_TEXT_MAIN)
|
|
90
|
+
|
|
91
|
+
def _check_space(self, needed_h: float):
|
|
92
|
+
if self.y - needed_h < self.config.margin_bottom:
|
|
93
|
+
self.start_new_page()
|
|
94
|
+
|
|
95
|
+
# ── Elementos comuns ─────────────────────────
|
|
96
|
+
def _draw_file_header(self, rel_path: str, continuation: str = ""):
|
|
97
|
+
h = 8 * mm
|
|
98
|
+
self._check_space(h + 15*mm)
|
|
99
|
+
if not self.is_simulation:
|
|
100
|
+
label = f"{rel_path} {continuation}".rstrip()
|
|
101
|
+
self.c.setFillColor(COLOR_HEADER_BG)
|
|
102
|
+
self.c.roundRect(self.config.margin_left, self.y - h,
|
|
103
|
+
PAGE_W - self.config.margin_left - self.config.margin_right, h, 2*mm, fill=1, stroke=0)
|
|
104
|
+
self.c.rect(self.config.margin_left, self.y - h,
|
|
105
|
+
PAGE_W - self.config.margin_left - self.config.margin_right, 2*mm, fill=1, stroke=0)
|
|
106
|
+
self._dtf(self.config.margin_left + 4*mm, self.y - h + 2.5*mm, label, self.config.bold_font, 9, COLOR_HEADER_FG)
|
|
107
|
+
self.y -= h
|
|
108
|
+
|
|
109
|
+
def _register_bookmark(self, display_name: str, bookmark_key: str):
|
|
110
|
+
if self.is_simulation:
|
|
111
|
+
self.summary_data.setdefault(bookmark_key, self.page_num)
|
|
112
|
+
else:
|
|
113
|
+
if bookmark_key not in self._registered_bookmarks:
|
|
114
|
+
self._registered_bookmarks.add(bookmark_key)
|
|
115
|
+
self.c.bookmarkPage(bookmark_key)
|
|
116
|
+
self.c.addOutlineEntry(display_name, bookmark_key, level=0)
|
|
117
|
+
|
|
118
|
+
# ── Capa ─────────────────────────────────────
|
|
119
|
+
def draw_cover(self):
|
|
120
|
+
self.start_new_page()
|
|
121
|
+
if not self.is_simulation:
|
|
122
|
+
mid_x = PAGE_W / 2
|
|
123
|
+
self._dctf(mid_x, PAGE_H * 0.60, "ANEXO TÉCNICO",
|
|
124
|
+
self.config.bold_font, 28, colors.HexColor("#1e1e2e"))
|
|
125
|
+
self._dctf(mid_x, PAGE_H * 0.53, "Documentação de Código-Fonte",
|
|
126
|
+
self.config.normal_font, 18, COLOR_TEXT_MAIN)
|
|
127
|
+
self.c.setStrokeColor(COLOR_ACCENT)
|
|
128
|
+
self.c.setLineWidth(1)
|
|
129
|
+
self.c.line(mid_x - 40*mm, PAGE_H * 0.5, mid_x + 40*mm, PAGE_H * 0.5)
|
|
130
|
+
|
|
131
|
+
project_name = self.config.project_name or self.project_root.name
|
|
132
|
+
if self.config.repo_url:
|
|
133
|
+
# Desenha "Repositório: " e depois o link com o nome do projeto
|
|
134
|
+
label_prefix = "Repositório: "
|
|
135
|
+
prefix_w = self._gsw(label_prefix, self.config.normal_font, 12)
|
|
136
|
+
name_w = self._gsw(project_name, self.config.normal_font, 12)
|
|
137
|
+
total_w = prefix_w + name_w
|
|
138
|
+
|
|
139
|
+
start_x = mid_x - total_w / 2
|
|
140
|
+
self._dtf(start_x, PAGE_H * 0.45, label_prefix, self.config.normal_font, 12, COLOR_TEXT_MAIN)
|
|
141
|
+
|
|
142
|
+
# O link em si (nome do projeto)
|
|
143
|
+
link_x = start_x + prefix_w
|
|
144
|
+
self._dtf(link_x, PAGE_H * 0.45, project_name, self.config.normal_font, 12, COLOR_ACCENT)
|
|
145
|
+
|
|
146
|
+
# Adicionar área clicável para o link
|
|
147
|
+
self.c.linkURL(self.config.repo_url,
|
|
148
|
+
(link_x, PAGE_H * 0.45 - 2, link_x + name_w, PAGE_H * 0.45 + 10),
|
|
149
|
+
relative=0, thickness=0, border=None)
|
|
150
|
+
else:
|
|
151
|
+
self._dctf(mid_x, PAGE_H * 0.45, f"Repositório: {project_name}",
|
|
152
|
+
self.config.normal_font, 12, COLOR_TEXT_MAIN)
|
|
153
|
+
|
|
154
|
+
# ── Sumário ──────────────────────────────────
|
|
155
|
+
def draw_summary_page(self, files: list):
|
|
156
|
+
self.start_new_page()
|
|
157
|
+
if not self.is_simulation:
|
|
158
|
+
self._dtf(self.config.margin_left, self.y, "Sumário / Índice de Arquivos",
|
|
159
|
+
self.config.bold_font, 16, COLOR_TEXT_MAIN)
|
|
160
|
+
self.y -= 12*mm
|
|
161
|
+
|
|
162
|
+
tree: dict = {}
|
|
163
|
+
seen: set = set()
|
|
164
|
+
for fpath, ftype in files:
|
|
165
|
+
rel = fpath.relative_to(self.project_root)
|
|
166
|
+
full_posix = rel.as_posix()
|
|
167
|
+
if full_posix in seen:
|
|
168
|
+
continue
|
|
169
|
+
seen.add(full_posix)
|
|
170
|
+
d = "." if len(rel.parts) == 1 else "/".join(rel.parts[:-1])
|
|
171
|
+
tree.setdefault(d, []).append((rel.name, _make_bookmark_key(full_posix)))
|
|
172
|
+
|
|
173
|
+
for d in tree:
|
|
174
|
+
self._check_space(15*mm)
|
|
175
|
+
if d != ".":
|
|
176
|
+
if not self.is_simulation:
|
|
177
|
+
self._dtf(self.config.margin_left + d.count("/") * 4*mm, self.y,
|
|
178
|
+
f"▶ {d}/", self.config.bold_font, 10, COLOR_ACCENT)
|
|
179
|
+
self.y -= 6*mm
|
|
180
|
+
|
|
181
|
+
for display_name, bookmark_key in tree[d]:
|
|
182
|
+
self._check_space(8*mm)
|
|
183
|
+
page_str = str(self.summary_data.get(bookmark_key, 0)) \
|
|
184
|
+
if not self.is_simulation else "000"
|
|
185
|
+
|
|
186
|
+
if not self.is_simulation:
|
|
187
|
+
base_indent = 0 if d == "." else (d.count("/") + 1) * 4*mm
|
|
188
|
+
file_indent = self.config.margin_left + base_indent + 4*mm
|
|
189
|
+
entry_text = f"□ {display_name}"
|
|
190
|
+
self._dtf(file_indent, self.y, entry_text, self.config.normal_font, 10, COLOR_TEXT_MAIN)
|
|
191
|
+
|
|
192
|
+
dot_w = self._gsw(".", self.config.normal_font, 10)
|
|
193
|
+
name_w = self._gsw(entry_text, self.config.normal_font, 10)
|
|
194
|
+
page_w = self._gsw(page_str, self.config.normal_font, 10)
|
|
195
|
+
avail = PAGE_W - file_indent - self.config.margin_right - name_w - page_w - 5
|
|
196
|
+
|
|
197
|
+
if avail > 0:
|
|
198
|
+
self._dtf(file_indent + name_w + 2.5, self.y,
|
|
199
|
+
"." * int(avail / dot_w), self.config.normal_font, 10, COLOR_TEXT_MAIN)
|
|
200
|
+
self._dtf(PAGE_W - self.config.margin_right - page_w, self.y,
|
|
201
|
+
page_str, self.config.normal_font, 10, COLOR_TEXT_MAIN)
|
|
202
|
+
self.c.linkRect("", bookmark_key,
|
|
203
|
+
(file_indent, self.y - 2, PAGE_W - self.config.margin_right, self.y + 10),
|
|
204
|
+
Border=[0, 0, 0])
|
|
205
|
+
self.y -= 6*mm
|
|
206
|
+
self.y -= 2*mm
|
|
207
|
+
|
|
208
|
+
self.y = 0
|
|
209
|
+
|
|
210
|
+
# ── Arquivo de texto ─────────────────────────
|
|
211
|
+
def render_text_file(self, fpath: Path, label_suffix=""):
|
|
212
|
+
rel = fpath.relative_to(self.project_root).as_posix()
|
|
213
|
+
display_name = rel + label_suffix
|
|
214
|
+
bookmark_key = _make_bookmark_key(rel) # chave sem sufixo — igual à do sumário
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
content = fpath.read_text(encoding="utf-8", errors="replace")
|
|
218
|
+
except Exception:
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
self._check_space(25*mm)
|
|
222
|
+
self._register_bookmark(display_name, bookmark_key)
|
|
223
|
+
|
|
224
|
+
bookmark_parts = f"{bookmark_key}__parts"
|
|
225
|
+
total_parts = self.summary_data.get(bookmark_parts, None)
|
|
226
|
+
|
|
227
|
+
header_suffix = ""
|
|
228
|
+
if total_parts:
|
|
229
|
+
header_suffix = f"(parte 1/{total_parts})"
|
|
230
|
+
self._draw_file_header(display_name, continuation=header_suffix)
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
lexer = get_lexer_for_filename(str(fpath), stripnl=False)
|
|
234
|
+
except ClassNotFound:
|
|
235
|
+
lexer = TextLexer(stripnl=False)
|
|
236
|
+
|
|
237
|
+
# Tokenizar e montar linhas
|
|
238
|
+
lines: list = [[]]
|
|
239
|
+
for ttype, value in lexer.get_tokens(content or " "):
|
|
240
|
+
color = get_token_color(ttype)
|
|
241
|
+
parts = value.split("\n")
|
|
242
|
+
for i, part in enumerate(parts):
|
|
243
|
+
if i > 0:
|
|
244
|
+
lines.append([])
|
|
245
|
+
if part:
|
|
246
|
+
lines[-1].append((part, color))
|
|
247
|
+
|
|
248
|
+
max_w = self.config.get_code_w() - 4*mm
|
|
249
|
+
|
|
250
|
+
def text_w(t: str) -> float:
|
|
251
|
+
# During simulation, uses fast approximation to avoid checking each character
|
|
252
|
+
if self.is_simulation:
|
|
253
|
+
try:
|
|
254
|
+
return pdfmetrics.stringWidth(t, self.mono_font, CODE_FONT_SIZE)
|
|
255
|
+
except Exception:
|
|
256
|
+
return len(t) * (CODE_FONT_SIZE * 0.6) # Heurística: ~60% da altura
|
|
257
|
+
return get_safe_string_width(t, self.mono_font, CODE_FONT_SIZE, self.emoji_font,
|
|
258
|
+
emoji_description=self.config.emoji_description)
|
|
259
|
+
|
|
260
|
+
def wrap_segment(part: str, color, curr_width: float,
|
|
261
|
+
curr_v_line: list, v_lines: list) -> tuple[list, float]:
|
|
262
|
+
"""Adiciona `part` à linha atual, quebrando por largura real quando necessário."""
|
|
263
|
+
pw = text_w(part)
|
|
264
|
+
if curr_width + pw <= max_w:
|
|
265
|
+
curr_v_line.append((part, color))
|
|
266
|
+
return curr_v_line, curr_width + pw
|
|
267
|
+
|
|
268
|
+
# Não cabe — tenta quebrar codepoint a codepoint
|
|
269
|
+
if curr_width > 0:
|
|
270
|
+
v_lines.append(curr_v_line)
|
|
271
|
+
curr_v_line, curr_width = [], 0.0
|
|
272
|
+
|
|
273
|
+
buf = ""
|
|
274
|
+
for ch in part:
|
|
275
|
+
cw = text_w(buf + ch)
|
|
276
|
+
if cw > max_w and buf:
|
|
277
|
+
v_lines.append([(buf, color)])
|
|
278
|
+
buf = ch
|
|
279
|
+
else:
|
|
280
|
+
buf += ch
|
|
281
|
+
if buf:
|
|
282
|
+
curr_v_line.append((buf, color))
|
|
283
|
+
curr_width = text_w(buf)
|
|
284
|
+
|
|
285
|
+
return curr_v_line, curr_width
|
|
286
|
+
|
|
287
|
+
line_idx = 1
|
|
288
|
+
page_part = 1
|
|
289
|
+
|
|
290
|
+
for original_line in lines:
|
|
291
|
+
v_lines: list = []
|
|
292
|
+
curr_v_line: list = []
|
|
293
|
+
curr_width = 0.0
|
|
294
|
+
|
|
295
|
+
for text, color in original_line:
|
|
296
|
+
text = sanitize_text(text.replace("\r", "").replace("\t", " "))
|
|
297
|
+
for part in re.split(r"( +)", text):
|
|
298
|
+
if not part:
|
|
299
|
+
continue
|
|
300
|
+
curr_v_line, curr_width = wrap_segment(
|
|
301
|
+
part, color, curr_width, curr_v_line, v_lines
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
if curr_v_line:
|
|
305
|
+
v_lines.append(curr_v_line)
|
|
306
|
+
if not v_lines:
|
|
307
|
+
v_lines.append([])
|
|
308
|
+
|
|
309
|
+
for i, v_line in enumerate(v_lines):
|
|
310
|
+
if self.y - CODE_LINE_H < self.config.margin_bottom:
|
|
311
|
+
page_part += 1
|
|
312
|
+
suffix_str = f"(parte {page_part}/{total_parts})" if total_parts else f"(parte {page_part})"
|
|
313
|
+
self._draw_file_header(display_name, continuation=suffix_str)
|
|
314
|
+
|
|
315
|
+
if not self.is_simulation:
|
|
316
|
+
base_y = self.y - CODE_LINE_H
|
|
317
|
+
block_w = PAGE_W - self.config.margin_left - self.config.margin_right
|
|
318
|
+
self.c.setFillColor(COLOR_CODE_BG)
|
|
319
|
+
self.c.rect(self.config.margin_left, base_y, block_w, CODE_LINE_H, fill=1, stroke=0)
|
|
320
|
+
self.c.setFillColor(COLOR_GUTTER_BG)
|
|
321
|
+
self.c.rect(self.config.margin_left, base_y, GUTTER_W, CODE_LINE_H, fill=1, stroke=0)
|
|
322
|
+
|
|
323
|
+
if i == 0:
|
|
324
|
+
num_str = str(line_idx)
|
|
325
|
+
num_char_w = pdfmetrics.stringWidth("0", self.mono_font, CODE_FONT_SIZE)
|
|
326
|
+
box_hw = CODE_FONT_SIZE * 0.8
|
|
327
|
+
start_x = self.config.margin_left + GUTTER_W - 2*mm - len(num_str) * num_char_w
|
|
328
|
+
for char in num_str:
|
|
329
|
+
self.c.drawImage(
|
|
330
|
+
get_digit_sprites()[char],
|
|
331
|
+
start_x + (num_char_w - box_hw) / 2.0,
|
|
332
|
+
(self.y - CODE_FONT_SIZE - 1) - box_hw * 0.2,
|
|
333
|
+
width=box_hw, height=box_hw, mask="auto",
|
|
334
|
+
)
|
|
335
|
+
start_x += num_char_w
|
|
336
|
+
|
|
337
|
+
code_x = self.config.get_code_x() + 2*mm
|
|
338
|
+
for chunk, color in v_line:
|
|
339
|
+
code_x = draw_text_with_fallback(
|
|
340
|
+
self.c, code_x, self.y - CODE_FONT_SIZE - 1,
|
|
341
|
+
chunk, self.mono_font, CODE_FONT_SIZE, self.emoji_font, color,
|
|
342
|
+
emoji_description=self.config.emoji_description,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
self.y -= CODE_LINE_H
|
|
346
|
+
line_idx += 1
|
|
347
|
+
|
|
348
|
+
if self.is_simulation and page_part > 1:
|
|
349
|
+
self.summary_data[bookmark_parts] = page_part
|
|
350
|
+
|
|
351
|
+
self.y -= 4*mm
|
|
352
|
+
|
|
353
|
+
# ── Arquivo de imagem ────────────────────────
|
|
354
|
+
def render_image_file(self, fpath: Path, label_suffix=""):
|
|
355
|
+
rel = fpath.relative_to(self.project_root).as_posix()
|
|
356
|
+
display_name = rel + label_suffix
|
|
357
|
+
bookmark_key = _make_bookmark_key(rel) # chave sem sufixo — igual à do sumário
|
|
358
|
+
|
|
359
|
+
self._check_space(40*mm)
|
|
360
|
+
self._register_bookmark(display_name, bookmark_key)
|
|
361
|
+
self._draw_file_header(display_name)
|
|
362
|
+
|
|
363
|
+
is_svg = fpath.suffix.lower() == ".svg"
|
|
364
|
+
max_w = PAGE_W - self.config.margin_left - self.config.margin_right - 10*mm
|
|
365
|
+
max_h = self.y - self.config.margin_bottom - 10*mm
|
|
366
|
+
img, png_data, drawing = None, None, None
|
|
367
|
+
img_w = img_h = 0
|
|
368
|
+
|
|
369
|
+
if is_svg:
|
|
370
|
+
try:
|
|
371
|
+
import cairosvg
|
|
372
|
+
png_data = cairosvg.svg2png(url=str(fpath), scale=2.0)
|
|
373
|
+
img = PilImage.open(io.BytesIO(png_data))
|
|
374
|
+
img_w, img_h = img.size
|
|
375
|
+
except ImportError:
|
|
376
|
+
try:
|
|
377
|
+
from svglib.svglib import svg2rlg
|
|
378
|
+
drawing = svg2rlg(str(fpath))
|
|
379
|
+
if drawing:
|
|
380
|
+
img_w, img_h = drawing.width, drawing.height
|
|
381
|
+
except Exception:
|
|
382
|
+
pass
|
|
383
|
+
else:
|
|
384
|
+
try:
|
|
385
|
+
img = PilImage.open(fpath)
|
|
386
|
+
img_w, img_h = img.size
|
|
387
|
+
except Exception:
|
|
388
|
+
pass
|
|
389
|
+
|
|
390
|
+
if img_w > 0 and img_h > 0:
|
|
391
|
+
scale = min(max_w / img_w, max_h / img_h, 1.0)
|
|
392
|
+
draw_w = img_w * scale
|
|
393
|
+
draw_h = img_h * scale
|
|
394
|
+
padding = 5*mm
|
|
395
|
+
block_top = self.y
|
|
396
|
+
draw_y = block_top - draw_h - padding
|
|
397
|
+
block_bottom = draw_y - padding
|
|
398
|
+
block_w = PAGE_W - self.config.margin_left - self.config.margin_right
|
|
399
|
+
|
|
400
|
+
if not self.is_simulation:
|
|
401
|
+
self.c.setFillColor(colors.HexColor("#ffffff"))
|
|
402
|
+
self.c.rect(self.config.margin_left, block_bottom, block_w, block_top - block_bottom,
|
|
403
|
+
fill=1, stroke=0)
|
|
404
|
+
self.c.setStrokeColor(colors.HexColor("#e6e9ef"))
|
|
405
|
+
self.c.setLineWidth(0.5)
|
|
406
|
+
self.c.rect(self.config.margin_left, block_bottom, block_w, block_top - block_bottom,
|
|
407
|
+
fill=0, stroke=1)
|
|
408
|
+
|
|
409
|
+
draw_x = self.config.margin_left + (block_w - draw_w) / 2
|
|
410
|
+
if is_svg and png_data is None and drawing is not None:
|
|
411
|
+
from reportlab.graphics import renderPDF
|
|
412
|
+
drawing.width *= scale
|
|
413
|
+
drawing.height *= scale
|
|
414
|
+
drawing.transform = (scale, 0, 0, scale, 0, 0)
|
|
415
|
+
renderPDF.draw(drawing, self.c, draw_x, draw_y)
|
|
416
|
+
else:
|
|
417
|
+
if img is None:
|
|
418
|
+
img = PilImage.open(io.BytesIO(png_data)) if png_data else PilImage.open(fpath)
|
|
419
|
+
self.c.drawImage(ImageReader(img), draw_x, draw_y, draw_w, draw_h,
|
|
420
|
+
preserveAspectRatio=True, mask="auto")
|
|
421
|
+
|
|
422
|
+
self.y = block_bottom - 4*mm
|
|
423
|
+
else:
|
|
424
|
+
if not self.is_simulation:
|
|
425
|
+
self.c.setFont(self.config.normal_font, 9)
|
|
426
|
+
self.c.setFillColor(colors.HexColor("#f38ba8"))
|
|
427
|
+
self.c.drawString(self.config.margin_left, self.y - 10*mm,
|
|
428
|
+
"[Error or missing library to render image]")
|
|
429
|
+
self.y -= 15*mm
|
|
430
|
+
|
|
431
|
+
# ── Main build ──────────────────────────
|
|
432
|
+
def build(self, files: list):
|
|
433
|
+
self.draw_cover()
|
|
434
|
+
self.draw_summary_page(files)
|
|
435
|
+
for i, (fpath, ftype) in enumerate(files):
|
|
436
|
+
if not self.is_simulation:
|
|
437
|
+
print(f"\r\033[K[{i+1}/{len(files)}] Processing: {fpath.name}", end="")
|
|
438
|
+
svg_suffix = " (Code)" if ftype == "text" else " (Image)"
|
|
439
|
+
suffix = svg_suffix if fpath.suffix.lower() == ".svg" else ""
|
|
440
|
+
if ftype == "text":
|
|
441
|
+
self.render_text_file(fpath, label_suffix=suffix)
|
|
442
|
+
elif ftype == "image":
|
|
443
|
+
self.render_image_file(fpath, label_suffix=suffix)
|
|
444
|
+
if not self.is_simulation:
|
|
445
|
+
print()
|
|
446
|
+
self.c.save()
|
codeannex/text_utils.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import unicodedata
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from reportlab.pdfbase import pdfmetrics
|
|
4
|
+
|
|
5
|
+
from .fonts import is_char_supported, is_emoji
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def sanitize_text(text: str) -> str:
|
|
9
|
+
"""Remove caracteres de controle invisíveis que quebram o PDF, mantendo emojis intactos."""
|
|
10
|
+
return "".join(
|
|
11
|
+
c if (ord(c) >= 0x20 and ord(c) != 0x7F) or ord(c) in (0x09, 0x0A, 0x0D)
|
|
12
|
+
else " "
|
|
13
|
+
for c in text
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def replace_unsupported_emojis(text: str, main_font: str, emoji_font: str | None,
|
|
18
|
+
emoji_description: bool = False) -> str:
|
|
19
|
+
"""Emojis are rendered natively when emoji_font is properly configured.
|
|
20
|
+
If emoji_description is True, emojis are replaced by their [NAME]."""
|
|
21
|
+
if not emoji_description:
|
|
22
|
+
return text
|
|
23
|
+
|
|
24
|
+
result = []
|
|
25
|
+
for c in text:
|
|
26
|
+
if is_emoji(c):
|
|
27
|
+
try:
|
|
28
|
+
name = unicodedata.name(c)
|
|
29
|
+
result.append(f"[{name}]")
|
|
30
|
+
except Exception:
|
|
31
|
+
result.append("[EMOJI]")
|
|
32
|
+
else:
|
|
33
|
+
result.append(c)
|
|
34
|
+
return "".join(result)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _iter_segments(text: str, main_font: str, emoji_font: str | None,
|
|
38
|
+
emoji_description: bool = False):
|
|
39
|
+
"""Agrupa caracteres consecutivos que usam a mesma fonte, gerando (segmento, usa_emoji)."""
|
|
40
|
+
text = replace_unsupported_emojis(text, main_font, emoji_font, emoji_description)
|
|
41
|
+
segment, using_emoji = "", False
|
|
42
|
+
for c in text:
|
|
43
|
+
# Detecta emojis diretamente pelos ranges Unicode
|
|
44
|
+
needs_emoji = (
|
|
45
|
+
is_emoji(c)
|
|
46
|
+
and emoji_font is not None
|
|
47
|
+
and not emoji_description
|
|
48
|
+
)
|
|
49
|
+
if needs_emoji != using_emoji:
|
|
50
|
+
if segment:
|
|
51
|
+
yield segment, using_emoji
|
|
52
|
+
segment, using_emoji = "", needs_emoji
|
|
53
|
+
segment += c
|
|
54
|
+
if segment:
|
|
55
|
+
yield segment, using_emoji
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _draw_segment(canvas_obj, x: float, y: float, seg: str,
|
|
59
|
+
font: str, main_font: str, font_size: float) -> float:
|
|
60
|
+
"""Draws a segment and returns the new x. Falls back to ASCII in case of error."""
|
|
61
|
+
curr_x = x
|
|
62
|
+
for c in seg:
|
|
63
|
+
if is_emoji(c):
|
|
64
|
+
# Use emoji font with slightly larger size for better appearance
|
|
65
|
+
emoji_size = font_size * 1.1
|
|
66
|
+
canvas_obj.setFont(font, emoji_size)
|
|
67
|
+
try:
|
|
68
|
+
canvas_obj.drawString(curr_x, y, c)
|
|
69
|
+
curr_x += pdfmetrics.stringWidth(c, font, emoji_size)
|
|
70
|
+
except Exception as e:
|
|
71
|
+
canvas_obj.setFont(main_font, font_size)
|
|
72
|
+
canvas_obj.drawString(curr_x, y, "?")
|
|
73
|
+
curr_x += pdfmetrics.stringWidth("?", main_font, font_size)
|
|
74
|
+
else:
|
|
75
|
+
canvas_obj.setFont(font, font_size)
|
|
76
|
+
try:
|
|
77
|
+
canvas_obj.drawString(curr_x, y, c)
|
|
78
|
+
curr_x += pdfmetrics.stringWidth(c, font, font_size)
|
|
79
|
+
except Exception:
|
|
80
|
+
canvas_obj.setFont(main_font, font_size)
|
|
81
|
+
clean = c if ord(c) <= 255 else "?"
|
|
82
|
+
canvas_obj.drawString(curr_x, y, clean)
|
|
83
|
+
curr_x += pdfmetrics.stringWidth(clean, main_font, font_size)
|
|
84
|
+
return curr_x
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_safe_string_width(text: str, main_font: str, font_size: float,
|
|
88
|
+
emoji_font: str | None, emoji_description: bool = False) -> float:
|
|
89
|
+
total = 0.0
|
|
90
|
+
for seg, using_emoji in _iter_segments(text, main_font, emoji_font, emoji_description):
|
|
91
|
+
for c in seg:
|
|
92
|
+
if is_emoji(c) and emoji_font and not emoji_description:
|
|
93
|
+
emoji_size = font_size * 1.1
|
|
94
|
+
try:
|
|
95
|
+
total += pdfmetrics.stringWidth(c, emoji_font, emoji_size)
|
|
96
|
+
except Exception:
|
|
97
|
+
total += pdfmetrics.stringWidth("?", main_font, font_size)
|
|
98
|
+
else:
|
|
99
|
+
font = emoji_font if (using_emoji and not emoji_description) else main_font
|
|
100
|
+
try:
|
|
101
|
+
total += pdfmetrics.stringWidth(c, font, font_size)
|
|
102
|
+
except Exception:
|
|
103
|
+
clean = c if ord(c) <= 255 else "?"
|
|
104
|
+
total += pdfmetrics.stringWidth(clean, main_font, font_size)
|
|
105
|
+
return total
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def draw_text_with_fallback(canvas_obj, x: float, y: float, text: str,
|
|
109
|
+
main_font: str, font_size: float,
|
|
110
|
+
emoji_font: str | None, text_color=None,
|
|
111
|
+
emoji_description: bool = False) -> float:
|
|
112
|
+
if text_color:
|
|
113
|
+
canvas_obj.setFillColor(text_color)
|
|
114
|
+
curr_x = x
|
|
115
|
+
for seg, using_emoji in _iter_segments(text, main_font, emoji_font, emoji_description):
|
|
116
|
+
font = emoji_font if (using_emoji and not emoji_description) else main_font
|
|
117
|
+
curr_x = _draw_segment(canvas_obj, curr_x, y, seg, font, main_font, font_size)
|
|
118
|
+
return curr_x
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def draw_centred_text_with_fallback(canvas_obj, x: float, y: float, text: str,
|
|
122
|
+
main_font: str, font_size: float,
|
|
123
|
+
emoji_font: str | None, text_color=None,
|
|
124
|
+
emoji_description: bool = False):
|
|
125
|
+
w = get_safe_string_width(text, main_font, font_size, emoji_font, emoji_description)
|
|
126
|
+
draw_text_with_fallback(canvas_obj, x - w / 2, y, text,
|
|
127
|
+
main_font, font_size, emoji_font, text_color,
|
|
128
|
+
emoji_description)
|