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.
@@ -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()
@@ -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)