bcmd 0.5.15__py3-none-any.whl → 0.5.16__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.
Potentially problematic release.
This version of bcmd might be problematic. Click here for more details.
- bcmd/tasks/image.py +180 -88
- {bcmd-0.5.15.dist-info → bcmd-0.5.16.dist-info}/METADATA +1 -1
- {bcmd-0.5.15.dist-info → bcmd-0.5.16.dist-info}/RECORD +6 -6
- {bcmd-0.5.15.dist-info → bcmd-0.5.16.dist-info}/WHEEL +1 -1
- {bcmd-0.5.15.dist-info → bcmd-0.5.16.dist-info}/entry_points.txt +0 -0
- {bcmd-0.5.15.dist-info → bcmd-0.5.16.dist-info}/top_level.txt +0 -0
bcmd/tasks/image.py
CHANGED
|
@@ -3,15 +3,15 @@ import os
|
|
|
3
3
|
import random
|
|
4
4
|
from enum import StrEnum
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
from typing import Final
|
|
6
|
+
from typing import Final, List, Tuple
|
|
7
7
|
|
|
8
8
|
import httpx
|
|
9
9
|
import typer
|
|
10
|
-
from beni import bcolor, bfile, bhttp, block, bpath, btask
|
|
10
|
+
from beni import bcolor, bfile, bhttp, binput, block, bpath, btask
|
|
11
11
|
from beni.bbyte import BytesReader, BytesWriter
|
|
12
12
|
from beni.bfunc import syncCall
|
|
13
13
|
from beni.btype import Null, XPath
|
|
14
|
-
from PIL import Image
|
|
14
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
15
15
|
|
|
16
16
|
app: Final = btask.newSubApp('图片工具集')
|
|
17
17
|
|
|
@@ -76,6 +76,92 @@ async def tiny(
|
|
|
76
76
|
]
|
|
77
77
|
random.shuffle(keyList)
|
|
78
78
|
|
|
79
|
+
class _TinyFile:
|
|
80
|
+
|
|
81
|
+
_endian: Final = '>'
|
|
82
|
+
_sep = BytesWriter(_endian).writeStr('tiny').writeUint(9527).writeUint(709394).toBytes()
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def compression(self):
|
|
86
|
+
return self._compression
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def isTiny(self):
|
|
90
|
+
return self._isTiny
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def file(self):
|
|
94
|
+
return self._file
|
|
95
|
+
|
|
96
|
+
def __init__(self, file: XPath):
|
|
97
|
+
self._file = file
|
|
98
|
+
self._compression: float = 0.0
|
|
99
|
+
self._isTiny: bool = False
|
|
100
|
+
|
|
101
|
+
def getSizeDisplay(self):
|
|
102
|
+
size = bpath.get(self._file).stat().st_size / 1024
|
|
103
|
+
return f'{size:,.2f}KB'
|
|
104
|
+
|
|
105
|
+
async def updateInfo(self):
|
|
106
|
+
fileBytes = await bfile.readBytes(self._file)
|
|
107
|
+
self._compression = 0.0
|
|
108
|
+
self._isTiny = False
|
|
109
|
+
blockAry = fileBytes.split(self._sep)
|
|
110
|
+
if len(blockAry) > 1:
|
|
111
|
+
info = BytesReader(self._endian, blockAry[1])
|
|
112
|
+
size = info.readUint()
|
|
113
|
+
if size == len(blockAry[0]):
|
|
114
|
+
self._compression = round(info.readFloat(), 2)
|
|
115
|
+
self._isTiny = info.readBool()
|
|
116
|
+
|
|
117
|
+
async def _flushInfo(self, compression: float, isTiny: bool):
|
|
118
|
+
self._compression = compression
|
|
119
|
+
self._isTiny = isTiny
|
|
120
|
+
content = await self._getPureContent()
|
|
121
|
+
info = (
|
|
122
|
+
BytesWriter(self._endian)
|
|
123
|
+
.writeUint(len(content))
|
|
124
|
+
.writeFloat(compression)
|
|
125
|
+
.writeBool(isTiny)
|
|
126
|
+
.toBytes()
|
|
127
|
+
)
|
|
128
|
+
content += self._sep + info
|
|
129
|
+
await bfile.writeBytes(self._file, content)
|
|
130
|
+
|
|
131
|
+
async def _getPureContent(self):
|
|
132
|
+
content = await bfile.readBytes(self._file)
|
|
133
|
+
content = content.split(self._sep)[0]
|
|
134
|
+
return content
|
|
135
|
+
|
|
136
|
+
@block.limit(1)
|
|
137
|
+
async def runTiny(self, key: str, compression: float, isKeepOriginal: bool):
|
|
138
|
+
content = await self._getPureContent()
|
|
139
|
+
async with httpx.AsyncClient() as client:
|
|
140
|
+
response = await client.post(
|
|
141
|
+
'https://api.tinify.com/shrink',
|
|
142
|
+
auth=('api', key),
|
|
143
|
+
content=content,
|
|
144
|
+
timeout=30,
|
|
145
|
+
)
|
|
146
|
+
response.raise_for_status()
|
|
147
|
+
result = response.json()
|
|
148
|
+
outputCompression = round(result['output']['ratio'] * 100, 2)
|
|
149
|
+
if outputCompression < compression:
|
|
150
|
+
# 下载文件
|
|
151
|
+
url = result['output']['url']
|
|
152
|
+
with bpath.useTempFile() as tempFile:
|
|
153
|
+
await bhttp.download(url, tempFile)
|
|
154
|
+
await _TinyFile(tempFile)._flushInfo(outputCompression, True)
|
|
155
|
+
outputFile = bpath.get(self._file)
|
|
156
|
+
if isKeepOriginal:
|
|
157
|
+
outputFile = outputFile.with_stem(f'{outputFile.stem}_tiny')
|
|
158
|
+
bpath.move(tempFile, outputFile, True)
|
|
159
|
+
bcolor.printGreen(f'{outputFile}({outputCompression - 100:.2f}% / 压缩 / {self.getSizeDisplay()})')
|
|
160
|
+
else:
|
|
161
|
+
# 不进行压缩
|
|
162
|
+
await self._flushInfo(outputCompression, False)
|
|
163
|
+
bcolor.printMagenta(f'{self._file} ({outputCompression - 100:.2f}% / 不处理 / {self.getSizeDisplay()})')
|
|
164
|
+
|
|
79
165
|
btask.assertTrue(0 < optimization < 100, '优化大小必须在0-100之间')
|
|
80
166
|
compression = 100 - optimization
|
|
81
167
|
await block.setLimit(_TinyFile.runTiny, len(keyList))
|
|
@@ -122,91 +208,97 @@ async def tiny(
|
|
|
122
208
|
await asyncio.gather(*taskList)
|
|
123
209
|
|
|
124
210
|
|
|
125
|
-
|
|
211
|
+
@app.command()
|
|
212
|
+
@syncCall
|
|
213
|
+
async def merge(
|
|
214
|
+
path: Path = typer.Argument(Path.cwd(), help='workspace 路径'),
|
|
215
|
+
force: bool = typer.Option(False, '--force', '-f', help='强制覆盖'),
|
|
216
|
+
):
|
|
217
|
+
'合并多张图片'
|
|
218
|
+
|
|
219
|
+
def _get_watermark_font(font_size: int) -> ImageFont.FreeTypeFont:
|
|
220
|
+
font_candidates = [
|
|
221
|
+
'arial.ttf', # Windows
|
|
222
|
+
'Arial.ttf', # macOS(部分系统可能区分大小写)
|
|
223
|
+
'Helvetica.ttc', # macOS
|
|
224
|
+
'DejaVuSans.ttf', # Linux
|
|
225
|
+
'FreeSans.ttf', # 某些Linux发行版
|
|
226
|
+
'LiberationSans-Regular.ttf' # RHEL系发行版
|
|
227
|
+
]
|
|
228
|
+
for font_name in font_candidates:
|
|
229
|
+
try:
|
|
230
|
+
return ImageFont.truetype(font_name, font_size)
|
|
231
|
+
except (IOError, OSError):
|
|
232
|
+
continue
|
|
233
|
+
raise Exception('No font found!')
|
|
234
|
+
|
|
235
|
+
def _add_watermark(image: Image.Image, text: str, position: Tuple[float, float]) -> Image.Image:
|
|
236
|
+
"""添加圆形背景水印"""
|
|
237
|
+
draw = ImageDraw.Draw(image)
|
|
238
|
+
font_size = 100
|
|
239
|
+
font = _get_watermark_font(font_size)
|
|
126
240
|
|
|
241
|
+
# 计算文本尺寸并确定圆形参数
|
|
242
|
+
text_bbox = draw.textbbox(position, text, font=font)
|
|
243
|
+
text_width = text_bbox[2] - text_bbox[0]
|
|
244
|
+
text_height = text_bbox[3] - text_bbox[1]
|
|
127
245
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
@property
|
|
138
|
-
def isTiny(self):
|
|
139
|
-
return self._isTiny
|
|
140
|
-
|
|
141
|
-
@property
|
|
142
|
-
def file(self):
|
|
143
|
-
return self._file
|
|
144
|
-
|
|
145
|
-
def __init__(self, file: XPath):
|
|
146
|
-
self._file = file
|
|
147
|
-
self._compression: float = 0.0
|
|
148
|
-
self._isTiny: bool = False
|
|
149
|
-
|
|
150
|
-
def getSizeDisplay(self):
|
|
151
|
-
size = bpath.get(self._file).stat().st_size / 1024
|
|
152
|
-
return f'{size:,.2f}KB'
|
|
153
|
-
|
|
154
|
-
async def updateInfo(self):
|
|
155
|
-
fileBytes = await bfile.readBytes(self._file)
|
|
156
|
-
self._compression = 0.0
|
|
157
|
-
self._isTiny = False
|
|
158
|
-
blockAry = fileBytes.split(self._sep)
|
|
159
|
-
if len(blockAry) > 1:
|
|
160
|
-
info = BytesReader(self._endian, blockAry[1])
|
|
161
|
-
size = info.readUint()
|
|
162
|
-
if size == len(blockAry[0]):
|
|
163
|
-
self._compression = round(info.readFloat(), 2)
|
|
164
|
-
self._isTiny = info.readBool()
|
|
165
|
-
|
|
166
|
-
async def _flushInfo(self, compression: float, isTiny: bool):
|
|
167
|
-
self._compression = compression
|
|
168
|
-
self._isTiny = isTiny
|
|
169
|
-
content = await self._getPureContent()
|
|
170
|
-
info = (
|
|
171
|
-
BytesWriter(self._endian)
|
|
172
|
-
.writeUint(len(content))
|
|
173
|
-
.writeFloat(compression)
|
|
174
|
-
.writeBool(isTiny)
|
|
175
|
-
.toBytes()
|
|
246
|
+
# 计算圆形参数(直径取文本宽高的最大值 + 边距)
|
|
247
|
+
diameter = max(text_width, text_height) + 20 # 10像素边距
|
|
248
|
+
radius = diameter // 2
|
|
249
|
+
|
|
250
|
+
# 计算圆形中心坐标(保持与原矩形左上角一致)
|
|
251
|
+
circle_center = (
|
|
252
|
+
position[0] + text_width // 2 + 5, # 原位置向右偏移边距
|
|
253
|
+
position[1] + text_height // 2 + 5 # 原位置向下偏移边距
|
|
176
254
|
)
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
255
|
+
|
|
256
|
+
# 绘制圆形背景
|
|
257
|
+
ellipse_box = (
|
|
258
|
+
circle_center[0] - radius,
|
|
259
|
+
circle_center[1] - radius,
|
|
260
|
+
circle_center[0] + radius,
|
|
261
|
+
circle_center[1] + radius
|
|
262
|
+
)
|
|
263
|
+
draw.ellipse(ellipse_box, fill='#B920D9')
|
|
264
|
+
|
|
265
|
+
# 绘制居中文字
|
|
266
|
+
draw.text(
|
|
267
|
+
circle_center,
|
|
268
|
+
text,
|
|
269
|
+
(255, 255, 255),
|
|
270
|
+
font=font,
|
|
271
|
+
anchor="mm" # 设置锚点为水平垂直居中
|
|
272
|
+
)
|
|
273
|
+
return image
|
|
274
|
+
|
|
275
|
+
def _merge_images(image_paths: List[Path], output_path: Path) -> None:
|
|
276
|
+
images = [Image.open(img_path) for img_path in image_paths]
|
|
277
|
+
max_width = max(img.width for img in images)
|
|
278
|
+
total_height = sum(img.height for img in images)
|
|
279
|
+
merged_image = Image.new('RGB', (max_width, total_height))
|
|
280
|
+
y_offset = 0
|
|
281
|
+
for idx, img in enumerate(images):
|
|
282
|
+
merged_image.paste(img, (0, y_offset))
|
|
283
|
+
_add_watermark(merged_image, f'{idx + 1}', (22, y_offset + 17))
|
|
284
|
+
y_offset += img.height
|
|
285
|
+
# 修改保存参数为 WebP 格式
|
|
286
|
+
merged_image.save(
|
|
287
|
+
output_path,
|
|
288
|
+
format='WEBP',
|
|
289
|
+
quality=80, # 质量参数(0-100),推荐 80-90 之间
|
|
290
|
+
method=6, # 压缩方法(0-6),6 为最佳压缩
|
|
291
|
+
lossless=False, # 不使用无损压缩(更小的文件体积)
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
image_files = [x for x in bpath.listFile(path) if x.suffix in ('.png', '.jpg', '.jpeg', '.webp', '.bmp')]
|
|
295
|
+
output_image = path / f'merge_{path.name}.webp' # 修改文件扩展名为 webp
|
|
296
|
+
if output_image in image_files:
|
|
297
|
+
if not force:
|
|
298
|
+
print(output_image)
|
|
299
|
+
await binput.confirm(f'生成文件已经存在,是否覆盖?')
|
|
300
|
+
image_files.remove(output_image)
|
|
301
|
+
image_files.sort(key=lambda x: x.as_posix())
|
|
302
|
+
_merge_images(image_files, output_image)
|
|
303
|
+
bcolor.printMagenta(output_image)
|
|
304
|
+
bcolor.printGreen('OK')
|
|
@@ -14,7 +14,7 @@ bcmd/tasks/code.py,sha256=IUs_ClZuSsBk2gavlitC8mkRrQQX9rvNDgR8cFxduBA,3992
|
|
|
14
14
|
bcmd/tasks/crypto.py,sha256=LKvgsMPLvsi1wlt66TinYiN-oV2IPAfaN9y7hWaVpHs,2951
|
|
15
15
|
bcmd/tasks/debian.py,sha256=B9aMIIct3vNqMJr5hTr1GegXVf20H49C27FMvRRGIzI,3004
|
|
16
16
|
bcmd/tasks/download.py,sha256=XdZYKi8zQTNYWEgUxeTNDqPgP7IGYJkMmlDDC9u93Vk,2315
|
|
17
|
-
bcmd/tasks/image.py,sha256
|
|
17
|
+
bcmd/tasks/image.py,sha256=-9cV8k645MejVRBsC9lmrQBOPcq_9uy3Me5IJCcNOEQ,12162
|
|
18
18
|
bcmd/tasks/json.py,sha256=WWOyvcZPYaqQgp-Tkm-uIJschNMBKPKtZN3yXz_SC5s,635
|
|
19
19
|
bcmd/tasks/lib.py,sha256=lq-PdHxhK-5Akf7k4QaIT8mBB-sCK5or5OqEFTdphIA,4567
|
|
20
20
|
bcmd/tasks/math.py,sha256=xbl5UdaDMyAjiLodDPleP4Cutrk2S3NOAgurzAgOEAE,2862
|
|
@@ -26,8 +26,8 @@ bcmd/tasks/time.py,sha256=ZiqA1jdgl-TBtFSOxxP51nwv4g9iZItmkFKpf9MKelk,2453
|
|
|
26
26
|
bcmd/tasks/upgrade.py,sha256=ZiyecgVbnnoTU_LAsd78CIKA4ioc9so9pXpAM76b_0M,447
|
|
27
27
|
bcmd/tasks/venv.py,sha256=a7ZDyagUPQvCAXx3cZJIqJt0p1Iy5u5qmmj8iRbDQZE,7673
|
|
28
28
|
bcmd/tasks/wasabi.py,sha256=xWFAxprSIlBqDDMGaNXZFb-SahnW1d_R9XxSKRYIhnM,3110
|
|
29
|
-
bcmd-0.5.
|
|
30
|
-
bcmd-0.5.
|
|
31
|
-
bcmd-0.5.
|
|
32
|
-
bcmd-0.5.
|
|
33
|
-
bcmd-0.5.
|
|
29
|
+
bcmd-0.5.16.dist-info/METADATA,sha256=FzL072W4dRsFfcC4HsqNJDz92pazZpDtIdeXGJK5MPM,500
|
|
30
|
+
bcmd-0.5.16.dist-info/WHEEL,sha256=beeZ86-EfXScwlR_HKu4SllMC9wUEj_8Z_4FJ3egI2w,91
|
|
31
|
+
bcmd-0.5.16.dist-info/entry_points.txt,sha256=rHJrP6KEQpB-YaQqDFzEL2v88r03rxSfnzAayRvAqHU,39
|
|
32
|
+
bcmd-0.5.16.dist-info/top_level.txt,sha256=-KrvhhtBcYsm4XhcjQvEcFbBB3VXeep7d3NIfDTrXKQ,5
|
|
33
|
+
bcmd-0.5.16.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|