prezo 2026.1.2__py3-none-any.whl → 2026.1.4__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.
prezo/__init__.py CHANGED
@@ -128,6 +128,13 @@ def main() -> None:
128
128
  action="store_true",
129
129
  help="Export without window decorations (for printing)",
130
130
  )
131
+ parser.add_argument(
132
+ "--pdf-backend",
133
+ metavar="BACKEND",
134
+ choices=["auto", "chrome", "inkscape", "cairosvg"],
135
+ default="auto",
136
+ help="PDF conversion backend (auto, chrome, inkscape, cairosvg). Default: auto",
137
+ )
131
138
  parser.add_argument(
132
139
  "--scale",
133
140
  metavar="FACTOR",
@@ -152,6 +159,12 @@ def main() -> None:
152
159
  choices=["auto", "kitty", "sixel", "iterm", "ascii", "none"],
153
160
  help="Image rendering mode (auto, kitty, sixel, iterm, ascii, none)",
154
161
  )
162
+ parser.add_argument(
163
+ "-I",
164
+ "--incremental",
165
+ action="store_true",
166
+ help="Display lists incrementally, one item at a time (like Pandoc)",
167
+ )
155
168
 
156
169
  args = parser.parse_args()
157
170
 
@@ -218,6 +231,7 @@ def main() -> None:
218
231
  width=width,
219
232
  height=height,
220
233
  chrome=not args.no_chrome,
234
+ pdf_backend=args.pdf_backend,
221
235
  ),
222
236
  )
223
237
  else:
@@ -228,4 +242,9 @@ def main() -> None:
228
242
  if args.file:
229
243
  file_path = _validate_file(Path(args.file))
230
244
 
231
- run_app(file_path, watch=not args.no_watch, config=config)
245
+ run_app(
246
+ file_path,
247
+ watch=not args.no_watch,
248
+ config=config,
249
+ incremental=args.incremental,
250
+ )
prezo/app.py CHANGED
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import base64
6
6
  import contextlib
7
7
  import os
8
+ import re
8
9
  import subprocess
9
10
  import sys
10
11
  import tempfile
@@ -109,6 +110,121 @@ def _format_recent_files(recent_files: list[str], max_files: int = 5) -> str:
109
110
  return "\n".join(lines)
110
111
 
111
112
 
