bcmd 0.5.15__py3-none-any.whl → 0.5.17__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 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
- class _TinyFile:
129
-
130
- _endian: Final = '>'
131
- _sep = BytesWriter(_endian).writeStr('tiny').writeUint(9527).writeUint(709394).toBytes()
132
-
133
- @property
134
- def compression(self):
135
- return self._compression
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
- content += self._sep + info
178
- await bfile.writeBytes(self._file, content)
179
-
180
- async def _getPureContent(self):
181
- content = await bfile.readBytes(self._file)
182
- content = content.split(self._sep)[0]
183
- return content
184
-
185
- @block.limit(1)
186
- async def runTiny(self, key: str, compression: float, isKeepOriginal: bool):
187
- content = await self._getPureContent()
188
- async with httpx.AsyncClient() as client:
189
- response = await client.post(
190
- 'https://api.tinify.com/shrink',
191
- auth=('api', key),
192
- content=content,
193
- timeout=30,
194
- )
195
- response.raise_for_status()
196
- result = response.json()
197
- outputCompression = round(result['output']['ratio'] * 100, 2)
198
- if outputCompression < compression:
199
- # 下载文件
200
- url = result['output']['url']
201
- with bpath.useTempFile() as tempFile:
202
- await bhttp.download(url, tempFile)
203
- await _TinyFile(tempFile)._flushInfo(outputCompression, True)
204
- outputFile = bpath.get(self._file)
205
- if isKeepOriginal:
206
- outputFile = outputFile.with_stem(f'{outputFile.stem}_tiny')
207
- bpath.move(tempFile, outputFile, True)
208
- bcolor.printGreen(f'{outputFile}({outputCompression - 100:.2f}% / 压缩 / {self.getSizeDisplay()})')
209
- else:
210
- # 不进行压缩
211
- await self._flushInfo(outputCompression, False)
212
- bcolor.printMagenta(f'{self._file} ({outputCompression - 100:.2f}% / 不处理 / {self.getSizeDisplay()})')
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')
bcmd/tasks/venv.py CHANGED
@@ -51,14 +51,14 @@ async def install_benimang(
51
51
  async def install_base(
52
52
  path: Path = typer.Option(None, '--path', help='指定路径,默认当前目录'),
53
53
  isOfficial: bool = typer.Option(False, '--official', help='是否使用官方地址安装(https://pypi.org/simple)'),
54
- isReinstall: bool = typer.Option(False, '--reinstall', help='是否清空venv目录后重新安装'),
54
+ isCleanup: bool = typer.Option(False, '--cleanup', help='是否清空venv目录后重新安装'),
55
55
  ):
56
56
  '安装基础库'
57
57
  await _venv(
58
58
  path=path,
59
59
  isOfficial=isOfficial,
60
60
  isUseBase=True,
61
- isCleanup=isReinstall,
61
+ isCleanup=isCleanup,
62
62
  )
63
63
 
64
64
 
@@ -67,14 +67,14 @@ async def install_base(
67
67
  async def install_lock(
68
68
  path: Path = typer.Option(None, '--path', help='指定路径,默认当前目录'),
69
69
  isOfficial: bool = typer.Option(False, '--official', help='是否使用官方地址安装(https://pypi.org/simple)'),
70
- isReinstall: bool = typer.Option(False, '--reinstall', help='是否清空venv目录后重新安装'),
70
+ isCleanup: bool = typer.Option(False, '--cleanup', help='是否清空venv目录后重新安装'),
71
71
  ):
72
72
  '安装指定版本的库'
73
73
  await _venv(
74
74
  path=path,
75
75
  isOfficial=isOfficial,
76
76
  isUseLock=True,
77
- isCleanup=isReinstall,
77
+ isCleanup=isCleanup,
78
78
  )
79
79
 
80
80
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: bcmd
3
- Version: 0.5.15
3
+ Version: 0.5.17
4
4
  Summary: Commands for Beni
5
5
  Author-email: Beni Mang <benimang@126.com>
6
6
  Maintainer-email: Beni Mang <benimang@126.com>
@@ -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=fxCiP9Xs4mxj6PhnZY2nRc0pvYg0DAbEIOFHhj-BkXA,8088
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
@@ -24,10 +24,10 @@ bcmd/tasks/project.py,sha256=yLYL6WNmq0mlY-hQ-SGKZ6uRjwceJxpgbP-QAo0Wk-Y,1948
24
24
  bcmd/tasks/proxy.py,sha256=xvxN5PClUnc5LQpmq2Wug7_LUVpJboMWLXBvL9lX7EM,1552
25
25
  bcmd/tasks/time.py,sha256=ZiqA1jdgl-TBtFSOxxP51nwv4g9iZItmkFKpf9MKelk,2453
26
26
  bcmd/tasks/upgrade.py,sha256=ZiyecgVbnnoTU_LAsd78CIKA4ioc9so9pXpAM76b_0M,447
27
- bcmd/tasks/venv.py,sha256=a7ZDyagUPQvCAXx3cZJIqJt0p1Iy5u5qmmj8iRbDQZE,7673
27
+ bcmd/tasks/venv.py,sha256=O7E-6FYoMsB4p1UM9m6_bljI-oBmlKg4AJWtsCln-8k,7661
28
28
  bcmd/tasks/wasabi.py,sha256=xWFAxprSIlBqDDMGaNXZFb-SahnW1d_R9XxSKRYIhnM,3110
29
- bcmd-0.5.15.dist-info/METADATA,sha256=4jTS3Z4qWZdQQGfDWSpTfpHWabDwQBJQV56s1xHDssk,500
30
- bcmd-0.5.15.dist-info/WHEEL,sha256=52BFRY2Up02UkjOa29eZOS2VxUrpPORXg1pkohGGUS8,91
31
- bcmd-0.5.15.dist-info/entry_points.txt,sha256=rHJrP6KEQpB-YaQqDFzEL2v88r03rxSfnzAayRvAqHU,39
32
- bcmd-0.5.15.dist-info/top_level.txt,sha256=-KrvhhtBcYsm4XhcjQvEcFbBB3VXeep7d3NIfDTrXKQ,5
33
- bcmd-0.5.15.dist-info/RECORD,,
29
+ bcmd-0.5.17.dist-info/METADATA,sha256=4Z6DuWwyZOa1AOj-dSa3JL0c153qk8B5QfcEYRgE4xY,500
30
+ bcmd-0.5.17.dist-info/WHEEL,sha256=DK49LOLCYiurdXXOXwGJm6U4DkHkg4lcxjhqwRa0CP4,91
31
+ bcmd-0.5.17.dist-info/entry_points.txt,sha256=rHJrP6KEQpB-YaQqDFzEL2v88r03rxSfnzAayRvAqHU,39
32
+ bcmd-0.5.17.dist-info/top_level.txt,sha256=-KrvhhtBcYsm4XhcjQvEcFbBB3VXeep7d3NIfDTrXKQ,5
33
+ bcmd-0.5.17.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (76.0.0)
2
+ Generator: setuptools (78.0.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5