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/__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
|
codeannex/file_utils.py
ADDED
|
@@ -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"
|