113
+ # -----------------------------------------------------------------------------
114
+ # Incremental List Helpers
115
+ # -----------------------------------------------------------------------------
116
+
117
+ # Pattern matching markdown list items (unordered and ordered)
118
+ _LIST_ITEM_PATTERN = re.compile(r"^(\s*)([-*+]|\d+\.)\s+")
119
+
120
+ # Pattern matching layout directive markers (:::)
121
+ _LAYOUT_MARKER_PATTERN = re.compile(r"^\s*:::")
122
+
123
+
124
+ def count_list_items(content: str) -> int:
125
+ """Count the number of top-level list items in markdown content.
126
+
127
+ Args:
128
+ content: Markdown content to analyze.
129
+
130
+ Returns:
131
+ Number of top-level list items found.
132
+
133
+ """
134
+ count = 0
135
+ for line in content.split("\n"):
136
+ match = _LIST_ITEM_PATTERN.match(line)
137
+ if match:
138
+ # Only count top-level items (no leading whitespace)
139
+ indent = match.group(1)
140
+ if not indent:
141
+ count += 1
142
+ return count
143
+
144
+
145
+ # Braille Pattern Blank - invisible character with width, behaves like text for layout
146
+ _INVISIBLE_CHAR = "\u2800"
147
+
148
+
149
+ def _make_placeholder(text: str) -> str:
150
+ """Create an invisible placeholder that matches the visual width of text.
151
+
152
+ Uses Braille Pattern Blank characters which are invisible but have
153
+ width and wrap like normal text.
154
+ """
155
+ return _INVISIBLE_CHAR * len(text) if text else _INVISIBLE_CHAR
156
+
157
+
158
+ def filter_list_items(content: str, max_items: int) -> str:
159
+ """Filter content to show only the first N list items.
160
+
161
+ Preserves layout directive markers (:::) and other structural elements.
162
+ Hidden items are replaced with placeholder text of the same length
163
+ to maintain visual height when text wraps.
164
+
165
+ Args:
166
+ content: Markdown content to filter.
167
+ max_items: Maximum number of top-level list items to show.
168
+
169
+ Returns:
170
+ Filtered content with only the first N list items visible.
171
+
172
+ """
173
+ if max_items < 0:
174
+ return content # Show all
175
+
176
+ lines = content.split("\n")
177
+ result_lines = []
178
+ item_count = 0
179
+ in_hidden_item = False
180
+
181
+ for line in lines:
182
+ # Always preserve layout markers (:::)
183
+ if _LAYOUT_MARKER_PATTERN.match(line):
184
+ result_lines.append(line)
185
+ # Reset hidden state when entering/exiting a block
186
+ in_hidden_item = False
187
+ continue
188
+
189
+ match = _LIST_ITEM_PATTERN.match(line)
190
+
191
+ if match:
192
+ indent = match.group(1) # Leading whitespace
193
+ marker = match.group(2) # List marker (-, *, +, 1.)
194
+ text_start = match.end()
195
+ text = line[text_start:] # The actual text content
196
+
197
+ if len(indent) == 0:
198
+ # Top-level item
199
+ item_count += 1
200
+ if item_count <= max_items:
201
+ result_lines.append(line)
202
+ in_hidden_item = False
203
+ else:
204
+ # Replace with same-length placeholder
205
+ placeholder = _make_placeholder(text)
206
+ result_lines.append(f"{indent}{marker} {placeholder}")
207
+ in_hidden_item = True
208
+ elif in_hidden_item:
209
+ # Nested item under hidden parent - also hide
210
+ placeholder = _make_placeholder(text)
211
+ result_lines.append(f"{indent}{marker} {placeholder}")
212
+ else:
213
+ # Nested item - show if parent is visible
214
+ result_lines.append(line)
215
+ elif in_hidden_item:
216
+ # Content continuation of hidden item - preserve length
217
+ stripped = line.lstrip()
218
+ leading = line[: len(line) - len(stripped)]
219
+ placeholder = _make_placeholder(stripped)
220
+ result_lines.append(f"{leading}{placeholder}")
221
+ else:
222
+ # Non-list line (could be continuation or other content)
223
+ result_lines.append(line)
224
+
225
+ return "\n".join(result_lines)
226
+
227
+
112
228
  class PrezoCommands(Provider):
113
229
  """Command provider for Prezo actions."""
114
230
 
@@ -199,7 +315,7 @@ class PrezoApp(App):
199
315
 
200
316
  ENABLE_COMMAND_PALETTE = True
201
317
  COMMAND_PALETTE_BINDING = "ctrl+p"
202
- COMMANDS: ClassVar[set[type[Provider]]] = {PrezoCommands} # type: ignore[assignment]
318
+ COMMANDS: ClassVar[set[type[Provider]]] = {PrezoCommands}
203
319
 
204
320
  CSS = """
205
321
  Screen {
@@ -342,6 +458,7 @@ class PrezoApp(App):
342
458
  current_slide: reactive[int] = reactive(0)
343
459
  notes_visible: reactive[bool] = reactive(False)
344
460
  app_theme: reactive[str] = reactive("dark")
461
+ reveal_index: reactive[int] = reactive(-1) # -1 = show all, 0+ = show up to index
345
462
 
346
463
  TITLE = "Prezo"
347
464
 
@@ -351,6 +468,7 @@ class PrezoApp(App):
351
468
  *,
352
469
  watch: bool | None = None,
353
470
  config: Config | None = None,
471
+ incremental: bool = False,
354
472
  ) -> None:
355
473
  """Initialize the Prezo application.
356
474
 
@@ -358,6 +476,7 @@ class PrezoApp(App):
358
476
  presentation_path: Path to the Markdown presentation file.
359
477
  watch: Whether to enable file watching for live reload.
360
478
  config: Optional config override. Uses global config if None.
479
+ incremental: Whether to display lists incrementally (-I flag).
361
480
 
