pycvr 0.1.0__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.
- cvr/__init__.py +34 -0
- cvr/__main__.py +5 -0
- cvr/cli.py +189 -0
- cvr/encoders.py +130 -0
- cvr/fonts.py +121 -0
- cvr/gui/__init__.py +5 -0
- cvr/gui/__main__.py +5 -0
- cvr/gui/app.py +1049 -0
- cvr/gui/settings.py +69 -0
- cvr/gui/widgets.py +232 -0
- cvr/highlight.py +205 -0
- cvr/palette.py +237 -0
- cvr/players.py +141 -0
- cvr/py.typed +0 -0
- cvr/renderer.py +650 -0
- cvr/themes.py +220 -0
- pycvr-0.1.0.dist-info/METADATA +357 -0
- pycvr-0.1.0.dist-info/RECORD +22 -0
- pycvr-0.1.0.dist-info/WHEEL +5 -0
- pycvr-0.1.0.dist-info/entry_points.txt +5 -0
- pycvr-0.1.0.dist-info/licenses/LICENSE +21 -0
- pycvr-0.1.0.dist-info/top_level.txt +1 -0
cvr/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""CVR: turn source code into a typewriter video in your real VS Code theme.
|
|
2
|
+
|
|
3
|
+
import cvr
|
|
4
|
+
cvr.render("script.py", "script.mp4", theme="Dark Modern", duration=20)
|
|
5
|
+
|
|
6
|
+
The GUI lives in `cvr.gui` and is only imported when you ask for it, so the
|
|
7
|
+
library works on machines without Tk.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
from .palette import DEFAULT_PALETTE, Palette
|
|
13
|
+
from .renderer import (ANIMATION_STYLES, CodeTypewriterVideoRenderer,
|
|
14
|
+
RenderCancelled, Renderer, RenderError, render)
|
|
15
|
+
from .themes import (ThemeInfo, active_vscode_theme_label, default_theme,
|
|
16
|
+
find_theme, list_themes, load_theme)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ANIMATION_STYLES",
|
|
20
|
+
"CodeTypewriterVideoRenderer",
|
|
21
|
+
"DEFAULT_PALETTE",
|
|
22
|
+
"Palette",
|
|
23
|
+
"RenderCancelled",
|
|
24
|
+
"RenderError",
|
|
25
|
+
"Renderer",
|
|
26
|
+
"ThemeInfo",
|
|
27
|
+
"active_vscode_theme_label",
|
|
28
|
+
"default_theme",
|
|
29
|
+
"find_theme",
|
|
30
|
+
"list_themes",
|
|
31
|
+
"load_theme",
|
|
32
|
+
"render",
|
|
33
|
+
"__version__",
|
|
34
|
+
]
|
cvr/__main__.py
ADDED
cvr/cli.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Command line: `cvr render`, `cvr themes`, and `cvr` alone for the GUI."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
from typing import List, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .palette import DEFAULT_PALETTE
|
|
10
|
+
from .renderer import ANIMATION_STYLES, RenderError, render
|
|
11
|
+
from .themes import default_theme, list_themes
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _resolution(text: str) -> Tuple[int, int]:
|
|
15
|
+
try:
|
|
16
|
+
w, h = (int(v) for v in text.lower().split("x"))
|
|
17
|
+
except ValueError:
|
|
18
|
+
raise argparse.ArgumentTypeError(f"expected WIDTHxHEIGHT, got {text!r}")
|
|
19
|
+
if w <= 0 or h <= 0:
|
|
20
|
+
raise argparse.ArgumentTypeError("width and height must be positive")
|
|
21
|
+
return w, h
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
25
|
+
parser = argparse.ArgumentParser(
|
|
26
|
+
prog="cvr",
|
|
27
|
+
description="Turn source code into a typewriter video in your VS Code "
|
|
28
|
+
"theme. Run with no command to open the app.")
|
|
29
|
+
parser.add_argument("--version", action="version",
|
|
30
|
+
version=f"%(prog)s {__version__}")
|
|
31
|
+
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
32
|
+
|
|
33
|
+
sub.add_parser("gui", help="open the app (the default)")
|
|
34
|
+
|
|
35
|
+
r = sub.add_parser("render", help="render a source file to a video",
|
|
36
|
+
description="Render a source file to a typewriter video.")
|
|
37
|
+
r.add_argument("file", help="source file to type out")
|
|
38
|
+
r.add_argument("-o", "--output", metavar="PATH",
|
|
39
|
+
help="video to write (default: FILE_typewriter.mp4 here)")
|
|
40
|
+
r.add_argument("-t", "--theme", metavar="NAME",
|
|
41
|
+
help="installed VS Code theme or a theme json path "
|
|
42
|
+
"(default: the theme VS Code is set to)")
|
|
43
|
+
r.add_argument("-d", "--duration", type=float, metavar="SECONDS",
|
|
44
|
+
help="fixed length in seconds; without it the delays decide")
|
|
45
|
+
r.add_argument("-r", "--resolution", type=_resolution, default=(1280, 720),
|
|
46
|
+
metavar="WxH", help="frame size (default: 1280x720)")
|
|
47
|
+
r.add_argument("--fps", type=int, default=30,
|
|
48
|
+
help="frames per second (default: 30)")
|
|
49
|
+
r.add_argument("--font", metavar="PATH", help="TrueType font file")
|
|
50
|
+
r.add_argument("--font-size", type=int, default=18, metavar="PX",
|
|
51
|
+
help="font size in pixels (default: 18)")
|
|
52
|
+
r.add_argument("--style", choices=ANIMATION_STYLES, default="smooth",
|
|
53
|
+
help="animation style (default: smooth)")
|
|
54
|
+
r.add_argument("--char-delay", type=float, default=0.05, metavar="SECONDS",
|
|
55
|
+
help="pause after each character (default: 0.05)")
|
|
56
|
+
r.add_argument("--line-delay", type=float, default=0.30, metavar="SECONDS",
|
|
57
|
+
help="pause after each line (default: 0.30)")
|
|
58
|
+
r.add_argument("--language", metavar="NAME",
|
|
59
|
+
help="Pygments lexer name, when the file extension is not enough")
|
|
60
|
+
r.add_argument("--no-line-numbers", action="store_true",
|
|
61
|
+
help="hide the line number gutter")
|
|
62
|
+
r.add_argument("--no-tab", action="store_true",
|
|
63
|
+
help="hide the file name tab")
|
|
64
|
+
r.add_argument("--no-cursor", action="store_true", help="hide the cursor")
|
|
65
|
+
r.add_argument("--no-blink", action="store_true",
|
|
66
|
+
help="keep the cursor solid")
|
|
67
|
+
r.add_argument("--no-follow", action="store_true",
|
|
68
|
+
help="do not scroll to follow the cursor")
|
|
69
|
+
r.add_argument("--no-smooth-scroll", action="store_true",
|
|
70
|
+
help="jump instead of gliding when scrolling")
|
|
71
|
+
r.add_argument("--no-highlight", action="store_true",
|
|
72
|
+
help="plain text, no syntax colours")
|
|
73
|
+
r.add_argument("--type-indent", action="store_true",
|
|
74
|
+
help="type leading spaces instead of indenting instantly")
|
|
75
|
+
r.add_argument("--compatible", action="store_true",
|
|
76
|
+
help="4:2:0 video that Windows Media Player can open "
|
|
77
|
+
"(about one level off on dark tones)")
|
|
78
|
+
r.add_argument("--open", action="store_true",
|
|
79
|
+
help="open the video when it is done")
|
|
80
|
+
r.add_argument("-q", "--quiet", action="store_true",
|
|
81
|
+
help="no progress bar")
|
|
82
|
+
r.add_argument("-v", "--verbose", action="store_true",
|
|
83
|
+
help="print timing details")
|
|
84
|
+
|
|
85
|
+
t = sub.add_parser("themes", help="list installed VS Code themes",
|
|
86
|
+
description="List the colour themes VS Code has "
|
|
87
|
+
"installed. * marks the default.")
|
|
88
|
+
t.add_argument("filter", nargs="?", default="",
|
|
89
|
+
help="only show themes whose name contains this")
|
|
90
|
+
t.add_argument("--paths", action="store_true",
|
|
91
|
+
help="show each theme's json file")
|
|
92
|
+
return parser
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _progress_printer(stream):
|
|
96
|
+
def show(pct: float, _total: float, status: str, eta: float):
|
|
97
|
+
width = 28
|
|
98
|
+
filled = int(width * pct / 100)
|
|
99
|
+
bar = "#" * filled + "-" * (width - filled)
|
|
100
|
+
eta_s = f" ETA {eta:.0f}s" if eta > 0 else ""
|
|
101
|
+
stream.write(f"\r[{bar}] {pct:5.1f}% {status}{eta_s}".ljust(79))
|
|
102
|
+
stream.flush()
|
|
103
|
+
return show
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _cmd_render(args) -> int:
|
|
107
|
+
show_progress = not args.quiet and sys.stderr.isatty()
|
|
108
|
+
try:
|
|
109
|
+
path = render(
|
|
110
|
+
args.file, args.output,
|
|
111
|
+
theme = args.theme,
|
|
112
|
+
duration = args.duration,
|
|
113
|
+
language = args.language,
|
|
114
|
+
progress = _progress_printer(sys.stderr) if show_progress else None,
|
|
115
|
+
resolution = args.resolution,
|
|
116
|
+
fps = args.fps,
|
|
117
|
+
font_path = args.font,
|
|
118
|
+
font_size = args.font_size,
|
|
119
|
+
animation_type = args.style,
|
|
120
|
+
char_delay = args.char_delay,
|
|
121
|
+
line_delay = args.line_delay,
|
|
122
|
+
show_line_numbers = not args.no_line_numbers,
|
|
123
|
+
show_filename_bar = not args.no_tab,
|
|
124
|
+
show_cursor = not args.no_cursor,
|
|
125
|
+
cursor_blink = not args.no_blink,
|
|
126
|
+
follow_cursor = not args.no_follow,
|
|
127
|
+
smooth_scroll = not args.no_smooth_scroll,
|
|
128
|
+
syntax_highlighting = not args.no_highlight,
|
|
129
|
+
instant_indent = not args.type_indent,
|
|
130
|
+
exact_colour = not args.compatible,
|
|
131
|
+
)
|
|
132
|
+
except KeyboardInterrupt:
|
|
133
|
+
if show_progress:
|
|
134
|
+
sys.stderr.write("\n")
|
|
135
|
+
print("cvr: cancelled", file=sys.stderr)
|
|
136
|
+
return 130
|
|
137
|
+
except (RenderError, LookupError, OSError, ValueError) as exc:
|
|
138
|
+
if show_progress:
|
|
139
|
+
sys.stderr.write("\n")
|
|
140
|
+
print(f"cvr: {exc}", file=sys.stderr)
|
|
141
|
+
return 1
|
|
142
|
+
|
|
143
|
+
if show_progress:
|
|
144
|
+
sys.stderr.write("\n")
|
|
145
|
+
print(path)
|
|
146
|
+
if args.open:
|
|
147
|
+
from .players import Player
|
|
148
|
+
try:
|
|
149
|
+
Player("System default", "default").launch(path)
|
|
150
|
+
except OSError as exc:
|
|
151
|
+
print(f"cvr: could not open the video: {exc}", file=sys.stderr)
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _cmd_themes(args) -> int:
|
|
156
|
+
themes = list_themes()
|
|
157
|
+
default = default_theme(themes)
|
|
158
|
+
wanted = args.filter.lower()
|
|
159
|
+
rows = [(t.label, t.path) for t in themes] + [(DEFAULT_PALETTE.name, "(built in)")]
|
|
160
|
+
for label, path in rows:
|
|
161
|
+
if wanted and wanted not in label.lower():
|
|
162
|
+
continue
|
|
163
|
+
mark = "*" if default and label == default.label else " "
|
|
164
|
+
print(f"{mark} {label}" + (f"\n {path}" if args.paths else ""))
|
|
165
|
+
if not themes:
|
|
166
|
+
print("No VS Code themes found; only the built-in palette is available.",
|
|
167
|
+
file=sys.stderr)
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _cmd_gui(_args) -> int:
|
|
172
|
+
try:
|
|
173
|
+
from .gui import main as gui_main
|
|
174
|
+
except ImportError as exc:
|
|
175
|
+
print(f"cvr: the app needs Tkinter, which this Python lacks ({exc}).\n"
|
|
176
|
+
"On Debian or Ubuntu: sudo apt install python3-tk", file=sys.stderr)
|
|
177
|
+
return 1
|
|
178
|
+
gui_main()
|
|
179
|
+
return 0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
183
|
+
args = build_parser().parse_args(argv)
|
|
184
|
+
logging.basicConfig(
|
|
185
|
+
format="cvr: %(message)s",
|
|
186
|
+
level=logging.INFO if getattr(args, "verbose", False) else logging.WARNING)
|
|
187
|
+
command = args.command or "gui"
|
|
188
|
+
return {"render": _cmd_render, "themes": _cmd_themes,
|
|
189
|
+
"gui": _cmd_gui}[command](args)
|
cvr/encoders.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Video writers. Frames go in as RGB uint8 arrays of shape (height, width, 3)."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from typing import Optional, Tuple
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def ffmpeg_exe() -> Optional[str]:
|
|
14
|
+
"""ffmpeg on PATH, else the copy bundled with the imageio-ffmpeg package."""
|
|
15
|
+
exe = shutil.which("ffmpeg")
|
|
16
|
+
if exe:
|
|
17
|
+
return exe
|
|
18
|
+
try:
|
|
19
|
+
import imageio_ffmpeg
|
|
20
|
+
return imageio_ffmpeg.get_ffmpeg_exe()
|
|
21
|
+
except Exception:
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OpenCVWriter:
|
|
26
|
+
"""Fallback encoder. Goes through YUV, so dark colours shift slightly."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, writer, cv2_module):
|
|
29
|
+
self._writer = writer
|
|
30
|
+
self._cv2 = cv2_module
|
|
31
|
+
self.exact_colour = False
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def open(cls, path: str, fps: int, size: Tuple[int, int]):
|
|
35
|
+
try:
|
|
36
|
+
import cv2
|
|
37
|
+
except ImportError:
|
|
38
|
+
return None
|
|
39
|
+
fourcc_of = getattr(cv2, "VideoWriter_fourcc", None) or cv2.VideoWriter.fourcc
|
|
40
|
+
for fourcc_str in ("avc1", "mp4v"):
|
|
41
|
+
w = cv2.VideoWriter(path, fourcc_of(*fourcc_str), float(fps), size)
|
|
42
|
+
if w.isOpened():
|
|
43
|
+
return cls(w, cv2)
|
|
44
|
+
w.release()
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
def write(self, frame: np.ndarray):
|
|
48
|
+
self._writer.write(self._cv2.cvtColor(frame, self._cv2.COLOR_RGB2BGR))
|
|
49
|
+
|
|
50
|
+
def release(self):
|
|
51
|
+
self._writer.release()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class FFmpegWriter:
|
|
55
|
+
"""Encoder that preserves colours exactly.
|
|
56
|
+
|
|
57
|
+
OpenCV converts every frame to limited range YUV, which moves the
|
|
58
|
+
theme colours. Encoding through ffmpeg keeps them intact, so what the
|
|
59
|
+
palette says is what the player shows.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, proc, exact_colour: bool):
|
|
63
|
+
self._proc = proc
|
|
64
|
+
self.exact_colour = exact_colour
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def open(cls, path: str, fps: int, size: Tuple[int, int],
|
|
68
|
+
exact_colour: bool = False):
|
|
69
|
+
exe = ffmpeg_exe()
|
|
70
|
+
if not exe:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
if exact_colour:
|
|
74
|
+
# RGB in, RGB out: no colour conversion, no chroma subsampling.
|
|
75
|
+
# Exact, but this is H.264 High 4:4:4 Predictive, which Windows
|
|
76
|
+
# Media Player refuses to open. VLC and mpv play it.
|
|
77
|
+
codec = ["-c:v", "libx264rgb", "-crf", "12", "-pix_fmt", "bgr0"]
|
|
78
|
+
else:
|
|
79
|
+
# Ordinary High profile that plays everywhere. Full range keeps
|
|
80
|
+
# the luma untouched, where limited range would compress it into
|
|
81
|
+
# 16..235 and lose a level on the dark tones a code theme uses.
|
|
82
|
+
codec = ["-c:v", "libx264", "-crf", "16", "-pix_fmt", "yuv420p",
|
|
83
|
+
"-color_range", "pc", "-colorspace", "bt709",
|
|
84
|
+
"-color_primaries", "bt709", "-color_trc", "bt709",
|
|
85
|
+
"-vf", "scale=in_range=full:out_range=full"]
|
|
86
|
+
|
|
87
|
+
cmd = [
|
|
88
|
+
exe, "-y", "-loglevel", "error",
|
|
89
|
+
"-f", "rawvideo", "-pix_fmt", "rgb24",
|
|
90
|
+
"-s", f"{size[0]}x{size[1]}", "-r", str(fps),
|
|
91
|
+
"-i", "-",
|
|
92
|
+
"-an", *codec,
|
|
93
|
+
"-preset", "medium",
|
|
94
|
+
"-movflags", "+faststart",
|
|
95
|
+
path,
|
|
96
|
+
]
|
|
97
|
+
try:
|
|
98
|
+
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE,
|
|
99
|
+
stdout=subprocess.DEVNULL,
|
|
100
|
+
stderr=subprocess.PIPE)
|
|
101
|
+
except OSError:
|
|
102
|
+
return None
|
|
103
|
+
return cls(proc, exact_colour)
|
|
104
|
+
|
|
105
|
+
def write(self, frame: np.ndarray):
|
|
106
|
+
self._proc.stdin.write(frame.tobytes())
|
|
107
|
+
|
|
108
|
+
def release(self):
|
|
109
|
+
try:
|
|
110
|
+
if self._proc.stdin:
|
|
111
|
+
self._proc.stdin.close()
|
|
112
|
+
except OSError:
|
|
113
|
+
pass
|
|
114
|
+
try:
|
|
115
|
+
self._proc.wait(timeout=60)
|
|
116
|
+
except subprocess.TimeoutExpired:
|
|
117
|
+
self._proc.kill()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def open_writer(path: str, fps: int, size: Tuple[int, int],
|
|
121
|
+
exact_colour: bool = True):
|
|
122
|
+
"""ffmpeg if there is one, else OpenCV, else None."""
|
|
123
|
+
writer = FFmpegWriter.open(path, fps, size, exact_colour=exact_colour)
|
|
124
|
+
if writer is None:
|
|
125
|
+
writer = OpenCVWriter.open(path, fps, size)
|
|
126
|
+
if writer is not None:
|
|
127
|
+
log.warning("ffmpeg not found; using the OpenCV writer, which "
|
|
128
|
+
"shifts dark colours slightly. pip install imageio-ffmpeg "
|
|
129
|
+
"to fix that.")
|
|
130
|
+
return writer
|
cvr/fonts.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Finding a monospace font, and measuring text with it."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
from PIL import ImageFont
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def font_candidates() -> List[str]:
|
|
15
|
+
"""Monospace fonts worth trying on this OS, best first."""
|
|
16
|
+
sys_name = platform.system()
|
|
17
|
+
if sys_name == "Windows":
|
|
18
|
+
return [
|
|
19
|
+
r"C:\Windows\Fonts\CascadiaCode.ttf",
|
|
20
|
+
r"C:\Windows\Fonts\CascadiaCode-Regular.ttf",
|
|
21
|
+
r"C:\Windows\Fonts\CascadiaMono.ttf",
|
|
22
|
+
r"C:\Windows\Fonts\consola.ttf",
|
|
23
|
+
r"C:\Windows\Fonts\cour.ttf",
|
|
24
|
+
r"C:\Windows\Fonts\lucon.ttf",
|
|
25
|
+
]
|
|
26
|
+
if sys_name == "Darwin":
|
|
27
|
+
return [
|
|
28
|
+
"/Library/Fonts/Courier New.ttf",
|
|
29
|
+
"/System/Library/Fonts/Monaco.ttf",
|
|
30
|
+
"/System/Library/Fonts/Menlo.ttc",
|
|
31
|
+
"/System/Library/Fonts/SFMono-Regular.otf",
|
|
32
|
+
]
|
|
33
|
+
return [
|
|
34
|
+
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
|
|
35
|
+
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
|
36
|
+
"/usr/share/fonts/truetype/ubuntu/UbuntuMono-R.ttf",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _open(path: str, size: int) -> ImageFont.FreeTypeFont:
|
|
41
|
+
kwargs = {"index": 0} if path.lower().endswith(".ttc") else {}
|
|
42
|
+
return ImageFont.truetype(path, size, **kwargs)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@functools.lru_cache(maxsize=16)
|
|
46
|
+
def _font_file(font_path: Optional[str]) -> Optional[str]:
|
|
47
|
+
"""First font file that actually opens, `font_path` first.
|
|
48
|
+
|
|
49
|
+
Cached because the last resort walks the whole Windows font folder.
|
|
50
|
+
"""
|
|
51
|
+
candidates = ([font_path] if font_path else []) + font_candidates()
|
|
52
|
+
for path in candidates:
|
|
53
|
+
if not path or not os.path.exists(path):
|
|
54
|
+
continue
|
|
55
|
+
try:
|
|
56
|
+
_open(path, 12)
|
|
57
|
+
return path
|
|
58
|
+
except (IOError, OSError):
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
fonts_dir = r"C:\Windows\Fonts"
|
|
62
|
+
if platform.system() == "Windows" and os.path.exists(fonts_dir):
|
|
63
|
+
for fname in os.listdir(fonts_dir):
|
|
64
|
+
if fname.lower().endswith((".ttf", ".ttc")):
|
|
65
|
+
path = os.path.join(fonts_dir, fname)
|
|
66
|
+
try:
|
|
67
|
+
_open(path, 12)
|
|
68
|
+
return path
|
|
69
|
+
except (IOError, OSError):
|
|
70
|
+
continue
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_font(font_path: Optional[str], size: int) -> ImageFont.FreeTypeFont:
|
|
75
|
+
"""A TrueType font at `size` px: `font_path` if it opens, else a system mono.
|
|
76
|
+
|
|
77
|
+
Each call returns a fresh font object, so the GUI thread and a render
|
|
78
|
+
thread never share one FreeType face.
|
|
79
|
+
"""
|
|
80
|
+
if font_path and not os.path.exists(font_path):
|
|
81
|
+
log.warning("Font %s not found; using a system font.", font_path)
|
|
82
|
+
path = _font_file(font_path)
|
|
83
|
+
if path:
|
|
84
|
+
try:
|
|
85
|
+
return _open(path, size)
|
|
86
|
+
except (IOError, OSError):
|
|
87
|
+
pass
|
|
88
|
+
log.warning("No TrueType font found; falling back to bitmap default.")
|
|
89
|
+
return ImageFont.load_default()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
_TW_CACHE: dict = {}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def text_width(font, text: str) -> float:
|
|
96
|
+
"""Advance width of `text` in pixels.
|
|
97
|
+
|
|
98
|
+
`getbbox` returns *ink* extents, which drift from the real pen advance and
|
|
99
|
+
would slowly push the cursor away from the text it follows; `getlength`
|
|
100
|
+
is the advance, so widths stay additive across tokens.
|
|
101
|
+
"""
|
|
102
|
+
if not text:
|
|
103
|
+
return 0.0
|
|
104
|
+
key = (font, text)
|
|
105
|
+
width = _TW_CACHE.get(key)
|
|
106
|
+
if width is None:
|
|
107
|
+
try:
|
|
108
|
+
width = float(font.getlength(text))
|
|
109
|
+
except (AttributeError, TypeError):
|
|
110
|
+
bb = font.getbbox(text)
|
|
111
|
+
width = float(bb[2] - bb[0])
|
|
112
|
+
if len(_TW_CACHE) > 100_000:
|
|
113
|
+
_TW_CACHE.clear()
|
|
114
|
+
_TW_CACHE[key] = width
|
|
115
|
+
return width
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def text_height(font, text: str = "Mg") -> int:
|
|
119
|
+
"""Ink height of `text` in pixels."""
|
|
120
|
+
bb = font.getbbox(text)
|
|
121
|
+
return bb[3] - bb[1]
|
cvr/gui/__init__.py
ADDED