codeannex 0.1.1__tar.gz → 0.2.0__tar.gz

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.
Files changed (31) hide show
  1. codeannex-0.2.0/PKG-INFO +116 -0
  2. codeannex-0.2.0/README.md +85 -0
  3. {codeannex-0.1.1 → codeannex-0.2.0}/codeannex/__main__.py +97 -5
  4. {codeannex-0.1.1 → codeannex-0.2.0}/codeannex/config.py +27 -0
  5. codeannex-0.2.0/codeannex/fonts.py +275 -0
  6. {codeannex-0.1.1 → codeannex-0.2.0}/codeannex/pdf_builder.py +123 -77
  7. codeannex-0.2.0/codeannex/text_utils.py +131 -0
  8. codeannex-0.2.0/codeannex.egg-info/PKG-INFO +116 -0
  9. {codeannex-0.1.1 → codeannex-0.2.0}/codeannex.egg-info/SOURCES.txt +5 -1
  10. {codeannex-0.1.1 → codeannex-0.2.0}/pyproject.toml +1 -1
  11. codeannex-0.2.0/tests/test_fonts_dynamic.py +47 -0
  12. codeannex-0.2.0/tests/test_interactive.py +65 -0
  13. codeannex-0.2.0/tests/test_summary_hierarchy.py +56 -0
  14. codeannex-0.2.0/tests/test_text_utils.py +25 -0
  15. codeannex-0.1.1/PKG-INFO +0 -147
  16. codeannex-0.1.1/README.md +0 -116
  17. codeannex-0.1.1/codeannex/fonts.py +0 -271
  18. codeannex-0.1.1/codeannex/text_utils.py +0 -128
  19. codeannex-0.1.1/codeannex.egg-info/PKG-INFO +0 -147
  20. {codeannex-0.1.1 → codeannex-0.2.0}/LICENSE +0 -0
  21. {codeannex-0.1.1 → codeannex-0.2.0}/codeannex/__init__.py +0 -0
  22. {codeannex-0.1.1 → codeannex-0.2.0}/codeannex/file_utils.py +0 -0
  23. {codeannex-0.1.1 → codeannex-0.2.0}/codeannex/highlight.py +0 -0
  24. {codeannex-0.1.1 → codeannex-0.2.0}/codeannex.egg-info/dependency_links.txt +0 -0
  25. {codeannex-0.1.1 → codeannex-0.2.0}/codeannex.egg-info/entry_points.txt +0 -0
  26. {codeannex-0.1.1 → codeannex-0.2.0}/codeannex.egg-info/requires.txt +0 -0
  27. {codeannex-0.1.1 → codeannex-0.2.0}/codeannex.egg-info/top_level.txt +0 -0
  28. {codeannex-0.1.1 → codeannex-0.2.0}/setup.cfg +0 -0
  29. {codeannex-0.1.1 → codeannex-0.2.0}/tests/test_emoji.py +0 -0
  30. {codeannex-0.1.1 → codeannex-0.2.0}/tests/test_file_management.py +0 -0
  31. {codeannex-0.1.1 → codeannex-0.2.0}/tests/test_pdf_layout.py +0 -0
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: codeannex
3
+ Version: 0.2.0
4
+ Summary: Generates a professional PDF source code annex with Smart Index, Images and Emoji support.
5
+ License: MIT
6
+ Project-URL: Repository, https://github.com/tanhleno/codeannex
7
+ Keywords: pdf,source-code,documentation,annex,reportlab,emoji
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Documentation
17
+ Classifier: Topic :: Software Development :: Documentation
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: reportlab>=4.0
22
+ Requires-Dist: Pillow>=10.0
23
+ Requires-Dist: Pygments>=2.16
24
+ Requires-Dist: pdfminer.six>=20231228
25
+ Provides-Extra: svg
26
+ Requires-Dist: cairosvg>=2.7; extra == "svg"
27
+ Requires-Dist: svglib>=1.5; extra == "svg"
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=7.0; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # 📂 codeannex
33
+
34
+ Generates a professional PDF annex from a project's source code — with syntax highlighting, a hierarchical table of contents, image rendering, and intelligent font discovery.
35
+
36
+ ## 🚀 Key Features
37
+
38
+ - **Interactive Wizard** — Run without arguments to configure your PDF step-by-step. It intelligently skips irrelevant questions (like starting page if numbering is disabled).
39
+ - **Intelligent Font Discovery** — Automatically finds and registers fonts from your system (Windows, Linux, macOS) by name (e.g., `--title-font "Play"`).
40
+ - **Fully Customizable UI** — Change every label, color, and size via CLI. Default labels are in English for international consistency.
41
+ - **Hierarchical Summary** — Real tree-structured Table of Contents with terminal-like connection lines.
42
+ - **Customizable Primary Color** — Set the theme color for headers, folder icons, and accents with `--primary-color`.
43
+ - **Smart Contrast** — Automatically switches header text between black and white based on the brightness of your primary color for maximum legibility.
44
+ - **Flexible Page Styling** — Support for custom page background and code block colors.
45
+ - **Git-Aware** — Perfectly interprets `.gitignore` using Git's native engine.
46
+
47
+ ## 🛠 Installation
48
+
49
+ ```bash
50
+ pip install codeannex
51
+ ```
52
+
53
+ ## 📖 Usage
54
+
55
+ ### Interactive Mode (Wizard)
56
+ Simply run without arguments to start the step-by-step configuration:
57
+ ```bash
58
+ codeannex .
59
+ ```
60
+
61
+ ### Automation / CI
62
+ Use the `--no-input` flag to disable the wizard in automated environments:
63
+ ```bash
64
+ codeannex . --no-input
65
+ ```
66
+
67
+ ### Professional Customization Example
68
+ ```bash
69
+ python3 -m codeannex . \
70
+ --cover-title "Anexo I" \
71
+ --no-page-numbers \
72
+ --primary-color "#0f4761" \
73
+ --title-font "Play" --title-size 20 \
74
+ --normal-font "Times New Roman" --normal-size 12 \
75
+ --margin-top 2.5 --margin-bottom 2.5 --margin-left 3 --margin-right 3 \
76
+ --code-bg "#f5f5f5" --code-size 9
77
+ ```
78
+
79
+ ## ⚙️ Configuration Options
80
+
81
+ ### Document & Labels
82
+ - `--cover-title TITLE` — Title for the cover (default: "ANEXO TÉCNICO").
83
+ - `--cover-subtitle SUB` — Subtitle for the cover.
84
+ - `--summary-title TITLE` — Title for the summary page.
85
+ - `--repo-label LABEL` — Prefix for repository link (default: "Repository: ").
86
+ - `--project-label LABEL` — Prefix for project name in footer.
87
+ - `--file-part-format FMT` — Format for file parts (default: "({current}/{total})").
88
+ - `--no-input` — Disable the interactive wizard.
89
+
90
+
91
+ ### Colors & Themes
92
+ - `--primary-color HEX` — Main color for headers and accents.
93
+ - `--page-bg-color HEX` — Background color for all pages.
94
+ - `--code-bg HEX` — Background color for code blocks.
95
+ - `--normal-color HEX` / `--title-color HEX` — Text colors.
96
+
97
+ ### Fonts & Sizes
98
+ - `--title-font` / `--subtitle-font` / `--normal-font` — System font names.
99
+ - `--mono-font` — Font for code (e.g., "Consolas", "Ubuntu Mono").
100
+ - `--emoji-font` — Font for emojis (e.g., "Noto Emoji").
101
+ - `--code-size N` — Font size for code blocks.
102
+
103
+ ### Layout
104
+ - `--margin CM` — General margin for all sides.
105
+ - `--margin-top`, `--margin-bottom`, `--margin-left`, `--margin-right` — Specific margins in cm.
106
+ - `--no-page-numbers` — Disable page numbering.
107
+
108
+ ## 🧪 Testing
109
+
110
+ ```bash
111
+ PYTHONPATH=. pytest
112
+ ```
113
+
114
+ ## 📄 License
115
+
116
+ MIT
@@ -0,0 +1,85 @@
1
+ # 📂 codeannex
2
+
3
+ Generates a professional PDF annex from a project's source code — with syntax highlighting, a hierarchical table of contents, image rendering, and intelligent font discovery.
4
+
5
+ ## 🚀 Key Features
6
+
7
+ - **Interactive Wizard** — Run without arguments to configure your PDF step-by-step. It intelligently skips irrelevant questions (like starting page if numbering is disabled).
8
+ - **Intelligent Font Discovery** — Automatically finds and registers fonts from your system (Windows, Linux, macOS) by name (e.g., `--title-font "Play"`).
9
+ - **Fully Customizable UI** — Change every label, color, and size via CLI. Default labels are in English for international consistency.
10
+ - **Hierarchical Summary** — Real tree-structured Table of Contents with terminal-like connection lines.
11
+ - **Customizable Primary Color** — Set the theme color for headers, folder icons, and accents with `--primary-color`.
12
+ - **Smart Contrast** — Automatically switches header text between black and white based on the brightness of your primary color for maximum legibility.
13
+ - **Flexible Page Styling** — Support for custom page background and code block colors.
14
+ - **Git-Aware** — Perfectly interprets `.gitignore` using Git's native engine.
15
+
16
+ ## 🛠 Installation
17
+
18
+ ```bash
19
+ pip install codeannex
20
+ ```
21
+
22
+ ## 📖 Usage
23
+
24
+ ### Interactive Mode (Wizard)
25
+ Simply run without arguments to start the step-by-step configuration:
26
+ ```bash
27
+ codeannex .
28
+ ```
29
+
30
+ ### Automation / CI
31
+ Use the `--no-input` flag to disable the wizard in automated environments:
32
+ ```bash
33
+ codeannex . --no-input
34
+ ```
35
+
36
+ ### Professional Customization Example
37
+ ```bash
38
+ python3 -m codeannex . \
39
+ --cover-title "Anexo I" \
40
+ --no-page-numbers \
41
+ --primary-color "#0f4761" \
42
+ --title-font "Play" --title-size 20 \
43
+ --normal-font "Times New Roman" --normal-size 12 \
44
+ --margin-top 2.5 --margin-bottom 2.5 --margin-left 3 --margin-right 3 \
45
+ --code-bg "#f5f5f5" --code-size 9
46
+ ```
47
+
48
+ ## ⚙️ Configuration Options
49
+
50
+ ### Document & Labels
51
+ - `--cover-title TITLE` — Title for the cover (default: "ANEXO TÉCNICO").
52
+ - `--cover-subtitle SUB` — Subtitle for the cover.
53
+ - `--summary-title TITLE` — Title for the summary page.
54
+ - `--repo-label LABEL` — Prefix for repository link (default: "Repository: ").
55
+ - `--project-label LABEL` — Prefix for project name in footer.
56
+ - `--file-part-format FMT` — Format for file parts (default: "({current}/{total})").
57
+ - `--no-input` — Disable the interactive wizard.
58
+
59
+
60
+ ### Colors & Themes
61
+ - `--primary-color HEX` — Main color for headers and accents.
62
+ - `--page-bg-color HEX` — Background color for all pages.
63
+ - `--code-bg HEX` — Background color for code blocks.
64
+ - `--normal-color HEX` / `--title-color HEX` — Text colors.
65
+
66
+ ### Fonts & Sizes
67
+ - `--title-font` / `--subtitle-font` / `--normal-font` — System font names.
68
+ - `--mono-font` — Font for code (e.g., "Consolas", "Ubuntu Mono").
69
+ - `--emoji-font` — Font for emojis (e.g., "Noto Emoji").
70
+ - `--code-size N` — Font size for code blocks.
71
+
72
+ ### Layout
73
+ - `--margin CM` — General margin for all sides.
74
+ - `--margin-top`, `--margin-bottom`, `--margin-left`, `--margin-right` — Specific margins in cm.
75
+ - `--no-page-numbers` — Disable page numbering.
76
+
77
+ ## 🧪 Testing
78
+
79
+ ```bash
80
+ PYTHONPATH=. pytest
81
+ ```
82
+
83
+ ## 📄 License
84
+
85
+ MIT
@@ -5,7 +5,7 @@ from pathlib import Path
5
5
 
