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 ADDED
File without changes
codeannex/__main__.py ADDED
@@ -0,0 +1,157 @@
1
+ import argparse
2
+ import io
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from .config import IMAGE_EXTENSIONS, BINARY_EXTENSIONS, PDFConfig
7
+ from .file_utils import get_project_files, classify_file, sort_files
8
+ from .fonts import init_sprites, register_best_font, register_emoji_font
9
+ from .pdf_builder import ModernAnnexPDF
10
+ from reportlab.lib.units import cm
11
+
12
+
13
+ def check_emoji_font_style():
14
+ """Utility function to check current emoji font style."""
15
+ from .fonts import register_emoji_font, get_emoji_font_style, is_google_like_emoji_font
16
+
17
+ emoji_font = register_emoji_font()
18
+ if emoji_font:
19
+ # Tentar obter informações sobre a fonte
20
+ # Nota: Esta é uma simplificação - em produção teríamos o caminho armazenado
21
+ print(f"✅ Emoji font registered: {emoji_font}")
22
+ print("ℹ️ To check if using Google-like style, look for 'Noto' in the font path above")
23
+ print("💡 Tip: Install Google Noto fonts for authentic Google emoji style")
24
+ else:
25
+ print("⚠️ No emoji font found - emojis may not render correctly")
26
+ return emoji_font
27
+
28
+
29
+ def main():
30
+ parser = argparse.ArgumentParser(description="Generates a PDF code annex with Smart Index and Images.")
31
+ parser.add_argument("dir", nargs="?", default=".", help="Project directory")
32
+ parser.add_argument("-o", "--output", default=None, help="Output PDF filename")
33
+ parser.add_argument("-n", "--name", default=None, help="Project name (default: directory name)")
34
+ parser.add_argument("--margin", type=float, default=None, help="General margin in cm for all sides")
35
+ parser.add_argument("--margin-left", type=float, default=None, help="Left margin in cm (default: 1.5cm)")
36
+ parser.add_argument("--margin-right", type=float, default=None, help="Right margin in cm (default: 1.5cm)")
37
+ parser.add_argument("--margin-top", type=float, default=None, help="Top margin in cm (default: 2.0cm)")
38
+ parser.add_argument("--margin-bottom", type=float, default=None, help="Bottom margin in cm (default: 2.0cm)")
39
+ parser.add_argument("--start-page", type=int, default=1, help="Starting page number (default: 1)")
40
+ parser.add_argument("--show-project", action="store_true", help="Show project name in footer")
41
+ parser.add_argument("--repo-url", default=None, help="Repository URL to show on cover")
42
+ parser.add_argument("--page-number-size", type=int, default=8, help="Font size for page numbers (default: 8)")
43
+ parser.add_argument("--normal-font", default=None, help="Normal text font (default: Helvetica)")
44
+ parser.add_argument("--bold-font", default=None, help="Bold text font (default: Helvetica-Bold)")
45
+ parser.add_argument("--mono-font", default=None, help="Monospace font for code (default: auto-detect)")
46
+ parser.add_argument("--emoji-font", default=None, help="Font for emojis (default: auto-detect)")
47
+ parser.add_argument("--emoji-description", action="store_true", help="Print [description] instead of emoji glyphs")
48
+ parser.add_argument("--check-emoji-font", action="store_true", help="Check current emoji font style and exit")
49
+
50
+ args, unknown = parser.parse_known_args()
51
+
52
+ # Handle unknown arguments
53
+ if unknown:
54
+ for u in unknown:
55
+ if u.startswith("-"):
56
+ print(f"⚠️ Warning: Unrecognized argument ignored: {u}")
57
+
58
+ # Handle emoji font check
59
+ if args.check_emoji_font:
60
+ check_emoji_font_style()
61
+ return
62
+
63
+ root = Path(args.dir).resolve()
64
+ output = args.output or f"{root.name}_anexo_codigo.pdf"
65
+
66
+ script_path = Path(os.path.abspath(__file__))
67
+ output_path = Path(output).resolve()
68
+
69
+ mono_font, is_ttf, ttf_path = register_best_font()
70
+ # If --emoji-description is NOT set, we MUST have an emoji font or we exit with error
71
+ emoji_font = register_emoji_font(error_on_missing=not args.emoji_description)
72
+ init_sprites(is_ttf, ttf_path)
73
+
74
+ # Determine margin values (specific margins override general --margin)
75
+ def get_margin(spec, general, default):
76
+ if spec is not None:
77
+ return spec * cm
78
+ if general is not None:
79
+ return general * cm
80
+ return default
81
+
82
+ # Criar configuração
83
+ config = PDFConfig(
84
+ project_name=args.name or root.name,
85
+ margin_left=get_margin(args.margin_left, args.margin, PDFConfig().margin_left),
86
+ margin_right=get_margin(args.margin_right, args.margin, PDFConfig().margin_right),
87
+ margin_top=get_margin(args.margin_top, args.margin, PDFConfig().margin_top),
88
+ margin_bottom=get_margin(args.margin_bottom, args.margin, PDFConfig().margin_bottom),
89
+ start_page_num=args.start_page,
90
+ show_project_name=args.show_project,
91
+ normal_font=args.normal_font or PDFConfig().normal_font,
92
+ bold_font=args.bold_font or PDFConfig().bold_font,
93
+ mono_font=args.mono_font or mono_font,
94
+ emoji_font=args.emoji_font or emoji_font,
95
+ emoji_description=args.emoji_description,
96
+ repo_url=args.repo_url,
97
+ page_number_size=args.page_number_size,
98
+ )
99
+
100
+ print(f"🔍 Analyzing directory: {root}")
101
+ all_files = sort_files(get_project_files(root), root)
102
+ included = []
103
+ ignored_binaries = []
104
+
105
+ for fp in all_files:
106
+ try:
107
+ if fp.resolve() in (script_path, output_path):
108
+ continue
109
+ except Exception:
110
+ pass
111
+ if not fp.is_file():
112
+ continue
113
+
114
+ ext = fp.suffix.lower()
115
+
116
+ # Check known extensions first (without reading file)
117
+ if ext == ".svg":
118
+ included += [(fp, "image"), (fp, "text")]
119
+ continue
120
+ elif ext in IMAGE_EXTENSIONS:
121
+ included.append((fp, "image"))
122
+ continue
123
+ elif ext in BINARY_EXTENSIONS:
124
+ ignored_binaries.append(fp)
125
+ continue
126
+
127
+ # Classify file with a single read
128
+ file_type = classify_file(fp)
129
+ if file_type == "text":
130
+ included.append((fp, "text"))
131
+ elif file_type == "binary":
132
+ ignored_binaries.append(fp)
133
+
134
+ if ignored_binaries:
135
+ for bp in ignored_binaries:
136
+ rel_path = bp.relative_to(root)
137
+ print(f"⚠️ Ignoring binary file: {rel_path}")
138
+
139
+ if not included:
140
+ print("❌ No compatible files found.")
141
+ return
142
+
143
+ print(f"🧮 Step 1/2: Simulating layout of {len(included)} files to generate the Table of Contents...")
144
+ pdf_sim = ModernAnnexPDF(io.BytesIO(), root, mono_font, emoji_font, config)
145
+ pdf_sim.is_simulation = True
146
+ pdf_sim.build(included)
147
+
148
+ print("🚀 Step 2/2: Generating the final document with images and code...")
149
+ pdf_final = ModernAnnexPDF(output, root, mono_font, emoji_font, config)
150
+ pdf_final.summary_data = pdf_sim.summary_data
151
+ pdf_final.build(included)
152
+
153
+ print(f"✅ Success! The annex was saved to: {output}")
154
+
155
+
156
+ if __name__ == "__main__":
157
+ main()
codeannex/config.py ADDED
@@ -0,0 +1,65 @@
1
+ from dataclasses import dataclass
2
+ from reportlab.lib import colors
3
+ from reportlab.lib.pagesizes import A4
4
+ from reportlab.lib.units import mm, cm
5
+
6
+ # ── Page (default) ───────────────────────────────
7
+ PAGE_W, PAGE_H = A4
8
+ _DEFAULT_MARGIN_LEFT = 1.5 * cm
9
+ _DEFAULT_MARGIN_RIGHT = 1.5 * cm
10
+ _DEFAULT_MARGIN_TOP = 2.0 * cm
11
+ _DEFAULT_MARGIN_BOTTOM = 2.0 * cm
12
+
13
+ # ── Code ───────────────────────────────────────
14
+ CODE_FONT_SIZE = 10
15
+ CODE_LINE_H = CODE_FONT_SIZE * 1.4
16
+ GUTTER_W = 14 * mm
17
+
18
+ # ── Colors ────────────────────────────────────────
19
+ COLOR_PAGE_BG = colors.HexColor("#ffffff")
20
+ COLOR_TEXT_MAIN = colors.HexColor("#4c4f69")
21
+ COLOR_CODE_BG = colors.HexColor("#1e1e2e")
22
+ COLOR_GUTTER_BG = colors.HexColor("#181825")
23
+ COLOR_GUTTER_FG = colors.HexColor("#bac2de")
24
+ COLOR_HEADER_BG = colors.HexColor("#313244")
25
+ COLOR_HEADER_FG = colors.HexColor("#cdd6f4")
26
+ COLOR_ACCENT = colors.HexColor("#89b4fa")
27
+
28
+ # ── Base fonts ──────────────────────────────────
29
+ NORMAL_FONT = "Helvetica"
30
+ BOLD_FONT = "Helvetica-Bold"
31
+
32
+ # ── Supported image extensions ───────────────
33
+ IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".ico", ".gif", ".webp", ".bmp", ".svg"}
34
+
35
+ # ── Binary extensions to ignore quickly ──────
36
+ BINARY_EXTENSIONS = {".pdf", ".pyc", ".pyo", ".exe", ".dll", ".so", ".dylib",
37
+ ".zip", ".tar", ".gz", ".rar", ".7z", ".jar", ".whl",
38
+ ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"}
39
+
40
+
41
+ @dataclass
42
+ class PDFConfig:
43
+ """PDF configuration for code annex."""
44
+ project_name: str | None = None
45
+ margin_left: float = _DEFAULT_MARGIN_LEFT
46
+ margin_right: float = _DEFAULT_MARGIN_RIGHT
47
+ margin_top: float = _DEFAULT_MARGIN_TOP
48
+ margin_bottom: float = _DEFAULT_MARGIN_BOTTOM
49
+ start_page_num: int = 1
50
+ show_project_name: bool = False
51
+ normal_font: str = NORMAL_FONT
52
+ bold_font: str = BOLD_FONT
53
+ mono_font: str | None = None # Will be set by register_best_font
54
+ emoji_font: str | None = None # Will be set by register_emoji_font
55
+ emoji_description: bool = False # Print descriptions instead of emojis
56
+ repo_url: str | None = None # URL of the repository for the cover page
57
+ page_number_size: int = 8 # Font size for page numbers
58
+
59
+ def get_code_x(self) -> float:
60
+ """Calculates the initial X position of the code."""
61
+ return self.margin_left + GUTTER_W
62
+
63
+ def get_code_w(self) -> float:
64
+ """Calculates the available width for code."""
65
+ return PAGE_W - self.get_code_x() - self.margin_right
@@ -0,0 +1,116 @@
1
+ import fnmatch
2
+ import os
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+
7
+ def classify_file(filepath: Path) -> str:
8
+ """
9
+ Detects binaries cross-platform (Windows/Linux/macOS).
10
+ Simple and reliable heuristic: null bytes = binary
11
+ Returns: "text", "binary", or "error"
12
+ """
13
+ try:
14
+ # Empty files are text
15
+ if filepath.stat().st_size == 0:
16
+ return "text"
17
+
18
+ # Read first 512 bytes (enough to detect null bytes)
19
+ with open(filepath, "rb") as f:
20
+ chunk = f.read(512)
21
+
22
+ # Null bytes is the most reliable indicator of binary
23
+ # (works on Windows, Linux, macOS without dependencies)
24
+ if b"\0" in chunk:
25
+ return "binary"
26
+
27
+ return "text"
28
+ except Exception:
29
+ return "error"
30
+
31
+
32
+ def is_text_file(filepath: Path) -> bool:
33
+ # Rejeita extensões binárias conhecidas rapidamente
34
+ binary_extensions = {".pdf", ".pyc", ".pyo", ".exe", ".dll", ".so", ".dylib",
35
+ ".zip", ".tar", ".gz", ".rar", ".7z", ".jar", ".whl"}
36
+ if filepath.suffix.lower() in binary_extensions:
37
+ return False
38
+
39
+ return classify_file(filepath) == "text"
40
+
41
+
42
+ def is_binary_file(filepath: Path) -> bool:
43
+ return classify_file(filepath) == "binary"
44
+
45
+
46
+ class FallbackGitignoreFilter:
47
+ def __init__(self, root: Path):
48
+ self.root = root
49
+ gi = root / ".gitignore"
50
+ self.rules = []
51
+ if gi.exists():
52
+ for line in gi.read_text(encoding="utf-8", errors="replace").splitlines():
53
+ line = line.strip()
54
+ if line and not line.startswith("#"):
55
+ self.rules.append(line.rstrip("/"))
56
+
57
+ def is_ignored(self, path: Path) -> bool:
58
+ try:
59
+ rel = path.relative_to(self.root)
60
+ except ValueError:
61
+ return False
62
+ rel_str = rel.as_posix()
63
+ parts = rel.parts
64
+ for rule in self.rules:
65
+ if rule.startswith("/"):
66
+ r = rule[1:]
67
+ if fnmatch.fnmatch(rel_str, r) or fnmatch.fnmatch(rel_str, f"{r}/*"):
68
+ return True
69
+ else:
70
+ if fnmatch.fnmatch(rel_str, rule) or fnmatch.fnmatch(rel_str, f"{rule}/*"):
71
+ return True
72
+ if any(fnmatch.fnmatch(p, rule) for p in parts):
73
+ return True
74
+ return False
75
+
76
+
77
+ def sort_files(files: list[Path], root: Path) -> list[Path]:
78
+ """
79
+ Sorts files respecting the rule:
80
+ - Root files first, in alphabetical order
81
+ - Then each directory (alphabetical), recursively with the same rule
82
+ """
83
+ def sort_key(p: Path):
84
+ parts = p.relative_to(root).parts
85
+ # Intercala cada parte com um flag (1 = está dentro de dir, 0 = é arquivo na raiz do nível)
86
+ # Para cada nível exceto o último (nome do arquivo), coloca (1, nome_dir)
87
+ # Para o arquivo em si, coloca (0, nome) no seu nível
88
+ dirs = parts[:-1]
89
+ fname = parts[-1]
90
+ return tuple(val for d in dirs for val in (1, d.lower())) + (0, fname.lower())
91
+
92
+ return sorted(files, key=sort_key)
93
+
94
+
95
+ def get_project_files(root: Path) -> list[Path]:
96
+ try:
97
+ subprocess.run(["git", "rev-parse", "--is-inside-work-tree"],
98
+ cwd=root, capture_output=True, check=True)
99
+ tracked = subprocess.run(["git", "ls-files", "-z"],
100
+ cwd=root, capture_output=True, check=True).stdout.split(b"\0")
101
+ untracked = subprocess.run(["git", "ls-files", "-z", "--others", "--exclude-standard"],
102
+ cwd=root, capture_output=True, check=True).stdout.split(b"\0")
103
+ files = [root / f.decode() for f in set(tracked + untracked) if f]
104
+ print("✅ Using Git's native engine to perfectly interpret .gitignore.")
105
+ return sorted(files)
106
+
107
+ except (subprocess.CalledProcessError, FileNotFoundError):
108
+ print("⚠️ Git not detected in the project. Using manual scan with Fallback.")
109
+ system_ignores = {".git", "node_modules", "__pycache__", "venv", ".venv", "dist", "build"}
110
+ filt = FallbackGitignoreFilter(root)
111
+ files = []
112
+ for dp, dns, fns in os.walk(root):
113
+ curr = Path(dp)
114
+ dns[:] = [d for d in dns if d not in system_ignores and not filt.is_ignored(curr / d)]
115
+ files.extend(curr / f for f in fns if not filt.is_ignored(curr / f))
116
+ return sorted(files)
codeannex/fonts.py ADDED
@@ -0,0 +1,271 @@
1
+ import io
2
+ import logging
3
+
4
+ from PIL import Image as PilImage, ImageDraw as PilImageDraw, ImageFont as PilImageFont
5
+ from reportlab.lib.utils import ImageReader
6
+ from reportlab.pdfbase import pdfmetrics
7
+ from reportlab.pdfbase.ttfonts import TTFont
8
+
9
+ from .config import CODE_FONT_SIZE, COLOR_GUTTER_FG
10
+
11
+ logging.getLogger("svglib").setLevel(logging.ERROR)
12
+
13
+ # ── Caminhos de busca ────────────────────────────
14
+ TTF_SEARCH_PATHS = [
15
+ "C:\\Windows\\Fonts\\consola.ttf", "C:\\Windows\\Fonts\\cour.ttf",
16
+ "C:\\Windows\\Fonts\\lucon.ttf",
17
+ "/System/Library/Fonts/Supplemental/Courier New.ttf",
18
+ "/System/Library/Fonts/Menlo.ttc", "/System/Library/Fonts/Monaco.ttf",
19
+ "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
20
+ "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
21
+ "/usr/share/fonts/truetype/ubuntu/UbuntuMono-R.ttf",
22
+ ]
23
+ EMOJI_SEARCH_PATHS = [
24
+ # Monochromatic fonts work best with ReportLab
25
+ "/usr/share/fonts/truetype/ancient-scripts/Symbola_hint.ttf", # High coverage, monochromatic
26
+ "/usr/share/fonts/truetype/noto/NotoEmoji-Regular.ttf", # Standard monochromatic Noto Emoji
27
+ # Cross-platform fonts with reasonable coverage
28
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", # Better for general Unicode
29
+ "/usr/share/fonts/truetype/ubuntu/Ubuntu-R.ttf",
30
+ # Google/Noto color fonts (fallback, may not work in all reportlab versions)
31
+ "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
32
+ "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf",
33
+ # Platform-specific fonts
34
+ "/Library/Fonts/Arial Unicode.ttf",
35
+ "/System/Library/Fonts/Supplemental/Apple Symbols.ttf",
36
+ "/System/Library/Fonts/Apple Color Emoji.ttc",
37
+ "C:\\Windows\\Fonts\\seguisym.ttf", "C:\\Windows\\Fonts\\seguiemj.ttf",
38
+ ]
39
+
40
+
41
+ def _register_font(name: str, paths: list, fallback):
42
+ import os
43
+ for p in paths:
44
+ if os.path.exists(p):
45
+ try:
46
+ pdfmetrics.registerFont(TTFont(name, p))
47
+ return name, p
48
+ except Exception:
49
+ continue
50
+ return fallback, None
51
+
52
+
53
+ def register_best_font():
54
+ name, path = _register_font("CustomMono", TTF_SEARCH_PATHS, "Courier")
55
+ return name, name != "Courier", path
56
+
57
+
58
+ def register_emoji_font(error_on_missing=False):
59
+ name, path = _register_font("CustomEmoji", EMOJI_SEARCH_PATHS, None)
60
+ if name is None:
61
+ # Fallback: try to use DejaVu fonts for unicode support
62
+ try:
63
+ dejavu_paths = [
64
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
65
+ "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
66
+ ]
67
+ name, path = _register_font("CustomEmoji", dejavu_paths, None)
68
+ except Exception:
69
+ pass
70
+
71
+ if name is None:
72
+ msg = (
73
+ "❌ Error: No emoji font found on your system.\n"
74
+ " Emojis cannot be rendered correctly without a dedicated font.\n\n"
75
+ " Solutions:\n"
76
+ " 1. Install a font like 'Google Noto Emoji' or 'DejaVu Sans'.\n"
77
+ " - Ubuntu/Debian: sudo apt install fonts-noto-color-emoji\n"
78
+ " - Fedora: sudo dnf install google-noto-emoji-color-fonts\n"
79
+ " - Arch: sudo pacman -S noto-fonts-emoji\n"
80
+ " 2. Use the --emoji-description flag to print [emoji descriptions] instead.\n"
81
+ " 3. Manually specify a font path using --emoji-font \"/path/to/font.ttf\""
82
+ )
83
+ if error_on_missing:
84
+ import sys
85
+ print(msg, file=sys.stderr)
86
+ sys.exit(1)
87
+ else:
88
+ print("⚠️ Warning: Dedicated Emoji font not found. Emojis may not render correctly.")
89
+ else:
90
+ emoji_style = get_emoji_font_style(path)
91
+ if emoji_style:
92
+ print(f"ℹ️ Using {emoji_style} emoji style (font: {path})")
93
+ return name
94
+
95
+
96
+ def get_emoji_font_style(font_path: str | None) -> str | None:
97
+ """Detects the emoji style based on the font path."""
98
+ if not font_path:
99
+ return None
100
+
101
+ font_path_lower = font_path.lower()
102
+
103
+ # Google/Noto fonts
104
+ if "noto" in font_path_lower:
105
+ if "color" in font_path_lower:
106
+ return "Google Noto Color"
107
+ elif "emoji" in font_path_lower:
108
+ return "Google Noto Emoji"
109
+ else:
110
+ return "Google Noto"
111
+
112
+ # Apple fonts
113
+ if "apple" in font_path_lower:
114
+ return "Apple"
115
+
116
+ # Microsoft fonts
117
+ if "segui" in font_path_lower or "windows" in font_path_lower:
118
+ return "Microsoft/Windows"
119
+
120
+ # Other common fonts
121
+ if "symbola" in font_path_lower:
122
+ return "Symbola (Unicode)"
123
+ if "dejavu" in font_path_lower:
124
+ return "DejaVu"
125
+ if "ubuntu" in font_path_lower:
126
+ return "Ubuntu"
127
+
128
+ return "Unknown"
129
+
130
+
131
+ def is_google_like_emoji_font(font_path: str | None) -> bool:
132
+ """Checks if the emoji font is Google-like (Noto-based)."""
133
+ if not font_path:
134
+ return False
135
+ return "noto" in font_path.lower()
136
+
137
+
138
+ def get_current_emoji_font_info() -> dict:
139
+ """Returns information about the currently registered emoji font."""
140
+ from reportlab.pdfbase import pdfmetrics
141
+
142
+ if "CustomEmoji" in pdfmetrics._fonts:
143
+ # Tentar obter o caminho da fonte (se disponível)
144
+ font_obj = pdfmetrics._fonts["CustomEmoji"]
145
+ # Esta é uma simplificação - na prática pode ser mais complexo
146
+ # obter o caminho original da fonte registrada
147
+ return {
148
+ "name": "CustomEmoji",
149
+ "is_registered": True,
150
+ "is_google_like": False, # Não podemos determinar sem o caminho
151
+ "style": "Unknown"
152
+ }
153
+
154
+ return {
155
+ "name": None,
156
+ "is_registered": False,
157
+ "is_google_like": False,
158
+ "style": None
159
+ }
160
+
161
+
162
+ # ── Character support cache ───────────────
163
+ _FONT_CACHE: dict = {}
164
+
165
+
166
+ _EMOJI_CACHE: dict[str, bool] = {}
167
+ _EMOJI_RANGES = [
168
+ (0x1F300, 0x1F9FF), # Símbolos diversos, pictogramas, emoticons, transporte, etc
169
+ (0x2600, 0x26FF), # Símbolos diversos
170
+ (0x2700, 0x27BF), # Dingbats
171
+ (0x1F900, 0x1F9FF), # Supplemental Symbols and Pictographs
172
+ (0x1F680, 0x1F6FF), # Transport and Map Symbols
173
+ (0x2300, 0x23FF), # Miscellaneous Technical
174
+ (0x2B50, 0x2B55), # Stars
175
+ (0x1F004, 0x1F0FF), # Emoticons
176
+ ]
177
+
178
+
179
+ def is_emoji(char: str) -> bool:
180
+ """Detects if a character is an emoji based on known Unicode ranges (with cache)."""
181
+ if char in _EMOJI_CACHE:
182
+ return _EMOJI_CACHE[char]
183
+
184
+ # Check if any code point in the string is in emoji ranges
185
+ result = any(
186
+ any(start <= ord(c) <= end for start, end in _EMOJI_RANGES)
187
+ for c in char
188
+ )
189
+ _EMOJI_CACHE[char] = result
190
+ return result
191
+
192
+
193
+ def is_char_supported(char: str, font_name: str) -> bool:
194
+ """Checks if the font has a glyph for the character. Emojis are always considered supported if emoji_font exists."""
195
+ if font_name not in _FONT_CACHE:
196
+ try:
197
+ _FONT_CACHE[font_name] = pdfmetrics.getFont(font_name)
198
+ except Exception:
199
+ return False
200
+
201
+ try:
202
+ f = _FONT_CACHE[font_name]
203
+ char_code = ord(char)
204
+
205
+ # Check the font's glyph map
206
+ if hasattr(f, "face") and hasattr(f.face, "charToGlyph"):
207
+ return char_code in f.face.charToGlyph
208
+
209
+ # Fallback: supports ASCII
210
+ return char_code <= 255
211
+ except Exception:
212
+ return False
213
+
214
+
215
+ # ── Digit sprites (non-selectable) ─────────
216
+ DIGIT_SPRITES: dict | None = None
217
+
218
+
219
+ def get_digit_sprites() -> dict:
220
+ if DIGIT_SPRITES is None:
221
+ raise RuntimeError("init_sprites() was not called before get_digit_sprites().")
222
+ return DIGIT_SPRITES
223
+
224
+
225
+ def init_sprites(is_ttf: bool, ttf_path: str | None):
226
+ global DIGIT_SPRITES
227
+ if DIGIT_SPRITES is not None:
228
+ return
229
+ DIGIT_SPRITES = {}
230
+ color_hex = "#bac2de"
231
+
232
+ try:
233
+ import cairosvg
234
+ cairo_available = True
235
+ except ImportError:
236
+ cairo_available = False
237
+
238
+ for d in "0123456789":
239
+ if cairo_available:
240
+ svg_str = (
241
+ f'<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50">'
242
+ f'<text x="25" y="40" font-family="monospace" font-size="40" '
243
+ f'fill="{color_hex}" text-anchor="middle">{d}</text></svg>'
244
+ )
245
+ png_data = cairosvg.svg2png(bytestring=svg_str.encode(), scale=4.0)
246
+ img = PilImage.open(io.BytesIO(png_data))
247
+ else:
248
+ box_size = 200
249
+ font = None
250
+ if is_ttf and ttf_path:
251
+ try:
252
+ font = PilImageFont.truetype(ttf_path, box_size)
253
+ except Exception:
254
+ pass
255
+ font = font or PilImageFont.load_default()
256
+ r = int(COLOR_GUTTER_FG.red * 255)
257
+ g = int(COLOR_GUTTER_FG.green * 255)
258
+ b = int(COLOR_GUTTER_FG.blue * 255)
259
+ img_large = PilImage.new("RGBA", (box_size, box_size), (0, 0, 0, 0))
260
+ draw = PilImageDraw.Draw(img_large)
261
+ try:
262
+ draw.text((box_size // 2, int(box_size * 0.8)), d, font=font,
263
+ fill=(r, g, b, 255), anchor="ms")
264
+ except TypeError:
265
+ draw.text((box_size // 4, box_size // 4), d, font=font, fill=(r, g, b, 255))
266
+ img = img_large.resize((50, 50), PilImage.Resampling.LANCZOS)
267
+
268
+ buf = io.BytesIO()
269
+ img.save(buf, format="PNG")
270
+ buf.seek(0)
271
+ DIGIT_SPRITES[d] = ImageReader(buf)
codeannex/highlight.py ADDED
@@ -0,0 +1,21 @@
1
+ from pygments.token import Token
2
+
3
+ TOKEN_COLORS = {
4
+ Token: "#cdd6f4",
5
+ Token.Keyword: "#cba6f7",
6
+ Token.Keyword.Namespace: "#89dceb",
7
+ Token.Name.Function: "#89b4fa",
8
+ Token.Name.Class: "#f9e2af",
9
+ Token.Literal.String: "#a6e3a1",
10
+ Token.Literal.Number: "#fab387",
11
+ Token.Comment: "#6c7086",
12
+ Token.Operator: "#89dceb",
13
+ }
14
+
15
+
16
+ def get_token_color(ttype) -> str:
17
+ while ttype is not None:
18
+ if ttype in TOKEN_COLORS:
19
+ return TOKEN_COLORS[ttype]
20
+ ttype = ttype.parent
21
+ return "#cdd6f4"