linecard 0.2.0.post1__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.
- linecard-0.2.0.post1/LICENSE +21 -0
- linecard-0.2.0.post1/PKG-INFO +32 -0
- linecard-0.2.0.post1/README.md +17 -0
- linecard-0.2.0.post1/linecard/__init__.py +582 -0
- linecard-0.2.0.post1/linecard/linecard_parsing.cp313-win_amd64.pyd +0 -0
- linecard-0.2.0.post1/linecard/linecard_parsing.pyi +1 -0
- linecard-0.2.0.post1/pyproject.toml +27 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 KarisAya
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: linecard
|
|
3
|
+
Version: 0.2.0.post1
|
|
4
|
+
Summary:
|
|
5
|
+
Author: KarisAya
|
|
6
|
+
Author-email: karisaya@foxmail.com
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
11
|
+
Requires-Dist: fonttools (>=4.58.0,<5.0.0)
|
|
12
|
+
Requires-Dist: pillow (>=11.1.0,<12.0.0)
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
<div align="center">
|
|
16
|
+
|
|
17
|
+
# linecard
|
|
18
|
+
|
|
19
|
+
_✨ linecard 是一个基于 pillow 的一个方便文字转图片的函数。 ✨_
|
|
20
|
+
|
|
21
|
+
[](https://www.python.org/)
|
|
22
|
+
[](https://pypi.python.org/pypi/linecard)
|
|
23
|
+
[](https://pypi.python.org/pypi/linecard)
|
|
24
|
+
[](https://python-poetry.org/)
|
|
25
|
+
[](./LICENSE)
|
|
26
|
+
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
[使用方法见示例文件](/example/main.py)
|
|
30
|
+
|
|
31
|
+

|
|
32
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# linecard
|
|
4
|
+
|
|
5
|
+
_✨ linecard 是一个基于 pillow 的一个方便文字转图片的函数。 ✨_
|
|
6
|
+
|
|
7
|
+
[](https://www.python.org/)
|
|
8
|
+
[](https://pypi.python.org/pypi/linecard)
|
|
9
|
+
[](https://pypi.python.org/pypi/linecard)
|
|
10
|
+
[](https://python-poetry.org/)
|
|
11
|
+
[](./LICENSE)
|
|
12
|
+
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
[使用方法见示例文件](/example/main.py)
|
|
16
|
+
|
|
17
|
+

|
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from collections import deque
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from fontTools.ttLib import TTFont
|
|
7
|
+
from PIL import Image, ImageDraw, ImageFont, ImageFilter
|
|
8
|
+
from PIL.ImageFont import FreeTypeFont
|
|
9
|
+
from PIL.Image import Image as IMG
|
|
10
|
+
from typing import cast, TypedDict, Literal, Protocol, TypeGuard
|
|
11
|
+
from .linecard_parsing import parse_str
|
|
12
|
+
|
|
13
|
+
match sys.platform:
|
|
14
|
+
case "win32":
|
|
15
|
+
FONT_PATHS = [
|
|
16
|
+
"C:/Windows/Fonts",
|
|
17
|
+
f"{os.environ["LOCALAPPDATA"]}/Microsoft/Windows/Fonts",
|
|
18
|
+
]
|
|
19
|
+
case "darwin":
|
|
20
|
+
FONT_PATHS = ["/Library/Fonts"]
|
|
21
|
+
case "linux":
|
|
22
|
+
FONT_PATHS = ["/usr/share/fonts"]
|
|
23
|
+
case _:
|
|
24
|
+
FONT_PATHS = os.environ["LINECARD_FONT_PATH"].split(";")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def find_font(font_name: str, search_paths: Iterable[Path] = [Path(FONT_PATH) for FONT_PATH in FONT_PATHS]):
|
|
28
|
+
def check_font(font_file: Path, font_name: str):
|
|
29
|
+
suffix = font_file.suffix.lower()
|
|
30
|
+
if not suffix.endswith((".ttf", ".otf", ".ttc")):
|
|
31
|
+
return False
|
|
32
|
+
if not font_name.lower() == font_file.stem.lower():
|
|
33
|
+
return False
|
|
34
|
+
try:
|
|
35
|
+
TTFont(font_file, recalcBBoxes=False, recalcTimestamp=False, fontNumber=0)
|
|
36
|
+
except:
|
|
37
|
+
return False
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
TTFont(font_name, recalcBBoxes=False, recalcTimestamp=False, fontNumber=0)
|
|
42
|
+
return Path(font_name).absolute().as_posix()
|
|
43
|
+
except:
|
|
44
|
+
pass
|
|
45
|
+
for search_path in search_paths:
|
|
46
|
+
if not search_path.exists():
|
|
47
|
+
continue
|
|
48
|
+
for file in search_path.iterdir():
|
|
49
|
+
if check_font(file, font_name):
|
|
50
|
+
return file.absolute().as_posix()
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def to_int(n: str | None):
|
|
55
|
+
try:
|
|
56
|
+
return int(n) if n else None
|
|
57
|
+
except ValueError:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def line_wrap(line: str, width: int, font: FreeTypeFont, start: float = 0.0) -> str:
|
|
62
|
+
"""调整文字排版到指定宽度,并添加换行符
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
line (str): 待排版的文本,一般是
|
|
66
|
+
width: 文字换行宽度
|
|
67
|
+
font (FreeTypeFont): 字体
|
|
68
|
+
start: 首行起始位置 default to 0.0
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
str: 排版后的文本
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
text_x = start
|
|
75
|
+
new_str = ""
|
|
76
|
+
for char in line:
|
|
77
|
+
if char == "\n":
|
|
78
|
+
new_str += "\n"
|
|
79
|
+
text_x = 0
|
|
80
|
+
else:
|
|
81
|
+
char_lenth = font.getlength(char)
|
|
82
|
+
text_x += char_lenth
|
|
83
|
+
if text_x > width:
|
|
84
|
+
new_str += "\n" + char
|
|
85
|
+
text_x = char_lenth
|
|
86
|
+
else:
|
|
87
|
+
new_str += char
|
|
88
|
+
return new_str
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def CropResize(img: IMG, size: tuple[int, int]) -> IMG:
|
|
92
|
+
"""
|
|
93
|
+
修改图像尺寸
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
test_x = img.size[0] / size[0]
|
|
97
|
+
test_y = img.size[1] / size[1]
|
|
98
|
+
|
|
99
|
+
if test_x < test_y:
|
|
100
|
+
width = img.size[0]
|
|
101
|
+
height = size[1] * test_x
|
|
102
|
+
else:
|
|
103
|
+
width = size[0] * test_y
|
|
104
|
+
height = img.size[1]
|
|
105
|
+
|
|
106
|
+
center = (img.size[0] / 2, img.size[1] / 2)
|
|
107
|
+
output = img.crop(
|
|
108
|
+
(
|
|
109
|
+
int(center[0] - width / 2),
|
|
110
|
+
int(center[1] - height / 2),
|
|
111
|
+
int(center[0] + width / 2),
|
|
112
|
+
int(center[1] + height / 2),
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
output = output.resize(size)
|
|
116
|
+
return output
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
type ImageList = list[IMG]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class CanvasEffectHandler(Protocol):
|
|
123
|
+
"""图片蒙版效果处理器
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
canvas (IMG): 被处理的背景图片
|
|
127
|
+
image (IMG): 被处理的前景图片
|
|
128
|
+
padding (int): 图片边距
|
|
129
|
+
x (int): image 起始像素 x 轴坐标
|
|
130
|
+
y (int): image 起始像素 y 轴坐标
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
None
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
def __call__(self, canvas: IMG, image: IMG, padding: int, x: int, y: int) -> None: ...
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def info_splicing(
|
|
140
|
+
info: ImageList,
|
|
141
|
+
BG_path: str | Path | None = None,
|
|
142
|
+
width: int = 880,
|
|
143
|
+
padding: int = 20,
|
|
144
|
+
spacing: int = 20,
|
|
145
|
+
BG_type: str | CanvasEffectHandler = "GAUSS",
|
|
146
|
+
) -> IMG:
|
|
147
|
+
"""
|
|
148
|
+
信息拼接
|
|
149
|
+
info:信息图片列表
|
|
150
|
+
bg_path:背景地址
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
height = padding
|
|
154
|
+
for image in info:
|
|
155
|
+
# x = image.size[0] if x < image.size[0] else x
|
|
156
|
+
height += image.size[1]
|
|
157
|
+
height += spacing * 2
|
|
158
|
+
else:
|
|
159
|
+
height = height - spacing + padding
|
|
160
|
+
|
|
161
|
+
size = (width + padding * 2, height)
|
|
162
|
+
if BG_path is not None and ((BG_path := Path(BG_path)) if isinstance(BG_path, str) else BG_path).exists():
|
|
163
|
+
bg = Image.open(BG_path).convert("RGB")
|
|
164
|
+
canvas = CropResize(bg, size)
|
|
165
|
+
else:
|
|
166
|
+
canvas = Image.new("RGB", size, "white")
|
|
167
|
+
BG_type = "NONE"
|
|
168
|
+
|
|
169
|
+
CanvasEffect: CanvasEffectHandler
|
|
170
|
+
|
|
171
|
+
if isinstance(BG_type, str):
|
|
172
|
+
if BG_type == "NONE":
|
|
173
|
+
|
|
174
|
+
def BG(canvas: IMG, image: IMG, padding: int, x: int, y: int):
|
|
175
|
+
canvas.paste(image, (padding, y), mask=image)
|
|
176
|
+
|
|
177
|
+
elif BG_type.startswith("GAUSS"):
|
|
178
|
+
arg = BG_type.split(":")
|
|
179
|
+
if len(arg) > 1:
|
|
180
|
+
try:
|
|
181
|
+
radius = int(arg[1])
|
|
182
|
+
except ValueError:
|
|
183
|
+
radius = 4
|
|
184
|
+
else:
|
|
185
|
+
radius = 4
|
|
186
|
+
|
|
187
|
+
def BG(canvas: IMG, image: IMG, padding: int, x: int, y: int):
|
|
188
|
+
box = (padding, y, x + padding, y + image.size[1])
|
|
189
|
+
region = canvas.crop(box)
|
|
190
|
+
blurred_region = region.filter(ImageFilter.GaussianBlur(radius=radius))
|
|
191
|
+
canvas.paste(blurred_region, box)
|
|
192
|
+
canvas.paste(image, (padding, y), mask=image)
|
|
193
|
+
|
|
194
|
+
else:
|
|
195
|
+
|
|
196
|
+
def BG(canvas: IMG, image: IMG, padding: int, x: int, y: int):
|
|
197
|
+
colorBG = Image.new("RGBA", (x, image.size[1]), BG_type)
|
|
198
|
+
canvas.paste(colorBG, (padding, y), mask=colorBG)
|
|
199
|
+
canvas.paste(image, (padding, y), mask=image)
|
|
200
|
+
|
|
201
|
+
CanvasEffect = BG
|
|
202
|
+
else:
|
|
203
|
+
CanvasEffect = BG_type
|
|
204
|
+
|
|
205
|
+
height = padding
|
|
206
|
+
|
|
207
|
+
for image in info:
|
|
208
|
+
CanvasEffect(canvas, image, padding, width, height)
|
|
209
|
+
height += image.size[1] + spacing * 2
|
|
210
|
+
|
|
211
|
+
return canvas
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class Linecard:
|
|
215
|
+
"""
|
|
216
|
+
文本标记
|
|
217
|
+
----:横线
|
|
218
|
+
[left]靠左
|
|
219
|
+
[right]靠右
|
|
220
|
+
[center]居中
|
|
221
|
+
[pixel 400]指定像素
|
|
222
|
+
[font size = 50,name = simsun,color = red,highlight = yellow]指定文本格式
|
|
223
|
+
[style **kwargs] 控制参数
|
|
224
|
+
height: 行高
|
|
225
|
+
width: 行宽
|
|
226
|
+
color: 本行颜色
|
|
227
|
+
[nowrap]不换行
|
|
228
|
+
[passport]保持标记
|
|
229
|
+
[autowrap]自动换行
|
|
230
|
+
[noautowrap]不自动换行
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
def __init__(self, font_name: str, fallback: list[str], sizes: Iterable[int] | None = None) -> None:
|
|
234
|
+
path = find_font(font_name)
|
|
235
|
+
if not path:
|
|
236
|
+
raise ValueError(f"Font:{font_name} not found")
|
|
237
|
+
self.font_path = path
|
|
238
|
+
self.font_cache: dict[str, dict[int, ImageFont.FreeTypeFont]] = {}
|
|
239
|
+
self.font_cache = {}
|
|
240
|
+
fallback_paths: list[str] = [path for font in fallback if (path := find_font(font))]
|
|
241
|
+
self.cmaps = {fallback_path: TTFont(fallback_path, fontNumber=0).getBestCmap() for fallback_path in fallback_paths}
|
|
242
|
+
self.cmaps = {k: v for k, v in self.cmaps.items() if v is not None}
|
|
243
|
+
self.fallback_paths = list(self.cmaps.keys())
|
|
244
|
+
if sizes:
|
|
245
|
+
for size in sizes:
|
|
246
|
+
self.get_font(self.font_path, size)
|
|
247
|
+
|
|
248
|
+
def get_font(self, name: str, size: int):
|
|
249
|
+
try:
|
|
250
|
+
font_cache = self.font_cache.get(name)
|
|
251
|
+
if font_cache:
|
|
252
|
+
if size in font_cache:
|
|
253
|
+
font = font_cache[size]
|
|
254
|
+
else:
|
|
255
|
+
font = font_cache[size] = ImageFont.truetype(font=name, size=size, encoding="utf-8")
|
|
256
|
+
else:
|
|
257
|
+
font = ImageFont.truetype(font=name, size=size, encoding="utf-8")
|
|
258
|
+
name = Path(cast(str, font.path)).absolute().as_posix()
|
|
259
|
+
self.font_cache.setdefault(name, {})[size] = font
|
|
260
|
+
if name in self.cmaps:
|
|
261
|
+
cmap = self.cmaps[name]
|
|
262
|
+
else:
|
|
263
|
+
cmap = self.cmaps[name] = TTFont(name, fontNumber=font.index).getBestCmap()
|
|
264
|
+
return font, cast(dict[int, int], cmap)
|
|
265
|
+
except OSError:
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
class CharSingle(TypedDict):
|
|
269
|
+
char: str
|
|
270
|
+
color: str
|
|
271
|
+
y: int
|
|
272
|
+
x: int
|
|
273
|
+
end_x: int
|
|
274
|
+
font: FreeTypeFont
|
|
275
|
+
align: str
|
|
276
|
+
highlight: str | None
|
|
277
|
+
|
|
278
|
+
class CharLine(TypedDict):
|
|
279
|
+
char: Literal["----"]
|
|
280
|
+
color: str
|
|
281
|
+
y: int
|
|
282
|
+
size: int
|
|
283
|
+
color: str
|
|
284
|
+
|
|
285
|
+
type CharStyle = CharSingle | CharLine
|
|
286
|
+
type CharStyleList = list[CharStyle]
|
|
287
|
+
|
|
288
|
+
@staticmethod
|
|
289
|
+
def is_charsingle(chaestyle: CharStyle) -> TypeGuard[CharSingle]:
|
|
290
|
+
return chaestyle["char"] != "----"
|
|
291
|
+
|
|
292
|
+
def __call__(
|
|
293
|
+
self,
|
|
294
|
+
text: str,
|
|
295
|
+
font_size: int,
|
|
296
|
+
width: int | None = None,
|
|
297
|
+
height: int | None = None,
|
|
298
|
+
padding: tuple[int, int] = (20, 20),
|
|
299
|
+
spacing: float = 1.2,
|
|
300
|
+
color: str = "black",
|
|
301
|
+
bg_color: str | None = None,
|
|
302
|
+
autowrap: bool = False,
|
|
303
|
+
canvas: IMG | None = None,
|
|
304
|
+
) -> IMG:
|
|
305
|
+
font_cmap = self.get_font(self.font_path, font_size)
|
|
306
|
+
assert font_cmap is not None, "字体文件不存在"
|
|
307
|
+
text, tags = parse_str(text)
|
|
308
|
+
tags = deque(tags)
|
|
309
|
+
padding_x, padding_y = padding
|
|
310
|
+
|
|
311
|
+
charlist: Linecard.CharStyleList = []
|
|
312
|
+
|
|
313
|
+
wrap_width: int = width - padding_x if width else 0
|
|
314
|
+
absolute_spacing: int = int(font_size * (spacing - 1.0) + 0.5)
|
|
315
|
+
|
|
316
|
+
line_height: int = 0
|
|
317
|
+
line_width: int = 0
|
|
318
|
+
|
|
319
|
+
line_align: str = "left"
|
|
320
|
+
line_font, line_cmap = font_cmap
|
|
321
|
+
|
|
322
|
+
line_passport: bool = False
|
|
323
|
+
line_autowrap: bool = False
|
|
324
|
+
line_nowrap: bool = False
|
|
325
|
+
|
|
326
|
+
line_color: str = color
|
|
327
|
+
line_highlight: str | None = None
|
|
328
|
+
|
|
329
|
+
inline_height: int = 0
|
|
330
|
+
|
|
331
|
+
def line_init() -> None:
|
|
332
|
+
nonlocal line_height, line_width, line_align, line_font, line_cmap, line_passport, line_autowrap, line_nowrap, line_color, line_highlight, inline_height
|
|
333
|
+
line_height = font_size
|
|
334
|
+
line_width = wrap_width
|
|
335
|
+
|
|
336
|
+
line_align = line_align if line_nowrap else "left"
|
|
337
|
+
|
|
338
|
+
line_font, line_cmap = font_cmap
|
|
339
|
+
|
|
340
|
+
line_passport = False
|
|
341
|
+
line_autowrap = autowrap
|
|
342
|
+
line_nowrap = False
|
|
343
|
+
|
|
344
|
+
line_color = color
|
|
345
|
+
line_highlight = None
|
|
346
|
+
|
|
347
|
+
inline_height = 0
|
|
348
|
+
|
|
349
|
+
x: int = 0
|
|
350
|
+
max_x: int = 0
|
|
351
|
+
y: int = 0
|
|
352
|
+
|
|
353
|
+
for line in text.split("\n"):
|
|
354
|
+
# 检查继承格式
|
|
355
|
+
if line_passport:
|
|
356
|
+
line_passport = False
|
|
357
|
+
else:
|
|
358
|
+
line_init()
|
|
359
|
+
tmp_height: int = 0
|
|
360
|
+
for unit_rawline in line.split("{"):
|
|
361
|
+
# 渲染行单位存在格式标签
|
|
362
|
+
if unit_rawline.startswith("}"):
|
|
363
|
+
unit_line = unit_rawline = unit_rawline[1:]
|
|
364
|
+
tag, param = tags.popleft()
|
|
365
|
+
match tag:
|
|
366
|
+
# 原样输出字符串
|
|
367
|
+
case b"r":
|
|
368
|
+
unit_line = param + unit_rawline
|
|
369
|
+
# 对齐标签
|
|
370
|
+
case b"a":
|
|
371
|
+
unit_align = param
|
|
372
|
+
if line_align != unit_align:
|
|
373
|
+
x = 0
|
|
374
|
+
line_align = unit_align
|
|
375
|
+
# 字体标签
|
|
376
|
+
case b"f":
|
|
377
|
+
fontkwargs = {k: v for k, v in [x.split("=", 1) for x in param.replace(" ", "").split(",")]}
|
|
378
|
+
if unit_font_name := fontkwargs.get("name"):
|
|
379
|
+
unit_font_name = find_font(unit_font_name)
|
|
380
|
+
if unit_font_size := fontkwargs.get("size"):
|
|
381
|
+
unit_font_size = to_int(unit_font_size)
|
|
382
|
+
if unit_font_size:
|
|
383
|
+
if not unit_font_name:
|
|
384
|
+
unit_font_name = cast(str, line_font.path)
|
|
385
|
+
line_font_cmap = self.get_font(unit_font_name, unit_font_size)
|
|
386
|
+
if line_font_cmap:
|
|
387
|
+
line_font, line_cmap = line_font_cmap
|
|
388
|
+
elif unit_font_name:
|
|
389
|
+
unit_font_size = int(line_font.size)
|
|
390
|
+
line_font_cmap = self.get_font(unit_font_name, unit_font_size)
|
|
391
|
+
if line_font_cmap:
|
|
392
|
+
line_font, line_cmap = line_font_cmap
|
|
393
|
+
line_color = fontkwargs.get("color", line_color)
|
|
394
|
+
line_highlight = fontkwargs.get("highlight")
|
|
395
|
+
# 样式标签
|
|
396
|
+
case b"s":
|
|
397
|
+
stylekwargs = {k: v for k, v in [x.split("=", 1) for x in param.replace(" ", "").split(",")]}
|
|
398
|
+
if unit_height := stylekwargs.get("height"):
|
|
399
|
+
line_height = to_int(unit_height) or line_height
|
|
400
|
+
if unit_width := stylekwargs.get("width"):
|
|
401
|
+
line_width = to_int(unit_width) or line_height
|
|
402
|
+
line_color = stylekwargs.get("color", color)
|
|
403
|
+
case b"t":
|
|
404
|
+
match param:
|
|
405
|
+
case "nowrap":
|
|
406
|
+
line_nowrap = True
|
|
407
|
+
case "autowrap":
|
|
408
|
+
line_autowrap = True
|
|
409
|
+
case "noautowrap":
|
|
410
|
+
line_autowrap = False
|
|
411
|
+
case "passport":
|
|
412
|
+
line_passport = True
|
|
413
|
+
else:
|
|
414
|
+
unit_line = unit_rawline
|
|
415
|
+
# 渲染行单位格式标签外的文本
|
|
416
|
+
if not unit_line:
|
|
417
|
+
continue
|
|
418
|
+
elif unit_rawline == "----":
|
|
419
|
+
line_height = line_height or font_size
|
|
420
|
+
charlist.append({"char": "----", "color": line_color, "y": y, "size": line_height})
|
|
421
|
+
x = 0
|
|
422
|
+
else:
|
|
423
|
+
inline_height = int(line_font.size)
|
|
424
|
+
if line_width and line_autowrap:
|
|
425
|
+
if line_align in ("left", "right", "center"):
|
|
426
|
+
start_x = x
|
|
427
|
+
char_align = 0
|
|
428
|
+
else:
|
|
429
|
+
char_align = to_int(line_align) or 0
|
|
430
|
+
start_x = char_align + x
|
|
431
|
+
if line_font.getlength(unit_line) > line_width - start_x:
|
|
432
|
+
if (inner_wrap_width := line_width - char_align) > inline_height:
|
|
433
|
+
unit_line = line_wrap(unit_line, inner_wrap_width, line_font, x)
|
|
434
|
+
line_seg = unit_line.split("\n")
|
|
435
|
+
line_seg_l = len(line_seg)
|
|
436
|
+
for i, seg in enumerate(line_seg, 1):
|
|
437
|
+
for char in seg:
|
|
438
|
+
charcode = ord(char)
|
|
439
|
+
if charcode in line_cmap:
|
|
440
|
+
inner_font = line_font
|
|
441
|
+
else:
|
|
442
|
+
for fallback_path in self.fallback_paths:
|
|
443
|
+
if charcode in self.cmaps[fallback_path]:
|
|
444
|
+
inner_font = self.get_font(fallback_path, inline_height)
|
|
445
|
+
assert inner_font is not None
|
|
446
|
+
inner_font = inner_font[0]
|
|
447
|
+
break
|
|
448
|
+
else:
|
|
449
|
+
inner_font = line_font
|
|
450
|
+
char = "□"
|
|
451
|
+
temp_x = x
|
|
452
|
+
x += int(inner_font.getlength(char))
|
|
453
|
+
charlist.append(
|
|
454
|
+
{
|
|
455
|
+
"char": char,
|
|
456
|
+
"color": line_color,
|
|
457
|
+
"y": y + tmp_height,
|
|
458
|
+
"x": temp_x,
|
|
459
|
+
"end_x": x,
|
|
460
|
+
"font": inner_font,
|
|
461
|
+
"align": line_align,
|
|
462
|
+
"highlight": line_highlight,
|
|
463
|
+
}
|
|
464
|
+
)
|
|
465
|
+
max_x = max(max_x, x)
|
|
466
|
+
if i < line_seg_l:
|
|
467
|
+
x = 0
|
|
468
|
+
tmp_height += absolute_spacing + inline_height
|
|
469
|
+
inline_height = tmp_height + absolute_spacing + inline_height
|
|
470
|
+
line_height = max(line_height, inline_height)
|
|
471
|
+
if not line_nowrap:
|
|
472
|
+
x = 0
|
|
473
|
+
y += absolute_spacing + line_height
|
|
474
|
+
line_height = 0
|
|
475
|
+
|
|
476
|
+
width = width if width else int(max_x + padding_x * 2)
|
|
477
|
+
height = height if height else int(y + padding_y * 2)
|
|
478
|
+
canvas = canvas if canvas else Image.new("RGBA", (width, height), bg_color)
|
|
479
|
+
draw = ImageDraw.Draw(canvas)
|
|
480
|
+
i = 0
|
|
481
|
+
loop = len(charlist)
|
|
482
|
+
while i < loop:
|
|
483
|
+
charstyle = charlist[i]
|
|
484
|
+
if self.is_charsingle(charstyle):
|
|
485
|
+
charstyle = cast(Linecard.CharSingle, charstyle)
|
|
486
|
+
align = charstyle["align"]
|
|
487
|
+
y = charstyle["y"]
|
|
488
|
+
start_y = y + padding_y
|
|
489
|
+
if align == "left":
|
|
490
|
+
start_x = padding_x
|
|
491
|
+
elif align == "right":
|
|
492
|
+
last_charstyle_index = i
|
|
493
|
+
# 获取下次换行前最后一个的文字样式
|
|
494
|
+
for inner_charstyle in charlist[i:]:
|
|
495
|
+
if self.is_charsingle(inner_charstyle) and inner_charstyle["y"] == y and inner_charstyle["align"] == align:
|
|
496
|
+
last_charstyle_index += 1
|
|
497
|
+
else:
|
|
498
|
+
break
|
|
499
|
+
last_charstyle = cast(Linecard.CharSingle, charlist[last_charstyle_index - 1])
|
|
500
|
+
start_x = width - padding_x - last_charstyle["end_x"]
|
|
501
|
+
for inner_charstyle in cast(list[Linecard.CharSingle], charlist[i:last_charstyle_index]):
|
|
502
|
+
inner_x = inner_charstyle["x"]
|
|
503
|
+
inner_font = inner_charstyle["font"]
|
|
504
|
+
inner_highlight = inner_charstyle["highlight"]
|
|
505
|
+
if inner_highlight:
|
|
506
|
+
draw.rectangle(
|
|
507
|
+
(
|
|
508
|
+
start_x + inner_x,
|
|
509
|
+
start_y,
|
|
510
|
+
start_x + inner_charstyle["end_x"],
|
|
511
|
+
start_y + inner_font.size,
|
|
512
|
+
),
|
|
513
|
+
fill=inner_highlight,
|
|
514
|
+
)
|
|
515
|
+
draw.text(
|
|
516
|
+
(start_x + inner_x, start_y),
|
|
517
|
+
inner_charstyle["char"],
|
|
518
|
+
fill=inner_charstyle["color"],
|
|
519
|
+
font=inner_font,
|
|
520
|
+
)
|
|
521
|
+
i = last_charstyle_index
|
|
522
|
+
continue
|
|
523
|
+
elif align == "center":
|
|
524
|
+
last_charstyle_index = i
|
|
525
|
+
# 获取下次换行前最后一个的文字样式
|
|
526
|
+
for inner_charstyle in charlist[i:]:
|
|
527
|
+
if self.is_charsingle(inner_charstyle) and inner_charstyle["y"] == y and inner_charstyle["align"] == align:
|
|
528
|
+
last_charstyle_index += 1
|
|
529
|
+
else:
|
|
530
|
+
break
|
|
531
|
+
last_charstyle = cast(Linecard.CharSingle, charlist[last_charstyle_index - 1])
|
|
532
|
+
start_x = (width - last_charstyle["end_x"]) // 2
|
|
533
|
+
for inner_charstyle in cast(list[Linecard.CharSingle], charlist[i:last_charstyle_index]):
|
|
534
|
+
inner_x = inner_charstyle["x"]
|
|
535
|
+
inner_font = inner_charstyle["font"]
|
|
536
|
+
inner_highlight = inner_charstyle["highlight"]
|
|
537
|
+
if inner_highlight:
|
|
538
|
+
draw.rectangle(
|
|
539
|
+
(
|
|
540
|
+
start_x + inner_x,
|
|
541
|
+
start_y,
|
|
542
|
+
start_x + inner_charstyle["end_x"],
|
|
543
|
+
start_y + inner_font.size,
|
|
544
|
+
),
|
|
545
|
+
fill=inner_highlight,
|
|
546
|
+
)
|
|
547
|
+
draw.text(
|
|
548
|
+
(start_x + inner_x, start_y),
|
|
549
|
+
inner_charstyle["char"],
|
|
550
|
+
fill=inner_charstyle["color"],
|
|
551
|
+
font=inner_font,
|
|
552
|
+
)
|
|
553
|
+
i = last_charstyle_index
|
|
554
|
+
continue
|
|
555
|
+
else:
|
|
556
|
+
start_x = to_int(align) or 0
|
|
557
|
+
x = charstyle["x"]
|
|
558
|
+
font = charstyle["font"]
|
|
559
|
+
highlight = charstyle["highlight"]
|
|
560
|
+
if highlight:
|
|
561
|
+
draw.rectangle(
|
|
562
|
+
(
|
|
563
|
+
start_x + x,
|
|
564
|
+
start_y,
|
|
565
|
+
start_x + charstyle["end_x"],
|
|
566
|
+
start_y + font.size,
|
|
567
|
+
),
|
|
568
|
+
fill=highlight,
|
|
569
|
+
)
|
|
570
|
+
draw.text(
|
|
571
|
+
(start_x + x, start_y),
|
|
572
|
+
charstyle["char"],
|
|
573
|
+
fill=charstyle["color"],
|
|
574
|
+
font=font,
|
|
575
|
+
)
|
|
576
|
+
i += 1
|
|
577
|
+
else:
|
|
578
|
+
charstyle = cast(Linecard.CharLine, charstyle)
|
|
579
|
+
inner_y = charstyle["y"] + padding_y + (charstyle["size"] + 0.5) // 2 + 4
|
|
580
|
+
draw.line(((0, inner_y), (width, inner_y)), fill=charstyle["color"], width=4)
|
|
581
|
+
i += 1
|
|
582
|
+
return canvas
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
def parse_str(text: str) -> tuple[str, list[tuple[bytes, str]]]: ...
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "linecard"
|
|
3
|
+
version = "0.2.0r1"
|
|
4
|
+
description = ""
|
|
5
|
+
authors = [{ name = "KarisAya", email = "karisaya@foxmail.com" }]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
dependencies = ["pillow (>=11.1.0,<12.0.0)", "fonttools (>=4.58.0,<5.0.0)"]
|
|
9
|
+
# [build-system]
|
|
10
|
+
# requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
11
|
+
# build-backend = "poetry.core.masonry.api"
|
|
12
|
+
|
|
13
|
+
[build-system]
|
|
14
|
+
requires = ["setuptools"]
|
|
15
|
+
build-backend = "setuptools.build_meta"
|
|
16
|
+
|
|
17
|
+
[tool.poetry.group.build.dependencies]
|
|
18
|
+
setuptools = "^75.8.0"
|
|
19
|
+
cibuildwheel = "^2.22.0"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
[tool.setuptools]
|
|
23
|
+
packages = ["linecard"]
|
|
24
|
+
|
|
25
|
+
# python setup.py build_ext --inplace
|
|
26
|
+
# cibuildwheel --platform linux --output-dir ./dist
|
|
27
|
+
# cibuildwheel --platform windows --output-dir ./dist
|