6
6
  from .config import IMAGE_EXTENSIONS, BINARY_EXTENSIONS, PDFConfig
7
7
  from .file_utils import get_project_files, classify_file, sort_files
8
- from .fonts import init_sprites, register_best_font, register_emoji_font
8
+ from .fonts import init_sprites, register_best_font, register_emoji_font, auto_register_font
9
9
  from .pdf_builder import ModernAnnexPDF
10
10
  from reportlab.lib.units import cm
11
11
 
@@ -16,8 +16,6 @@ def check_emoji_font_style():
16
16
 
17
17
  emoji_font = register_emoji_font()
18
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
19
  print(f"✅ Emoji font registered: {emoji_font}")
22
20
  print("ℹ️ To check if using Google-like style, look for 'Noto' in the font path above")
23
21
  print("💡 Tip: Install Google Noto fonts for authentic Google emoji style")
@@ -26,11 +24,50 @@ def check_emoji_font_style():
26
24
  return emoji_font
27
25
 
28
26
 
27
+ def run_interactive_wizard(args):
28
+ """Interactive wizard to configure the PDF when no arguments are provided."""
29
+ print("\n✨ Welcome to codeannex Interactive Wizard! ✨")
30
+ print("Press Enter to keep the [default] value.\n")
31
+
32
+ questions = [
33
+ ("name", "Project Name", args.name or "Current Directory"),
34
+ ("cover_title", "Cover Title", args.cover_title),
35
+ ("cover_subtitle", "Cover Subtitle", args.cover_subtitle),
36
+ ("primary_color", "Primary Color (HEX)", args.primary_color),
37
+ ("title_font", "Title Font Name", args.title_font or "Play"),
38
+ ("normal_font", "Normal Text Font Name", args.normal_font or "Tinos"),
39
+ ("mono_font", "Monospace Font Name", args.mono_font or "NotoSansMono"),
40
+ ("margin_top", "Top Margin (cm)", args.margin_top or 2.0),
41
+ ("margin_bottom", "Bottom Margin (cm)", args.margin_bottom or 2.0),
42
+ ("margin_left", "Left Margin (cm)", args.margin_left or 1.5),
43
+ ("margin_right", "Right Margin (cm)", args.margin_right or 1.5),
44
+ ("no_page_numbers", "Disable Page Numbers? (y/n)", "n"),
45
+ ("start_page", "Starting Page Number", args.start_page or 1),
46
+ ]
47
+
48
+ for i, (attr, label, default) in enumerate(questions, 1):
49
+ # Skip start_page question if page numbers were disabled in previous step
50
+ if attr == "start_page" and args.no_page_numbers:
51
+ continue
52
+
53
+ choice = input(f"[{i}/{len(questions)}] {label} [{default}]: ").strip()
54
+ if choice:
55
+ if attr == "no_page_numbers":
56
+ args.no_page_numbers = choice.lower() == 'y'
57
+ elif "margin" in attr or attr == "start_page":
58
+ setattr(args, attr, float(choice) if "." in choice else int(choice))
59
+ else:
60
+ setattr(args, attr, choice)
61
+ print("\n🚀 Configuration complete! Generating PDF...\n")
62
+
63
+
29
64
  def main():