362
481
  """
363
482
  super().__init__()
@@ -367,6 +486,9 @@ class PrezoApp(App):
367
486
  self.presentation_path = Path(presentation_path) if presentation_path else None
368
487
  self.presentation: Presentation | None = None
369
488
 
489
+ # Incremental lists: CLI flag overrides config
490
+ self.incremental_cli = incremental
491
+
370
492
  # Use config for watch if not explicitly set
371
493
  if watch is None:
372
494
  self.watch_enabled = self.config.behavior.auto_reload
@@ -436,11 +558,20 @@ class PrezoApp(App):
436
558
  return
437
559
 
438
560
  old_slide = self.current_slide
561
+ old_reveal = self.reveal_index
439
562
  self.presentation = parse_presentation(self.presentation_path)
440
563
 
441
564
  if old_slide >= self.presentation.total_slides:
442
- self.current_slide = max(0, self.presentation.total_slides - 1)
565
+ target_slide = max(0, self.presentation.total_slides - 1)
566
+ self._init_reveal_for_slide(target_slide, show_all=False)
567
+ self.current_slide = target_slide
443
568
  else:
569
+ # Preserve reveal position if still valid
570
+ list_count = self._get_list_count(old_slide)
571
+ if self._is_incremental_enabled(old_slide) and list_count > 0:
572
+ self.reveal_index = max(0, min(old_reveal, list_count - 1))
573
+ else:
574
+ self.reveal_index = -1
444
575
  self._update_display()
445
576
 
446
577
  self.notify("Presentation reloaded", timeout=2)
@@ -453,12 +584,16 @@ class PrezoApp(App):
453
584
  # Restore last position or start at 0
454
585
  abs_path = str(self.presentation_path.absolute())
455
586
  last_pos = self.state.get_position(abs_path)
456
- if last_pos < self.presentation.total_slides:
457
- self.current_slide = last_pos
587
+ target_slide = last_pos if last_pos < self.presentation.total_slides else 0
588
+
589
+ # Set the slide - the watcher will initialize reveal state
590
+ if self.current_slide == target_slide:
591
+ # Watcher won't fire, so initialize manually
592
+ self._init_reveal_for_slide(target_slide, show_all=False)
593
+ self._update_display()
458
594
  else:
459
- self.current_slide = 0
595
+ self.current_slide = target_slide
460
596
 
461
- self._update_display()
462
597
  self._update_progress_bar()
463
598
 
464
599
  if self.presentation.title:
@@ -479,6 +614,81 @@ class PrezoApp(App):
479
614
  self._apply_timer_config(status_bar)
480
615
  status_bar.reset_timer()
481
616
 
617
+ def _is_incremental_enabled(self, slide_index: int | None = None) -> bool:
618
+ """Check if incremental mode is enabled for a slide.
619
+
620
+ Priority: per-slide directive > CLI flag > config > presentation directive
621
+
622
+ Args:
623
+ slide_index: Slide index to check. Uses current_slide if None.
624
+
625
+ Returns:
626
+ True if incremental lists should be enabled.
627
+
628
+ """
629
+ if not self.presentation or not self.presentation.slides:
630
+ return False
631
+
632
+ idx = slide_index if slide_index is not None else self.current_slide
633
+ if idx < 0 or idx >= len(self.presentation.slides):
634
+ return False
635
+
636
+ slide = self.presentation.slides[idx]
637
+
638
+ # Per-slide directive takes highest priority
639
+ if slide.incremental is not None:
640
+ return slide.incremental
641
+
642
+ # CLI flag overrides config and presentation directives
643
+ if self.incremental_cli:
644
+ return True
645
+
646
+ # Presentation directive (from <!-- prezo --> block)
647
+ if self.presentation.directives.incremental_lists is not None:
648
+ return self.presentation.directives.incremental_lists
649
+
650
+ # Fall back to config
651
+ return self.config.behavior.incremental_lists
652
+
653
+ def _get_list_count(self, slide_index: int | None = None) -> int:
654
+ """Get the number of top-level list items in a slide.
655
+
656
+ Args:
657
+ slide_index: Slide index to check. Uses current_slide if None.
658
+
659
+ Returns:
660
+ Number of top-level list items.
661
+
662
+ """
663
+ if not self.presentation or not self.presentation.slides:
664
+ return 0
665
+
666
+ idx = slide_index if slide_index is not None else self.current_slide
667
+ if idx < 0 or idx >= len(self.presentation.slides):
668
+ return 0
669
+
670
+ slide = self.presentation.slides[idx]
671
+ return count_list_items(slide.content)
672
+
673
+ def _init_reveal_for_slide(
674
+ self, slide_index: int, *, show_all: bool = False
675
+ ) -> None:
676
+ """Initialize reveal state for a specific slide.
677
+
678
+ Args:
679
+ slide_index: The slide to initialize for.
680
+ show_all: If True, reveal all items. If False, start with first item.
681
+
682
+ """
683
+ if self._is_incremental_enabled(slide_index):
684
+ list_count = self._get_list_count(slide_index)
685
+ if list_count > 0:
686
+ self.reveal_index = (list_count - 1) if show_all else 0
687
+ else:
688
+ self.reveal_index = -1 # No list items, show all content
689
+ else:
690
+ self.reveal_index = -1 # Incremental disabled, show all
691
+
482
692
  def _apply_presentation_directives(self) -> None:
483
693
  """Apply presentation-specific directives on top of config."""
484
694
  if not self.presentation:
@@ -555,35 +765,27 @@ class PrezoApp(App):
555
765
  image_container.add_class("visible")
556
766
 
557
767
  # Apply layout based on MARP directive
558
- layout = first_image.layout
559
- if layout == "left":
560
- image_container.add_class("layout-left")
561
- # Ensure image is before text
562
- horizontal_container.move_child(
563
- image_container, before=slide_container
564
- )
565
- elif layout == "right":
566
- image_container.add_class("layout-right")
567
- # Move image after text
568
- horizontal_container.move_child(
569
- image_container, after=slide_container
570
- )
571
- elif layout == "inline":
572
- image_container.add_class("layout-inline")
573
- horizontal_container.move_child(
574
- image_container, before=slide_container
575
- )
576
- elif layout in ("background", "fit"):
577
- # Background/fit images: show image full width behind/above text
578
- image_container.add_class("layout-inline")
579
- horizontal_container.move_child(
580
- image_container, before=slide_container
581
- )
768
+ match first_image.layout:
769
+ case "left":
770
+ image_container.add_class("layout-left")
771
+ horizontal_container.move_child(
772
+ image_container, before=slide_container
773
+ )
774
+ case "right":
775
+ image_container.add_class("layout-right")
776
+ horizontal_container.move_child(
777
+ image_container, after=slide_container
778
+ )
779
+ case "inline" | "background" | "fit":
780
+ image_container.add_class("layout-inline")
781
+ horizontal_container.move_child(
782
+ image_container, before=slide_container
783
+ )
582
784
 
583
785
  # Apply dynamic width if size_percent is specified
584
786
  default_size = 50
585
787
  has_custom_size = first_image.size_percent != default_size
586
- if has_custom_size and layout in ("left", "right"):
788
+ if has_custom_size and first_image.layout in ("left", "right"):
587
789
  image_container.styles.width = f"{first_image.size_percent}%"
588
790
  else:
589
791
  image_container.styles.width = None # Reset to CSS default
@@ -591,9 +793,13 @@ class PrezoApp(App):
591
793
  image_widget.clear()
592
794
 
593
795
  # Use cleaned content (bg images already removed by parser)
594
- self.query_one("#slide-content", SlideContent).set_content(
595
- slide.content.strip()
596
- )
796
+ content = slide.content.strip()
797
+
798
+ # Apply incremental filtering if enabled
799
+ if self._is_incremental_enabled() and self.reveal_index >= 0:
800
+ content = filter_list_items(content, self.reveal_index + 1)
801
+
802
+ self.query_one("#slide-content", SlideContent).set_content(content)
597
803
 
598
804
  container = self.query_one("#slide-container", VerticalScroll)
599
805
  container.scroll_home(animate=False)
@@ -602,7 +808,7 @@ class PrezoApp(App):
602
808
  self._update_notes()
603
809
 
604
810
  def _update_progress_bar(self) -> None:
605
- """Update the progress bar."""
811
+ """Update the progress bar and reveal indicator."""
606
812
  if not self.presentation:
607
813
  return
608
814
 
@@ -610,6 +816,19 @@ class PrezoApp(App):
610
816
  status.current = self.current_slide
611
817
  status.total = self.presentation.total_slides
612
818
 
819
+ # Update reveal indicator
820
+ if self._is_incremental_enabled():
821
+ list_count = self._get_list_count()
822
+ if list_count > 0 and self.reveal_index >= 0:
823
+ status.reveal_current = self.reveal_index
824
+ status.reveal_total = list_count
825
+ else:
826
+ status.reveal_current = -1
827
+ status.reveal_total = 0
828
+ else:
829
+ status.reveal_current = -1
830
+ status.reveal_total = 0
831
+
613
832
  def _update_notes(self) -> None:
614
833
  """Update the notes panel content."""
615
834
  if not self.presentation or not self.presentation.slides:
@@ -625,6 +844,9 @@ class PrezoApp(App):
625
844
 
626
845
  def watch_current_slide(self, old_value: int, new_value: int) -> None:
627
846
  """React to slide changes."""
847
+ # Determine direction and initialize reveal state appropriately
848
+ going_back = new_value < old_value
849
+ self._init_reveal_for_slide(new_value, show_all=going_back)
628
850
  self._update_display()
629
851
  self._save_position()
630
852
 
@@ -644,15 +866,41 @@ class PrezoApp(App):
644
866
  notes_panel.remove_class("visible")
645
867
 
646
868
  def action_next_slide(self) -> None:
647
- """Go to the next slide."""
648
- if (
649
- self.presentation
650
- and self.current_slide < self.presentation.total_slides - 1
651
- ):
869
+ """Go to the next slide or reveal next list item."""
870
+ if not self.presentation:
871
+ return
872
+
873
+ # Check if we should reveal next item instead of advancing slide
874
+ if self._is_incremental_enabled():
875
+ list_count = self._get_list_count()
876
+ if (
877
+ list_count > 0
878
+ and self.reveal_index >= 0
879
+ and self.reveal_index < list_count - 1
880
+ ):
881
+ self.reveal_index += 1
882
+ self._update_display()
883
+ self._update_progress_bar()
884
+ return
885
+
886
+ # No more items to reveal, go to next slide
887
+ if self.current_slide < self.presentation.total_slides - 1:
888
+ # The watcher will initialize reveal_index for the new slide
652
889
  self.current_slide += 1
653
890
 
654
891
  def action_prev_slide(self) -> None:
655
- """Go to the previous slide."""
892
+ """Go to the previous slide or hide last revealed item."""
893
+ if not self.presentation:
894
+ return
895
+
896
+ # Check if we should hide last item instead of going back
897
+ if self._is_incremental_enabled() and self.reveal_index > 0:
898
+ self.reveal_index -= 1
899
+ self._update_display()
900
+ self._update_progress_bar()
901
+ return
902
+
903
+ # Go to previous slide (watcher will show all items)
656
904
  if self.current_slide > 0:
657
905
  self.current_slide -= 1
658
906
 
@@ -740,7 +988,7 @@ class PrezoApp(App):
740
988
  def watch_app_theme(self, theme_name: str) -> None:
741
989
  """Apply theme when it changes."""
742
990
  # Only apply to widgets after mount (watcher fires during init)
743
- if not self.is_mounted: # type: ignore[truthy-function]
991
+ if not self.is_mounted:
744
992
  return
745
993
  self._apply_theme(theme_name)
746
994
  self.notify(f"Theme: {theme_name}", timeout=1)
@@ -939,6 +1187,7 @@ def run_app(
939
1187
  *,
940
1188
  watch: bool | None = None,
941
1189
  config: Config | None = None,
1190
+ incremental: bool = False,
942
1191
  ) -> None:
943
1192
  """Run the Prezo application.
