kk-codeimg 0.0.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.
- kk_codeimg/__init__.py +126 -0
- kk_codeimg/contrast.py +115 -0
- kk_codeimg/palette.py +347 -0
- kk_codeimg/py.typed +0 -0
- kk_codeimg/renderer.py +641 -0
- kk_codeimg/styles.py +233 -0
- kk_codeimg-0.0.1.dist-info/METADATA +282 -0
- kk_codeimg-0.0.1.dist-info/RECORD +11 -0
- kk_codeimg-0.0.1.dist-info/WHEEL +5 -0
- kk_codeimg-0.0.1.dist-info/licenses/LICENSE +21 -0
- kk_codeimg-0.0.1.dist-info/top_level.txt +1 -0
kk_codeimg/renderer.py
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
"""渲染引擎:Pygments 分词 + Pillow 逐 token 绘制。
|
|
2
|
+
|
|
3
|
+
关键规格(实测自社区同类实现,非目测):
|
|
4
|
+
窗口栏 40px / 顶部左右圆角 9px / 控制点直径 12px / 容器宽 48px / margin-left 16px
|
|
5
|
+
阴影 rgba(0,0,0,.6) 0 0 18px 1px + rgba(255,255,255,.23) 0 0 0 1px
|
|
6
|
+
背景 linear-gradient(90deg, c1, c2),即左右走向
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import io
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Dict, List, Optional, Sequence, Tuple
|
|
15
|
+
|
|
16
|
+
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
|
17
|
+
from pygments.lexers import get_lexer_by_name
|
|
18
|
+
from pygments.lexers.python import PythonLexer
|
|
19
|
+
from pygments.token import Token
|
|
20
|
+
from pygments.util import ClassNotFound
|
|
21
|
+
|
|
22
|
+
from .palette import GRADIENTS, THEMES, WINDOW_CONTROLS, Theme, get_gradient, get_theme
|
|
23
|
+
|
|
24
|
+
# ---- 实测规格 ----
|
|
25
|
+
BAR_HEIGHT = 40 # .windowbar_windowContainer height
|
|
26
|
+
BAR_RADIUS = 9 # 顶部左右圆角
|
|
27
|
+
DOT_SIZE = 12 # 控制点直径
|
|
28
|
+
DOT_CONTAINER_W = 48 # 控制点容器宽度
|
|
29
|
+
DOT_MARGIN_LEFT = 16 # margin-left: 1rem
|
|
30
|
+
CORNER_RADIUS = 9 # 编辑器外框圆角
|
|
31
|
+
SHADOW_BLUR = 18 # 0 0 18px 1px
|
|
32
|
+
SHADOW_ALPHA = 153 # rgba(0,0,0,.6)
|
|
33
|
+
RING_COLOR = (255, 255, 255, 59) # rgba(255,255,255,23%)
|
|
34
|
+
CODE_PAD_X = 28 # 代码区左右留白
|
|
35
|
+
CODE_PAD_Y = 20 # 代码区上下留白
|
|
36
|
+
GUTTER_GAP = 14 # 行号与代码间距
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# 字体
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
_FONT_CANDIDATES = {
|
|
43
|
+
"jetbrains": ["JetBrainsMono-Regular.ttf", "JetBrainsMono[wght].ttf"],
|
|
44
|
+
"cascadia": ["CascadiaMono.ttf", "CascadiaCode.ttf"],
|
|
45
|
+
"consolas": ["consola.ttf", "Consola.ttf"],
|
|
46
|
+
"menlo": ["Menlo.ttc", "Menlo-Regular.ttf"],
|
|
47
|
+
"courier": ["cour.ttf", "Courier New.ttf", "cour.ttf"],
|
|
48
|
+
"dejavu": ["DejaVuSansMono.ttf", "DejaVuSansMono_0.ttf"],
|
|
49
|
+
"ubuntu-mono": ["UbuntuMono-R.ttf", "UbuntuMono.ttf"],
|
|
50
|
+
}
|
|
51
|
+
# 默认按此顺序探测;全部为免费商用字体(OFL / Bitstream Vera / 微软随系统授权)
|
|
52
|
+
_DEFAULT_ORDER = ("cascadia", "jetbrains", "dejavu", "consolas", "ubuntu-mono", "courier")
|
|
53
|
+
|
|
54
|
+
_font_cache: Dict[Tuple, ImageFont.FreeTypeFont] = {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _font_dirs() -> List[str]:
|
|
58
|
+
dirs = []
|
|
59
|
+
win = os.environ.get("WINDIR", "C:/Windows")
|
|
60
|
+
dirs.append(os.path.join(win, "Fonts"))
|
|
61
|
+
local = os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\Windows\Fonts")
|
|
62
|
+
if local:
|
|
63
|
+
dirs.append(local)
|
|
64
|
+
dirs.append("/usr/share/fonts/truetype")
|
|
65
|
+
dirs.append("/Library/Fonts")
|
|
66
|
+
return [d for d in dirs if d and os.path.isdir(d)]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _load_font(name: Optional[str], size: int) -> ImageFont.FreeTypeFont:
|
|
70
|
+
"""按名称或路径加载等宽字体,带缓存。"""
|
|
71
|
+
if name and (os.path.isfile(name) or (os.sep in name and name.lower().endswith((".ttf", ".otf", ".ttc")))):
|
|
72
|
+
key = (name, size)
|
|
73
|
+
if key not in _font_cache:
|
|
74
|
+
_font_cache[key] = ImageFont.truetype(name, size)
|
|
75
|
+
return _font_cache[key]
|
|
76
|
+
|
|
77
|
+
# 注意:必须取 dict 的 value(文件名列表),不能直接迭代 dict
|
|
78
|
+
order = _DEFAULT_ORDER if name is None else (name,)
|
|
79
|
+
names: List[str] = []
|
|
80
|
+
for key_name in order:
|
|
81
|
+
names.extend(_FONT_CANDIDATES.get(key_name, [key_name]))
|
|
82
|
+
|
|
83
|
+
for d in _font_dirs():
|
|
84
|
+
for fn in names:
|
|
85
|
+
p = os.path.join(d, fn)
|
|
86
|
+
if os.path.isfile(p):
|
|
87
|
+
cache_key = (p, size)
|
|
88
|
+
if cache_key not in _font_cache:
|
|
89
|
+
_font_cache[cache_key] = ImageFont.truetype(p, size)
|
|
90
|
+
return _font_cache[cache_key]
|
|
91
|
+
raise RuntimeError(
|
|
92
|
+
"未找到可用的等宽字体。请用 font= 参数显式指定 .ttf 路径,"
|
|
93
|
+
"或在系统字体目录安装 Cascadia Mono / JetBrains Mono / DejaVu Sans Mono。"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _load_cjk_font(size: int) -> Optional[ImageFont.FreeTypeFont]:
|
|
98
|
+
"""中文回退字体(等宽字体普遍无中文字形)。找不到返回 None。"""
|
|
99
|
+
for fn in ("msyh.ttc", "msyhl.ttc", "simhei.ttf", "NotoSansSC-VF.ttf",
|
|
100
|
+
"simsun.ttc", "PingFang.ttc", "NotoSansCJK-Regular.ttc"):
|
|
101
|
+
for d in _font_dirs():
|
|
102
|
+
p = os.path.join(d, fn)
|
|
103
|
+
if os.path.isfile(p):
|
|
104
|
+
try:
|
|
105
|
+
return ImageFont.truetype(p, size)
|
|
106
|
+
except Exception:
|
|
107
|
+
continue
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _is_cjk(ch: str) -> bool:
|
|
112
|
+
o = ord(ch)
|
|
113
|
+
return (
|
|
114
|
+
0x4E00 <= o <= 0x9FFF # CJK 统一表意
|
|
115
|
+
or 0x3400 <= o <= 0x4DBF # 扩展 A
|
|
116
|
+
or 0x3000 <= o <= 0x303F # CJK 标点
|
|
117
|
+
or 0xFF00 <= o <= 0xFFEF # 全角
|
|
118
|
+
or 0xAC00 <= o <= 0xD7AF # 韩文
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _split_runs(text: str) -> List[Tuple[str, bool]]:
|
|
123
|
+
"""把文本切成 [(片段, 是否 CJK)],用于逐段选字体。"""
|
|
124
|
+
runs: List[Tuple[str, bool]] = []
|
|
125
|
+
for ch in text:
|
|
126
|
+
c = _is_cjk(ch)
|
|
127
|
+
if runs and runs[-1][1] == c:
|
|
128
|
+
runs[-1] = (runs[-1][0] + ch, c)
|
|
129
|
+
else:
|
|
130
|
+
runs.append((ch, c))
|
|
131
|
+
return runs
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class _TextPen:
|
|
135
|
+
"""按字符选择字体绘制,兼顾等宽对齐与中文显示。"""
|
|
136
|
+
|
|
137
|
+
def __init__(self, mono: ImageFont.FreeTypeFont, cjk: Optional[ImageFont.FreeTypeFont]) -> None:
|
|
138
|
+
self.mono = mono
|
|
139
|
+
self.cjk = cjk
|
|
140
|
+
|
|
141
|
+
def measure(self, text: str) -> float:
|
|
142
|
+
total = 0.0
|
|
143
|
+
if self.cjk is None:
|
|
144
|
+
return self.mono.getlength(text)
|
|
145
|
+
for frag, is_cjk in _split_runs(text):
|
|
146
|
+
total += (self.cjk if is_cjk else self.mono).getlength(frag)
|
|
147
|
+
return total
|
|
148
|
+
|
|
149
|
+
def draw(self, draw: "ImageDraw.ImageDraw", xy: Tuple[float, float], text: str, fill,
|
|
150
|
+
anchor: str = "la", bg: Optional[Tuple[int, int, int]] = None) -> float:
|
|
151
|
+
"""绘制文本并返回结束 x。
|
|
152
|
+
|
|
153
|
+
注意:fill 带 alpha 时不能直接交给 PIL。
|
|
154
|
+
ImageDraw.text() 在 RGBA 图上会忽略 fill 的 alpha 通道
|
|
155
|
+
(实测 title_alpha=1.0 与 0.15 画出的像素完全相同),
|
|
156
|
+
因此这里按 bg 预乘混合,得到等效实色。
|
|
157
|
+
"""
|
|
158
|
+
x, y = xy
|
|
159
|
+
rgba = fill if isinstance(fill, tuple) and len(fill) == 4 else None
|
|
160
|
+
if rgba and rgba[3] < 255:
|
|
161
|
+
base = bg or (0, 0, 0)
|
|
162
|
+
a = rgba[3] / 255.0
|
|
163
|
+
fill = tuple(round(rgba[i] * a + base[i] * (1 - a)) for i in range(3))
|
|
164
|
+
if self.cjk is None:
|
|
165
|
+
draw.text((x, y), text, font=self.mono, fill=fill, anchor=anchor)
|
|
166
|
+
return x + self.mono.getlength(text)
|
|
167
|
+
for frag, is_cjk in _split_runs(text):
|
|
168
|
+
font = self.cjk if is_cjk else self.mono
|
|
169
|
+
draw.text((x, y), frag, font=font, fill=fill, anchor=anchor)
|
|
170
|
+
# 非 "la" 锚点时 PIL 自己处理定位,这里只需推进 x
|
|
171
|
+
x += font.getlength(frag)
|
|
172
|
+
return x
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
# Pygments token -> 语义槽位
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
def _map_token(ttype, theme: Theme) -> str:
|
|
179
|
+
"""把 Pygments token 映射到主题的语义颜色槽。"""
|
|
180
|
+
t = ttype
|
|
181
|
+
# 精确槽位优先
|
|
182
|
+
table = {
|
|
183
|
+
Token.Comment: "comment",
|
|
184
|
+
Token.Keyword.Namespace: "module_keyword",
|
|
185
|
+
Token.Keyword.Type: "type_name",
|
|
186
|
+
Token.Keyword.Constant: "bool",
|
|
187
|
+
Token.Keyword: "keyword",
|
|
188
|
+
Token.Name.Class: "class_name",
|
|
189
|
+
Token.Name.Function: "function",
|
|
190
|
+
Token.Name.Builtin: "name",
|
|
191
|
+
Token.Name.Decorator: "definition_keyword",
|
|
192
|
+
Token.Name.Exception: "class_name",
|
|
193
|
+
Token.Name.Namespace: "namespace",
|
|
194
|
+
Token.Name.Attribute: "property_name",
|
|
195
|
+
Token.Name.Constant: "constant",
|
|
196
|
+
Token.Name.Tag: "tag_name",
|
|
197
|
+
Token.Name.Variable: "variable_name",
|
|
198
|
+
Token.Name: "name",
|
|
199
|
+
Token.Literal.Number: "number",
|
|
200
|
+
Token.String.Doc: "string",
|
|
201
|
+
Token.String.Escape: "string",
|
|
202
|
+
Token.String: "string",
|
|
203
|
+
Token.Operator.Word: "keyword",
|
|
204
|
+
Token.Operator: "operator",
|
|
205
|
+
Token.Punctuation: "punctuation",
|
|
206
|
+
Token.Error: "invalid",
|
|
207
|
+
}
|
|
208
|
+
cur = t
|
|
209
|
+
while cur is not None:
|
|
210
|
+
if cur in table:
|
|
211
|
+
slot = table[cur]
|
|
212
|
+
if slot in theme.colors:
|
|
213
|
+
return slot
|
|
214
|
+
cur = cur.parent
|
|
215
|
+
return "" # 交给主题前景色
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _highlight(code: str, lexer, theme: Theme) -> List[List[Tuple[str, Tuple[int, int, int]]]]:
|
|
219
|
+
"""返回按行切分的 [(片段文本, 颜色)]。"""
|
|
220
|
+
lines: List[List[Tuple[str, Tuple[int, int, int]]]] = []
|
|
221
|
+
default = theme.fg_rgb()
|
|
222
|
+
for ttype, value in lexer.get_tokens(code):
|
|
223
|
+
if not value:
|
|
224
|
+
continue
|
|
225
|
+
slot = _map_token(ttype, theme)
|
|
226
|
+
color = theme.color(slot) if slot else default
|
|
227
|
+
parts = value.split("\n")
|
|
228
|
+
for i, part in enumerate(parts):
|
|
229
|
+
if i > 0:
|
|
230
|
+
lines.append([])
|
|
231
|
+
if part:
|
|
232
|
+
if not lines:
|
|
233
|
+
lines.append([])
|
|
234
|
+
lines[-1].append((part, color))
|
|
235
|
+
return lines
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _get_lexer(language: str):
|
|
239
|
+
try:
|
|
240
|
+
return get_lexer_by_name(language, stripnl=False, ensurenl=False)
|
|
241
|
+
except ClassNotFound:
|
|
242
|
+
return PythonLexer(stripnl=False, ensurenl=False)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
# 绘制基元
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
def _linear_gradient(size: Tuple[int, int], c1: str, c2: str) -> Image.Image:
|
|
249
|
+
"""左右走向线性渐变(对应 CSS linear-gradient(90deg, c1, c2))。"""
|
|
250
|
+
w, h = size
|
|
251
|
+
r1, g1, b1 = _rgb(c1)
|
|
252
|
+
r2, g2, b2 = _rgb(c2)
|
|
253
|
+
img = Image.new("RGB", (w, h))
|
|
254
|
+
px = img.load()
|
|
255
|
+
for x in range(w):
|
|
256
|
+
t = x / max(1, w - 1)
|
|
257
|
+
col = (
|
|
258
|
+
round(r1 + (r2 - r1) * t),
|
|
259
|
+
round(g1 + (g2 - g1) * t),
|
|
260
|
+
round(b1 + (b2 - b1) * t),
|
|
261
|
+
)
|
|
262
|
+
for y in range(h):
|
|
263
|
+
px[x, y] = col
|
|
264
|
+
return img
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _rgb(value: str) -> Tuple[int, int, int]:
|
|
268
|
+
from .palette import _hex_to_rgb
|
|
269
|
+
return _hex_to_rgb(value)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _rounded_mask(size: Tuple[int, int], radius: int) -> Image.Image:
|
|
273
|
+
mask = Image.new("L", size, 0)
|
|
274
|
+
ImageDraw.Draw(mask).rounded_rectangle([0, 0, size[0] - 1, size[1] - 1], radius=radius, fill=255)
|
|
275
|
+
return mask
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
# 主渲染
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
@dataclass
|
|
282
|
+
class RenderResult:
|
|
283
|
+
image: Image.Image
|
|
284
|
+
path: Optional[str] = None
|
|
285
|
+
# 编辑器在画布中的精确矩形 (left, top, right, bottom),供裁剪/校验使用
|
|
286
|
+
editor_box: Optional[Tuple[int, int, int, int]] = None
|
|
287
|
+
# 代码区(窗口栏以下)的精确矩形
|
|
288
|
+
code_box: Optional[Tuple[int, int, int, int]] = None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class CodeImageRenderer:
|
|
292
|
+
"""按既定规格组装图片。"""
|
|
293
|
+
|
|
294
|
+
def __init__(
|
|
295
|
+
self,
|
|
296
|
+
theme: str = "one-dark",
|
|
297
|
+
background: str = "mystic",
|
|
298
|
+
window_controls: str = "color",
|
|
299
|
+
font: Optional[str] = None,
|
|
300
|
+
font_size: int = 15,
|
|
301
|
+
line_height: float = 1.45,
|
|
302
|
+
padding: int = 64,
|
|
303
|
+
line_numbers: bool = True,
|
|
304
|
+
transparent_editor: bool = False,
|
|
305
|
+
title: Optional[str] = None,
|
|
306
|
+
scale: int = 2,
|
|
307
|
+
border_radius: int = CORNER_RADIUS,
|
|
308
|
+
scrim_alpha: Optional[float] = None,
|
|
309
|
+
title_alpha: float = 0.72,
|
|
310
|
+
title_size: Optional[int] = None,
|
|
311
|
+
) -> None:
|
|
312
|
+
self.theme: Theme = get_theme(theme)
|
|
313
|
+
self.background = background
|
|
314
|
+
self.window_controls = window_controls if window_controls in WINDOW_CONTROLS else "color"
|
|
315
|
+
self.font_name = font
|
|
316
|
+
self.font_size = font_size
|
|
317
|
+
self.line_height = line_height
|
|
318
|
+
self.padding = padding
|
|
319
|
+
self.line_numbers = line_numbers
|
|
320
|
+
self.transparent_editor = transparent_editor
|
|
321
|
+
self.title = title
|
|
322
|
+
self.title_alpha = max(0.0, min(1.0, float(title_alpha)))
|
|
323
|
+
# 标题字号:None -> 跟随 font_size(视觉上与代码齐平)
|
|
324
|
+
# "same" -> 显式同字号(等价于 font_size)
|
|
325
|
+
# int -> 指定字号,超过窗口栏容量会被钳制
|
|
326
|
+
self.title_size = self._resolve_title_size(title_size)
|
|
327
|
+
self._title_pen: Optional[_TextPen] = None
|
|
328
|
+
self.scale = max(1, int(scale))
|
|
329
|
+
self.border_radius = max(0, int(border_radius))
|
|
330
|
+
# 透明编辑器下,浅色主题的深色文字直接压在背景上极易糊掉
|
|
331
|
+
# (实测 github-light 的 keyword 压在 leaf 渐变上仅 1.24:1)。
|
|
332
|
+
# 故默认给浅色主题铺一层高不透明度白底,保留背景氛围同时保证可读。
|
|
333
|
+
self.scrim_alpha = scrim_alpha
|
|
334
|
+
|
|
335
|
+
# -- 背景 ---------------------------------------------------------------
|
|
336
|
+
def _build_background(self, w: int, h: int) -> Image.Image:
|
|
337
|
+
"""始终返回 RGBA 画布,避免后续 alpha_composite 模式冲突。"""
|
|
338
|
+
name = (self.background or "mystic").strip().lower()
|
|
339
|
+
if name in ("transparent", "none", "alpha"):
|
|
340
|
+
return Image.new("RGBA", (w, h), (0, 0, 0, 0))
|
|
341
|
+
if name == "single":
|
|
342
|
+
return Image.new("RGBA", (w, h), _rgb("#b993d6") + (255,))
|
|
343
|
+
pair = get_gradient(name)
|
|
344
|
+
if pair is None:
|
|
345
|
+
# 允许直接给 '#aabbcc' 或 'aabbcc'
|
|
346
|
+
v = self.background.strip()
|
|
347
|
+
c = v if v.startswith("#") else "#" + v
|
|
348
|
+
return Image.new("RGBA", (w, h), _rgb(c) + (255,))
|
|
349
|
+
return _linear_gradient((w, h), pair[0], pair[1]).convert("RGBA")
|
|
350
|
+
|
|
351
|
+
# -- 主流程 -------------------------------------------------------------
|
|
352
|
+
def render(self, code: str, language: str = "python", output: Optional[str] = None) -> RenderResult:
|
|
353
|
+
s = self.scale
|
|
354
|
+
theme = self.theme
|
|
355
|
+
font = _load_font(self.font_name, self.font_size * s)
|
|
356
|
+
cjk_font = _load_cjk_font(self.font_size * s)
|
|
357
|
+
pen = _TextPen(font, cjk_font)
|
|
358
|
+
|
|
359
|
+
# 标题字体:与代码**同一个字体族**,只有字号可不同。
|
|
360
|
+
# 字号相同时直接复用代码的字体对象,保证 100% 同一文件(含 CJK)。
|
|
361
|
+
if self.title:
|
|
362
|
+
t_size = self.title_size * s
|
|
363
|
+
if abs(self.title_size - self.font_size) < 1e-6:
|
|
364
|
+
self._title_pen = pen # 完全复用
|
|
365
|
+
else:
|
|
366
|
+
self._title_pen = _TextPen(
|
|
367
|
+
_load_font(self.font_name, t_size),
|
|
368
|
+
_load_cjk_font(t_size),
|
|
369
|
+
)
|
|
370
|
+
else:
|
|
371
|
+
self._title_pen = None
|
|
372
|
+
|
|
373
|
+
lexer = _get_lexer(language)
|
|
374
|
+
hl_lines = _highlight(code.rstrip("\n"), lexer, theme)
|
|
375
|
+
if not hl_lines:
|
|
376
|
+
hl_lines = [[]]
|
|
377
|
+
|
|
378
|
+
# 行高(像素)
|
|
379
|
+
lh = round(self.font_size * self.line_height) * s
|
|
380
|
+
# 单字符宽度:用于行号右对齐与最小宽度估算
|
|
381
|
+
cw = max(1, int(round(font.getlength("M"))))
|
|
382
|
+
|
|
383
|
+
gutter_w = 0
|
|
384
|
+
if self.line_numbers:
|
|
385
|
+
digits = len(str(max(1, len(hl_lines))))
|
|
386
|
+
gutter_w = int(round(pen.measure("8" * digits))) + GUTTER_GAP * s
|
|
387
|
+
|
|
388
|
+
# 文本区宽高
|
|
389
|
+
text_w = 0
|
|
390
|
+
for line in hl_lines:
|
|
391
|
+
w = int(round(sum(pen.measure(t) for t, _ in line)))
|
|
392
|
+
text_w = max(text_w, w)
|
|
393
|
+
text_w = max(text_w, cw * 20)
|
|
394
|
+
|
|
395
|
+
content_w = gutter_w + text_w
|
|
396
|
+
content_h = lh * len(hl_lines)
|
|
397
|
+
|
|
398
|
+
# 编辑器窗口尺寸
|
|
399
|
+
editor_w = content_w + CODE_PAD_X * 2 * s
|
|
400
|
+
editor_h = BAR_HEIGHT * s + content_h + CODE_PAD_Y * 2 * s
|
|
401
|
+
|
|
402
|
+
# 画布尺寸(含外边距与阴影余量)
|
|
403
|
+
pad = self.padding * s
|
|
404
|
+
shadow_pad = (SHADOW_BLUR + 6) * s
|
|
405
|
+
canvas_w = editor_w + pad * 2 + shadow_pad * 2
|
|
406
|
+
canvas_h = editor_h + pad * 2 + shadow_pad * 2
|
|
407
|
+
|
|
408
|
+
canvas = self._build_background(canvas_w, canvas_h)
|
|
409
|
+
|
|
410
|
+
ex = shadow_pad + pad # 编辑器左上角
|
|
411
|
+
ey = shadow_pad + pad
|
|
412
|
+
editor = self._build_editor(editor_w, editor_h, pen, lh, gutter_w, hl_lines)
|
|
413
|
+
# 阴影:模糊黑
|
|
414
|
+
shadow = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
|
415
|
+
sd = ImageDraw.Draw(shadow)
|
|
416
|
+
sd.rounded_rectangle(
|
|
417
|
+
[ex, ey + 2 * s, ex + editor_w, ey + editor_h + 2 * s],
|
|
418
|
+
radius=self.border_radius * s, fill=(0, 0, 0, SHADOW_ALPHA),
|
|
419
|
+
)
|
|
420
|
+
shadow = shadow.filter(ImageFilter.GaussianBlur(SHADOW_BLUR * s / 2))
|
|
421
|
+
canvas = Image.alpha_composite(canvas, shadow)
|
|
422
|
+
|
|
423
|
+
# 1px 白色内描边(rgba(255,255,255,.23))
|
|
424
|
+
ring = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
|
425
|
+
ImageDraw.Draw(ring).rounded_rectangle(
|
|
426
|
+
[ex, ey, ex + editor_w - 1, ey + editor_h - 1],
|
|
427
|
+
radius=self.border_radius * s, outline=RING_COLOR, width=max(1, s // 2),
|
|
428
|
+
)
|
|
429
|
+
canvas = Image.alpha_composite(canvas, ring)
|
|
430
|
+
|
|
431
|
+
# 编辑器主体(自带圆角 alpha 遮罩)
|
|
432
|
+
canvas.paste(editor, (ex, ey), editor)
|
|
433
|
+
box = (ex, ey, ex + editor_w, ey + editor_h)
|
|
434
|
+
code_box = (ex + CODE_PAD_X * s, ey + BAR_HEIGHT * s + CODE_PAD_Y * s,
|
|
435
|
+
ex + editor_w - CODE_PAD_X * s, ey + editor_h - CODE_PAD_Y * s)
|
|
436
|
+
if output:
|
|
437
|
+
os.makedirs(os.path.dirname(os.path.abspath(output)) or ".", exist_ok=True)
|
|
438
|
+
# 仅在不透明背景下压成 RGB;透明背景保留 alpha
|
|
439
|
+
if (self.background or "").strip().lower() in ("transparent", "none", "alpha"):
|
|
440
|
+
canvas.save(output, "PNG")
|
|
441
|
+
else:
|
|
442
|
+
canvas.convert("RGB").save(output, "PNG")
|
|
443
|
+
return RenderResult(canvas, os.path.abspath(output), box, code_box)
|
|
444
|
+
return RenderResult(canvas, None, box, code_box)
|
|
445
|
+
|
|
446
|
+
# -- 编辑器内部 ---------------------------------------------------------
|
|
447
|
+
def _max_title_size(self) -> float:
|
|
448
|
+
"""窗口栏能容纳的最大标题字号(逻辑像素)。
|
|
449
|
+
|
|
450
|
+
栏高固定 40px,字形盒高约为字号的 0.7 倍,留 6px 余量。
|
|
451
|
+
超出这个值标题会盖住代码区,属于静默失败,必须钳制。
|
|
452
|
+
"""
|
|
453
|
+
return max(8.0, (BAR_HEIGHT - 6) / 0.7)
|
|
454
|
+
|
|
455
|
+
def _resolve_title_size(self, title_size) -> float:
|
|
456
|
+
"""把 title_size 归一化为实际使用的字号。
|
|
457
|
+
|
|
458
|
+
None / "same" -> 与 font_size 齐平(字体统一,字号也可统一)
|
|
459
|
+
数字 -> 使用该字号,但不超过窗口栏容量
|
|
460
|
+
"""
|
|
461
|
+
if title_size is None or (isinstance(title_size, str)
|
|
462
|
+
and title_size.strip().lower() == "same"):
|
|
463
|
+
want = float(self.font_size)
|
|
464
|
+
else:
|
|
465
|
+
try:
|
|
466
|
+
want = float(title_size)
|
|
467
|
+
except (TypeError, ValueError):
|
|
468
|
+
raise ValueError(
|
|
469
|
+
f"title_size 应为数字、None 或 'same',收到 {title_size!r}"
|
|
470
|
+
) from None
|
|
471
|
+
if want <= 0:
|
|
472
|
+
raise ValueError(f"title_size 必须大于 0,收到 {title_size!r}")
|
|
473
|
+
return min(want, self._max_title_size())
|
|
474
|
+
|
|
475
|
+
def renderer_probe(self) -> dict:
|
|
476
|
+
"""暴露关键渲染决策,供测试与调试使用(不影响正常渲染)。"""
|
|
477
|
+
return {
|
|
478
|
+
"theme": self.theme.name,
|
|
479
|
+
"dark": self.theme.dark,
|
|
480
|
+
"background": self.background,
|
|
481
|
+
"border_radius": self.border_radius,
|
|
482
|
+
"transparent_editor": self.transparent_editor,
|
|
483
|
+
"scrim": self._scrim_opacity(None),
|
|
484
|
+
"font": self.font_name or "auto",
|
|
485
|
+
"font_size": self.font_size,
|
|
486
|
+
"title_size": self.title_size,
|
|
487
|
+
"title": self.title,
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
def _scrim_opacity(self, bg_samples: Optional[List[Tuple[int, int, int]]]) -> float:
|
|
491
|
+
"""决定透明模式下编辑器铺多少不透明底色。
|
|
492
|
+
|
|
493
|
+
深色主题文字本身够亮,全透明即可,保持通透观感。
|
|
494
|
+
浅色主题必须垫一层高不透明度白底——实测 github-light 的 keyword
|
|
495
|
+
压在 leaf 渐变上只有 1.24:1,垫到 100% 才达 4.5:1(AA)。
|
|
496
|
+
|
|
497
|
+
返回 0~1。scrim_alpha 参数可手动覆盖。
|
|
498
|
+
"""
|
|
499
|
+
if self.scrim_alpha is not None:
|
|
500
|
+
return max(0.0, min(1.0, float(self.scrim_alpha)))
|
|
501
|
+
if not self.theme.dark:
|
|
502
|
+
return 1.0 if bg_samples else 1.0
|
|
503
|
+
return 0.0
|
|
504
|
+
|
|
505
|
+
def _build_editor(self, w: int, h: int, pen: "_TextPen", lh: int, gutter_w: int,
|
|
506
|
+
lines, bg_samples=None) -> Image.Image:
|
|
507
|
+
s = self.scale
|
|
508
|
+
theme = self.theme
|
|
509
|
+
if self.transparent_editor:
|
|
510
|
+
alpha = self._scrim_opacity(bg_samples)
|
|
511
|
+
if alpha >= 1.0:
|
|
512
|
+
img = Image.new("RGBA", (w, h), theme.bg_rgb() + (255,))
|
|
513
|
+
elif alpha > 0:
|
|
514
|
+
# 磨砂:半透明主题底色,背景渐变隐约透出
|
|
515
|
+
img = Image.new("RGBA", (w, h), theme.bg_rgb() + (round(alpha * 255),))
|
|
516
|
+
else:
|
|
517
|
+
img = Image.new("RGBA", (w, h), (0, 0, 0, 0))
|
|
518
|
+
else:
|
|
519
|
+
img = Image.new("RGBA", (w, h), theme.bg_rgb() + (255,))
|
|
520
|
+
d = ImageDraw.Draw(img)
|
|
521
|
+
|
|
522
|
+
self._draw_window_controls(d, w, h)
|
|
523
|
+
if self._title_pen is not None:
|
|
524
|
+
self._draw_title(d, w, h, self._title_pen)
|
|
525
|
+
|
|
526
|
+
# 代码区起点
|
|
527
|
+
cy = BAR_HEIGHT * s + CODE_PAD_Y * s
|
|
528
|
+
cx = CODE_PAD_X * s
|
|
529
|
+
gutter_color = theme.gutter_rgb()
|
|
530
|
+
|
|
531
|
+
for idx, line in enumerate(lines, start=1):
|
|
532
|
+
y = cy + (idx - 1) * lh
|
|
533
|
+
if self.line_numbers:
|
|
534
|
+
num = str(idx)
|
|
535
|
+
num_w = pen.measure(num)
|
|
536
|
+
pen.draw(d, (cx + gutter_w - GUTTER_GAP * s - num_w, y), num, gutter_color)
|
|
537
|
+
x = cx + gutter_w
|
|
538
|
+
for text, color in line:
|
|
539
|
+
x = pen.draw(d, (x, y), text, color)
|
|
540
|
+
|
|
541
|
+
# 应用圆角遮罩:此前编辑器画的是直角矩形,边缘显得生硬。
|
|
542
|
+
# 窗口栏只需顶部左右圆角,代码区保持方角,故用整块圆角 + 顶部补方。
|
|
543
|
+
r = max(0, int(self.border_radius)) * s
|
|
544
|
+
if r > 0:
|
|
545
|
+
mask = _rounded_mask((w, h), r)
|
|
546
|
+
img.putalpha(mask)
|
|
547
|
+
return img
|
|
548
|
+
|
|
549
|
+
def _draw_window_controls(self, d: ImageDraw.ImageDraw, w: int, h: int) -> None:
|
|
550
|
+
s = self.scale
|
|
551
|
+
spec = WINDOW_CONTROLS[self.window_controls]
|
|
552
|
+
if self.window_controls != "none":
|
|
553
|
+
border = spec.get("border")
|
|
554
|
+
r = DOT_SIZE * s / 2
|
|
555
|
+
cy = BAR_HEIGHT * s / 2
|
|
556
|
+
start_x = DOT_MARGIN_LEFT * s + r
|
|
557
|
+
gap = (DOT_CONTAINER_W * s - DOT_SIZE * s) / 2
|
|
558
|
+
for key in ("exit", "min", "max"):
|
|
559
|
+
cx = start_x + gap * ("exit", "min", "max").index(key)
|
|
560
|
+
color = spec.get(key)
|
|
561
|
+
if color is None:
|
|
562
|
+
if border:
|
|
563
|
+
d.ellipse([cx - r, cy - r, cx + r, cy + r],
|
|
564
|
+
outline=_rgb(border), width=max(1, s // 2))
|
|
565
|
+
else:
|
|
566
|
+
d.ellipse([cx - r, cy - r, cx + r, cy + r], outline=(0, 0, 0, 0))
|
|
567
|
+
else:
|
|
568
|
+
col = _rgba(color)
|
|
569
|
+
d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=col)
|
|
570
|
+
|
|
571
|
+
def _draw_title(self, d: ImageDraw.ImageDraw, w: int, h: int, pen: "_TextPen") -> None:
|
|
572
|
+
"""在窗口栏居中绘制标题。
|
|
573
|
+
|
|
574
|
+
配色:用主题前景色而非 gutter 色——实测 gutter 色在部分主题上
|
|
575
|
+
对比度仅 2.76:1(tokyo-night),前景色全部 >= 6.10:1。
|
|
576
|
+
再乘 title_alpha 让它退居次要,不与代码抢视觉焦点。
|
|
577
|
+
"""
|
|
578
|
+
if not self.title:
|
|
579
|
+
return
|
|
580
|
+
s = self.scale
|
|
581
|
+
text = str(self.title)
|
|
582
|
+
|
|
583
|
+
# 标题在**整个编辑器宽度**居中。
|
|
584
|
+
# 早期实现是在「扣掉左侧控制点区」的不对称区域里居中
|
|
585
|
+
# (左 160px / 右 32px),导致标题右偏 58px(scale=2)。
|
|
586
|
+
# 现在 x 由编辑器中点决定,left_pad 只用于超宽判定与截断。
|
|
587
|
+
left_pad = (DOT_MARGIN_LEFT + DOT_CONTAINER_W + 16) * s
|
|
588
|
+
right_pad = 16 * s
|
|
589
|
+
max_w = w - left_pad - right_pad
|
|
590
|
+
if max_w <= 0:
|
|
591
|
+
return
|
|
592
|
+
|
|
593
|
+
# 标题宽度用 pen.measure,正确处理中英混排
|
|
594
|
+
size = pen.measure(text)
|
|
595
|
+
if size > max_w:
|
|
596
|
+
# 超宽则截断并加省略号
|
|
597
|
+
out: List[str] = []
|
|
598
|
+
acc = 0.0
|
|
599
|
+
ell = "…"
|
|
600
|
+
for ch in text:
|
|
601
|
+
cw = pen.measure(ch + ell)
|
|
602
|
+
if acc + cw > max_w:
|
|
603
|
+
break
|
|
604
|
+
out.append(ch)
|
|
605
|
+
acc += cw
|
|
606
|
+
text = "".join(out) + ell
|
|
607
|
+
size = pen.measure(text)
|
|
608
|
+
|
|
609
|
+
# 关键:x 为标题左边缘,锚点 "lm" 表示从该点向右绘制。
|
|
610
|
+
# 故 x = 编辑器中点 - 标题半宽,才是真正的视觉居中。
|
|
611
|
+
x = w / 2 - size / 2
|
|
612
|
+
# 标题很宽时(超过半边),居中会撞上左侧控制点区 —— 钳制避让。
|
|
613
|
+
# 此时牺牲"严格居中"以换取不重叠,属于必要取舍。
|
|
614
|
+
if x < left_pad:
|
|
615
|
+
x = left_pad
|
|
616
|
+
elif x + size > w - right_pad:
|
|
617
|
+
x = max(left_pad, w - right_pad - size)
|
|
618
|
+
y = BAR_HEIGHT * s / 2
|
|
619
|
+
# 标题用前景色(对比度 >= 6.10:1),乘 title_alpha 退居次要。
|
|
620
|
+
# 混合底色取编辑器实际呈现色——透明模式下是垫底色,需按不透明度算。
|
|
621
|
+
base = self.theme.bg_rgb()
|
|
622
|
+
if self.transparent_editor:
|
|
623
|
+
a = self._scrim_opacity(None)
|
|
624
|
+
if a < 1.0:
|
|
625
|
+
# 全透明时用背景色兜底混合,避免标题变成"看不见的浅色"
|
|
626
|
+
base = _rgb(get_gradient(self.background)[0]) if get_gradient(self.background) else base
|
|
627
|
+
else:
|
|
628
|
+
base = self.theme.bg_rgb()
|
|
629
|
+
color = self.theme.fg_rgb() + (round(255 * self.title_alpha),)
|
|
630
|
+
pen.draw(d, (x, y), text, color, anchor="lm", bg=base)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _rgba(value: str) -> Tuple[int, int, int, int]:
|
|
634
|
+
"""支持 #RRGGBB / #RRGGBBAA(gray 系列控制点就是 8 位带 alpha)。"""
|
|
635
|
+
from .palette import _hex_to_rgb
|
|
636
|
+
v = value.strip().lstrip("#")
|
|
637
|
+
if len(v) == 8: # RRGGBBAA
|
|
638
|
+
r, g, b = _hex_to_rgb("#" + v[:6])
|
|
639
|
+
return (r, g, b, int(v[6:8], 16))
|
|
640
|
+
r, g, b = _hex_to_rgb("#" + v)
|
|
641
|
+
return (r, g, b, 255)
|