30
65
  parser = argparse.ArgumentParser(description="Generates a PDF code annex with Smart Index and Images.")
31
66
  parser.add_argument("dir", nargs="?", default=".", help="Project directory")
32
67
  parser.add_argument("-o", "--output", default=None, help="Output PDF filename")
33
68
  parser.add_argument("-n", "--name", default=None, help="Project name (default: directory name)")
69
+ parser.add_argument("--no-input", action="store_true", help="Disable interactive mode")
70
+ parser.add_argument("--cover-title", default="TECHNICAL ANNEX", help="Title for the cover page")
34
71
  parser.add_argument("--margin", type=float, default=None, help="General margin in cm for all sides")
35
72
  parser.add_argument("--margin-left", type=float, default=None, help="Left margin in cm (default: 1.5cm)")
36
73
  parser.add_argument("--margin-right", type=float, default=None, help="Right margin in cm (default: 1.5cm)")
@@ -39,9 +76,29 @@ def main():
39
76
  parser.add_argument("--start-page", type=int, default=1, help="Starting page number (default: 1)")
40
77
  parser.add_argument("--show-project", action="store_true", help="Show project name in footer")
41
78
  parser.add_argument("--repo-url", default=None, help="Repository URL to show on cover")
79
+ parser.add_argument("--repo-label", default="Repository: ", help="Label for repo URL (default: 'Repository: ')")
80
+ parser.add_argument("--project-label", default="Project: ", help="Label for project name in footer (default: 'Project: ')")
81
+ parser.add_argument("--file-part-format", default="({current}/{total})", help="Format for file parts continuation (e.g. '({current}/{total})')")
82
+ parser.add_argument("--summary-title", default="Summary / File Index", help="Title for the summary page")
83
+ parser.add_argument("--cover-subtitle", default="Source Code Documentation", help="Subtitle for the cover page")
84
+
42
85
  parser.add_argument("--page-number-size", type=int, default=8, help="Font size for page numbers (default: 8)")