944
1193
 
@@ -946,7 +1195,10 @@ def run_app(
946
1195
  presentation_path: Path to the presentation file.
947
1196
  watch: Whether to watch for file changes. Uses config default if None.
948
1197
  config: Optional config override. Uses global config if None.
1198
+ incremental: Whether to display lists incrementally (-I flag).
949
1199
 
950
1200
  """
951
- app = PrezoApp(presentation_path, watch=watch, config=config)
1201
+ app = PrezoApp(
1202
+ presentation_path, watch=watch, config=config, incremental=incremental
1203
+ )
952
1204
  app.run()
prezo/config.py CHANGED
@@ -11,7 +11,7 @@ from typing import Any
11
11
  try:
12
12
  import tomllib
13
13
  except ImportError:
14
- import tomli as tomllib # type: ignore[no-redef]
14
+ import tomli as tomllib
15
15
 
16
16
 
17
17
  CONFIG_DIR = Path.home() / ".config" / "prezo"
@@ -69,6 +69,7 @@ class BehaviorConfig:
69
69
 
70
70
  auto_reload: bool = True
71
71
  reload_interval: float = 1.0
72
+ incremental_lists: bool = False # Display lists one item at a time
72
73
 
73
74
 
74
75
  @dataclass
@@ -0,0 +1,36 @@
1
+ """Export functionality for prezo presentations.
2
+
3
+ Exports presentations to PDF, HTML, PNG, and SVG formats.
4
+
5
+ IMPORTANT: PDF/PNG/SVG export must be a faithful image of the TUI console.
6
+ This requires proper monospace font loading. If Fira Code is not available,
7
+ alignment may be incorrect.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .common import (
13
+ ExportError,
14
+ check_font_availability,
15
+ print_font_warnings,
16
+ )
17
+ from .html import export_to_html, render_slide_to_html, run_html_export
18
+ from .images import export_slide_to_image, export_to_images, run_image_export
19
+ from .pdf import combine_svgs_to_pdf, export_to_pdf, run_export
20
+ from .svg import render_slide_to_svg
21
+
22
+ __all__ = [
23
+ "ExportError",
24
+ "check_font_availability",
25
+ "combine_svgs_to_pdf",
26
+ "export_slide_to_image",
27
+ "export_to_html",
28
+ "export_to_images",
29
+ "export_to_pdf",
30
+ "print_font_warnings",
31
+ "render_slide_to_html",
32
+ "render_slide_to_svg",
33
+ "run_export",
34
+ "run_html_export",
35
+ "run_image_export",
36
+ ]
prezo/export/common.py ADDED
@@ -0,0 +1,77 @@
1
+ """Common utilities and constants for export functionality."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+
9
+
10
+ class ExportError(Exception):
11
+ """Raised when an export operation fails."""
12
+
13
+
14
+ # Exit codes for CLI (only used in run_* wrapper functions)
15
+ EXIT_SUCCESS = 0
16
+ EXIT_FAILURE = 2
17
+
18
+ # Backwards compatibility aliases (deprecated, use exceptions instead)
19
+ EXPORT_SUCCESS = EXIT_SUCCESS
20
+ EXPORT_FAILED = EXIT_FAILURE
21
+
22
+
23
+ def check_font_availability() -> list[str]:
24
+ """Check if required fonts are available on the system.
25
+
26
+ Returns a list of warning messages (empty if all fonts are available).
27
+ """
28
+ warnings = []
29
+
30
+ # Check for fc-list (fontconfig) to query system fonts
31
+ fc_list_path = shutil.which("fc-list")
32
+ if fc_list_path:
33
+ try:
34
+ result = subprocess.run(
35
+ [fc_list_path, ":family"],
36
+ capture_output=True,
37
+ text=True,
38
+ timeout=5,
39
+ check=False,
40
+ )
41
+ fonts = result.stdout.lower()
42
+
43
+ # Check for Fira Code (primary monospace font)
44
+ if "fira code" not in fonts and "firacode" not in fonts:
45
+ warnings.append(
46
+ "Fira Code font not found. Install it for best results:\n"
47
+ " macOS: brew install --cask font-fira-code\n"
48
+ " Ubuntu: sudo apt install fonts-firacode\n"
49
+ " Or download from: https://github.com/tonsky/FiraCode"
50
+ )
51
+
52
+ except (subprocess.TimeoutExpired, subprocess.SubprocessError):
53
+ # Can't check fonts, skip warning
54
+ pass
55
+ else:
56
+ # No fontconfig available (Windows or minimal system)
57
+ # We can't easily check fonts, so just note the requirement
58
+ warnings.append(
59
+ "Cannot verify font availability. For correct alignment, ensure "
60
+ "Fira Code font is installed."
61
+ )
62
+
63
+ return warnings
64
+
65
+
66
+ def print_font_warnings(warnings: list[str]) -> None:
67
+ """Print font warnings to stderr."""
68
+ if warnings:
69
+ print("\n⚠️ Font Warning:", file=sys.stderr)
70
+ for warning in warnings:
71
+ for line in warning.split("\n"):
72
+ print(f" {line}", file=sys.stderr)
73
+ print(
74
+ "\n Without proper fonts, column alignment may be incorrect in exports.",
75
+ file=sys.stderr,
76
+ )
77
+ print(file=sys.stderr)