86
+ parser.add_argument("--page-number-format", default="{n}", help="Format for page numbers (e.g. 'Anexo I - {n}')")
87
+ parser.add_argument("--no-page-numbers", action="store_true", help="Disable page numbers")
88
+ parser.add_argument("--page-bg-color", default="#ffffff", help="Background color for pages (default: #ffffff)")
43
89
  parser.add_argument("--normal-font", default=None, help="Normal text font (default: Helvetica)")
90
+ parser.add_argument("--normal-size", type=int, default=10, help="Normal text font size (default: 10)")
91
+ parser.add_argument("--normal-color", default="#4c4f69", help="Normal text color (default: #4c4f69)")
44
92
  parser.add_argument("--bold-font", default=None, help="Bold text font (default: Helvetica-Bold)")
93
+ parser.add_argument("--title-font", default=None, help="Title font (default: bold-font)")
94
+ parser.add_argument("--title-size", type=int, default=28, help="Title font size (default: 28)")
95
+ parser.add_argument("--title-color", default="#1e1e2e", help="Title color (default: #1e1e2e)")
96
+ parser.add_argument("--subtitle-font", default=None, help="Subtitle font (default: normal-font)")
97
+ parser.add_argument("--subtitle-size", type=int, default=18, help="Subtitle font size (default: 18)")
98
+ parser.add_argument("--subtitle-color", default=None, help="Subtitle color (default: title-color)")
99
+ parser.add_argument("--primary-color", default="#1e66f5", help="Primary color for accents and headers (default: #1e66f5)")
100
+ parser.add_argument("--code-size", type=int, default=10, help="Font size for code (default: 10)")
101
+ parser.add_argument("--code-bg", default="#1e1e2e", help="Background color for code blocks (default: #1e1e2e)")
45
102
  parser.add_argument("--mono-font", default=None, help="Monospace font for code (default: auto-detect)")
46
103
  parser.add_argument("--emoji-font", default=None, help="Font for emojis (default: auto-detect)")
47
104
  parser.add_argument("--emoji-description", action="store_true", help="Print [description] instead of emoji glyphs")
@@ -49,6 +106,22 @@ def main():
49
106
 
50
107
  args, unknown = parser.parse_known_args()
51
108
 
109
+ # Check if we should run interactive mode:
110
+ # No optional arguments provided AND --no-input is False
111
+ import sys
112
+ is_interactive = len(sys.argv) <= 2 and not args.no_input
113
+ # If the second arg is actually a directory but no other flags were passed, we can still go interactive
114
+ if is_interactive:
115
+ run_interactive_wizard(args)
116
+
117
+ # Pre-register fonts specified in arguments dynamically from system
118
+ if args.normal_font: args.normal_font = auto_register_font(args.normal_font, required=True)
119
+ if args.bold_font: args.bold_font = auto_register_font(args.bold_font, required=True)
120
+ if args.title_font: args.title_font = auto_register_font(args.title_font, required=True)
121
+ if args.subtitle_font: args.subtitle_font = auto_register_font(args.subtitle_font, required=True)
122
+ if args.mono_font: args.mono_font = auto_register_font(args.mono_font, required=True)
123
+ if args.emoji_font: args.emoji_font = auto_register_font(args.emoji_font, required=True)
124
+
52
125
  # Handle unknown arguments
53
126
  if unknown:
54
127
  for u in unknown:
@@ -95,6 +168,27 @@ def main():
95
168
  emoji_description=args.emoji_description,
96
169
  repo_url=args.repo_url,
97
170
  page_number_size=args.page_number_size,
171
+ title_font=args.title_font,
172
+ title_size=args.title_size,
173
+ title_color=args.title_color,
174
+ subtitle_font=args.subtitle_font,
175
+ subtitle_size=args.subtitle_size,
176
+ subtitle_color=args.subtitle_color,
177
+ normal_text_size=args.normal_size,
178
+ normal_text_color=args.normal_color,
179
+ page_number_format=args.page_number_format,
180
+ show_page_numbers=not args.no_page_numbers,
181
+ cover_title=args.cover_title,
182
+ primary_color=args.primary_color,
183
+ # New dynamic fields
184
+ cover_subtitle=args.cover_subtitle,
185
+ summary_title=args.summary_title,
186
+ repo_label=args.repo_label,
187
+ project_label=args.project_label,
188
+ file_part_format=args.file_part_format,
189
+ code_font_size=args.code_size,
190
+ code_bg_color=args.code_bg,
191
+ page_bg_color=args.page_bg_color,
98
192
  )
99
193
 
100
194
  print(f"🔍 Analyzing directory: {root}")
@@ -113,7 +207,6 @@ def main():
113
207
 
114
208
  ext = fp.suffix.lower()
115
209
 
116
- # Check known extensions first (without reading file)
117
210
  if ext == ".svg":
118
211
  included += [(fp, "image"), (fp, "text")]
119
212
  continue
@@ -124,7 +217,6 @@ def main():
124
217
  ignored_binaries.append(fp)
125
218
  continue
126
219
 
127
- # Classify file with a single read
128
220
  file_type = classify_file(fp)
129
221
  if file_type == "text":
130
222
  included.append((fp, "text"))
@@ -56,6 +56,33 @@ class PDFConfig:
56
56
  repo_url: str | None = None # URL of the repository for the cover page
57
57
  page_number_size: int = 8 # Font size for page numbers
58
58
 
59
+ # Customization fields
60
+ title_font: str | None = None
61
+ title_size: int = 28
62
+ title_color: str = "#1e1e2e"
63
+ subtitle_font: str | None = None
64
+ subtitle_size: int = 18
65
+ subtitle_color: str | None = None # Defaults to title_color
66
+ normal_text_size: int = 10
67
+ normal_text_color: str = "#4c4f69" # Default COLOR_TEXT_MAIN
68
+ page_number_format: str = "{n}" # e.g. "Anexo I - {n}" or just "{n}"
69
+ show_page_numbers: bool = True
70
+ cover_title: str = "TECHNICAL ANNEX"
71
+ cover_subtitle: str = "Source Code Documentation"
72
+ primary_color: str = "#1e66f5" # Professional dark blue
73
+
74
+ # Code style
75
+ code_font_size: int = CODE_FONT_SIZE
76
+ code_bg_color: str = "#1e1e2e" # Default COLOR_CODE_BG
77
+ page_bg_color: str = "#ffffff" # Default COLOR_PAGE_BG
78
+
79
+ # Labels (Internationalization)
80
+ summary_title: str = "Summary / File Index"
81
+ repo_label: str = "Repository: "
82
+ project_label: str = "Project: "
83
+ file_part_format: str = "({current}/{total})" # e.g. "(1/2)"
84
+ cover_subtitle: str = "Source Code Documentation"
85
+
59
86
  def get_code_x(self) -> float:
60
87
  """Calculates the initial X position of the code."""
61
88
  return self.margin_left + GUTTER_W
@@ -0,0 +1,275 @@
1
+ import os
2
+ import io
3
+ import logging
4
+ from pathlib import Path
5
+ from PIL import Image as PilImage, ImageDraw as PilImageDraw, ImageFont as PilImageFont
6
+ from reportlab.lib.utils import ImageReader
7
+ from reportlab.pdfbase import pdfmetrics
8
+ from reportlab.pdfbase.ttfonts import TTFont
9
+
10
+ from .config import CODE_FONT_SIZE, COLOR_GUTTER_FG
11
+
12
+ logging.getLogger("svglib").setLevel(logging.ERROR)
13
+
14
+ # ── Discovery ────────────────────────────────────
15
+
16
+ def get_system_font_paths():
17
+ """Returns a list of standard system font directories."""
18
+ home = os.path.expanduser("~")
19
+ paths = [
20
+ os.path.join(home, ".local/share/fonts"),
21
+ os.path.join(home, ".fonts"),
22
+ "/usr/share/fonts",
23
+ "/usr/local/share/fonts",
24
+ "/System/Library/Fonts",
25
+ "/Library/Fonts",
26
+ "C:\\Windows\\Fonts",
27
+ ]
28
+ return [p for p in paths if os.path.exists(p)]
29
+
30
+ def find_font_file(font_name: str) -> str | None:
31
+ """Dynamically searches for a font file by name in system directories."""
32
+ search_dirs = get_system_font_paths()
33
+
34
+ # Normalize name for searching: "NotoEmoji" -> "notoemoji"
35
+ clean_name = font_name.lower().replace(" ", "").replace("-", "")
36
+
37
+ extensions = [".ttf", ".otf", ".ttc"]
38
+
39
+ for base_dir in search_dirs:
40
+ for root, _, files in os.walk(base_dir):
41
+ for f in files:
42
+ f_lower = f.lower()
43
+ # Check if it's a font file
44
+ if any(f_lower.endswith(ext) for ext in extensions):
45
+ # Check if our clean_name is in the filename
46
+ # e.g. "notoemoji" in "notocoloremoji.ttf"
47
+ f_clean = f_lower.replace(" ", "").replace("-", "").replace("_", "")
48
+ if clean_name in f_clean:
49
+ return os.path.join(root, f)
50
+ return None
51
+
52
+ def auto_register_font(font_name: str, required: bool = False) -> str:
53
+ """Attempts to register a font. Errors (🛑) stop execution, Warnings (⚠️) just alert."""
54
+ if not font_name:
55
+ return font_name
56
+
57
+ # Standard ReportLab fonts
58
+ if font_name in ["Helvetica", "Helvetica-Bold", "Times-Roman", "Times-Bold", "Courier"]:
59
+ return font_name
60
+
61
+ # Check if already registered
62
+ try:
63
+ pdfmetrics.getFont(font_name)
64
+ return font_name
65
+ except:
66
+ pass
67
+
68
+ # Try to find and register
69
+ path = find_font_file(font_name)
70
+ if path:
71
+ try:
72
+ print(f"✅ Found font '{font_name}' at: {path}")
73
+ pdfmetrics.registerFont(TTFont(font_name, path))
74
+ return font_name
75
+ except Exception as e:
76
+ if not required:
77
+ print(f"⚠️ Warning: Could not register font '{font_name}': {e}")
78
+ return font_name
79
+ # If required, it will fall through to the Error block below
80
+
81
+ # If font is not found or registration failed
82
+ if required:
83
+ msg = (
84
+ f"\n🛑 ERROR: Font '{font_name}' not found or invalid.\n"
85
+ f" Search paths: {', '.join(get_system_font_paths())}\n\n"
86
+ f" Fixes:\n"
87
+ f" - Install the font on your OS (Windows/Linux/macOS).\n"
88
+ f" - Check for typos in your command.\n"
89
+ f" - Use a built-in font: Helvetica, Times-Roman, Courier.\n"
90
+ )
91
+ import sys
92
+ print(msg, file=sys.stderr)
93
+ sys.exit(1)
94
+ else:
95
+ print(f"⚠️ WARNING: Font '{font_name}' not found. Emojis or text might not render correctly.")
96
+
97
+ return font_name
98
+
99
+ # ── Original Support Functions ───────────────────
100
+
101
+ TTF_SEARCH_PATHS = [
102
+ "/usr/share/fonts/truetype/noto/NotoSansMono-Regular.ttf",
103
+ "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
104
+ "C:\\Windows\\Fonts\\consola.ttf",
105
+ ]
106
+
107
+ EMOJI_SEARCH_PATHS = [
108
+ "/usr/share/fonts/truetype/noto/NotoEmoji-Regular.ttf",
109
+ "/usr/share/fonts/truetype/ancient-scripts/Symbola_hint.ttf",
110
+ ]
111
+
112
+ def _register_font(name: str, paths: list, fallback):
113
+ for p in paths:
114
+ if os.path.exists(p):
115
+ try:
116
+ pdfmetrics.registerFont(TTFont(name, p))
117
+ return name, p
118
+ except Exception:
119
+ continue
120
+ return fallback, None
121
+
122
+ def register_best_font():
123
+ name, path = _register_font("CustomMono", TTF_SEARCH_PATHS, "Courier")
124
+ return name, name != "Courier", path
125
+
126
+ def register_emoji_font(error_on_missing=False):
127
+ name, path = _register_font("CustomEmoji", EMOJI_SEARCH_PATHS, None)
128
+ if name is None:
129
+ # Tenta busca dinâmica como última opção
130
+ dynamic_path = find_font_file("NotoEmoji") or find_font_file("Symbola")
131
+ if dynamic_path:
132
+ name, _ = _register_font("CustomEmoji", [dynamic_path], None)
133
+
134
+ if name is None and error_on_missing:
135
+ msg = (
136
+ "❌ Error: No emoji font found on your system.\n"
137
+ " Emojis cannot be rendered correctly without a dedicated font.\n\n"
138
+ " Solutions:\n"
139
+ " 1. Install a font like 'Google Noto Emoji' or 'DejaVu Sans'.\n"
140
+ " - Ubuntu/Debian: sudo apt install fonts-noto-color-emoji\n"
141
+ " - Fedora: sudo dnf install google-noto-emoji-color-fonts\n"
142
+ " - Arch: sudo pacman -S noto-fonts-emoji\n"
143
+ " 2. Use the --emoji-description flag to print [emoji descriptions] instead.\n"
144
+ " 3. Manually specify a font path using --emoji-font \"/path/to/font.ttf\""
145
+ )
146
+ import sys
147
+ print(msg, file=sys.stderr)
148
+ sys.exit(1)
149
+ return name
150
+
151
+ def get_emoji_font_style(font_path: str | None) -> str | None:
152
+ """Detects the emoji style based on the font path."""
153
+ if not font_path:
154
+ return None
155
+ font_path_lower = font_path.lower()
156
+ if "noto" in font_path_lower:
157
+ if "color" in font_path_lower: return "Google Noto Color"
158
+ elif "emoji" in font_path_lower: return "Google Noto Emoji"
159
+ else: return "Google Noto"
160
+ if "apple" in font_path_lower: return "Apple"
161
+ if "segui" in font_path_lower or "windows" in font_path_lower: return "Microsoft/Windows"
162
+ if "symbola" in font_path_lower: return "Symbola (Unicode)"
163
+ if "dejavu" in font_path_lower: return "DejaVu"
164
+ if "ubuntu" in font_path_lower: return "Ubuntu"
165
+ return "Unknown"
166
+
167
+ def is_google_like_emoji_font(font_path: str | None) -> bool:
168
+ """Checks if the emoji font is Google-like (Noto-based)."""
169
+ if not font_path: return False
170
+ return "noto" in font_path.lower()
171
+
172
+ def get_current_emoji_font_info() -> dict:
173
+ """Returns information about the currently registered emoji font."""
174
+ if "CustomEmoji" in pdfmetrics._fonts:
175
+ return {
176
+ "name": "CustomEmoji",
177
+ "is_registered": True,
178
+ "is_google_like": False,
179
+ "style": "Unknown"
180
+ }
181
+ return {
182
+ "name": None,
183
+ "is_registered": False,
184
+ "is_google_like": False,
185
+ "style": None
186
+ }
187
+
188
+ # ── Character & Sprite Support ───────────────────
189
+
190
+ _FONT_CACHE: dict = {}
191
+
192
+ def is_char_supported(char: str, font_name: str) -> bool:
193
+ """Checks if the font has a glyph for the character. True for standard ASCII in built-in fonts."""
194
+ if font_name not in _FONT_CACHE:
195
+ try:
196
+ _FONT_CACHE[font_name] = pdfmetrics.getFont(font_name)
197
+ except:
198
+ return False
199
+
200
+ try:
201
+ f = _FONT_CACHE[font_name]
202
+
203
+ # ReportLab standard fonts (Type1) only support latin-1
204
+ if font_name in ["Helvetica", "Helvetica-Bold", "Times-Roman", "Times-Bold", "Courier"]:
205
+ return ord(char) < 256
206
+
207
+ # For TTFonts, check the actual glyph map
208
+ if hasattr(f, "face") and hasattr(f.face, "charToGlyph"):
209
+ return ord(char) in f.face.charToGlyph
210
+
211
+ return ord(char) < 256
212
+ except:
213
+ return False
214
+
215
+ def is_emoji(char: str) -> bool:
216
+ """
217
+ Returns True if the character is likely an emoji/symbol.
218
+ Excludes box-drawing characters (U+2500 to U+257F).
219
+ """
220
+ if not char: return False
221
+ cp = ord(char[0])
222
+ # Box Drawing range: 2500–257F
223
+ if 0x2500 <= cp <= 0x257F:
224
+ return False
225
+
226
+ import unicodedata
227
+ category = unicodedata.category(char[0])
228
+ return category in ['So', 'Sk', 'Cn']
229
+
230
+ DIGIT_SPRITES: dict | None = None
231
+
232
+ def get_digit_sprites() -> dict:
233
+ if DIGIT_SPRITES is None: raise RuntimeError("init_sprites() not called.")
234
+ return DIGIT_SPRITES
235
+
236
+ def init_sprites(is_ttf: bool, ttf_path: str | None):
237
+ global DIGIT_SPRITES
238
+ if DIGIT_SPRITES is not None: return
239
+ DIGIT_SPRITES = {}
240
+ color_hex = "#bac2de"
241
+ try:
242
+ import cairosvg
243
+ cairo_available = True
244
+ except ImportError:
245
+ cairo_available = False
246
+
247
+ for d in "0123456789":
248
+ if cairo_available:
249
+ svg_str = f'<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50"><text x="25" y="40" font-family="monospace" font-size="40" fill="{color_hex}" text-anchor="middle">{d}</text></svg>'
250
+ png_data = cairosvg.svg2png(bytestring=svg_str.encode(), scale=4.0)
251
+ img = PilImage.open(io.BytesIO(png_data))
252
+ else:
253
+ box_size = 200
254
+ font = None
255
+ if is_ttf and ttf_path:
256
+ try: font = PilImageFont.truetype(ttf_path, box_size)
257
+ except: pass
258
+ font = font or PilImageFont.load_default()
259
+ img_large = PilImage.new("RGBA", (box_size, box_size), (0, 0, 0, 0))
260
+ draw = PilImageDraw.Draw(img_large)
261
+ draw.text((box_size // 2, int(box_size * 0.8)), d, font=font, fill=(186, 194, 222, 255), anchor="ms")
262
+ img = img_large.resize((50, 50), PilImage.Resampling.LANCZOS)
263
+ buf = io.BytesIO()
264
+ img.save(buf, format="PNG")
265
+ buf.seek(0)
266
+ DIGIT_SPRITES[d] = ImageReader(buf)
267
+
268
+ def register_font_file(name: str, path: str):
269
+ """Fallback for manual registration if needed."""
270
+ if os.path.exists(path):
271
+ try:
272
+ pdfmetrics.registerFont(TTFont(name, path))
273
+ return name
274
+ except: pass
275
+ return None