bcmd 0.5.14__tar.gz → 0.5.16__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.

Potentially problematic release.


This version of bcmd might be problematic. Click here for more details.

Files changed (40) hide show
  1. {bcmd-0.5.14 → bcmd-0.5.16}/PKG-INFO +1 -1
  2. bcmd-0.5.16/bcmd/tasks/image.py +304 -0
  3. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd/tasks/wasabi.py +33 -24
  4. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd.egg-info/PKG-INFO +1 -1
  5. {bcmd-0.5.14 → bcmd-0.5.16}/pyproject.toml +1 -1
  6. bcmd-0.5.14/bcmd/tasks/image.py +0 -212
  7. {bcmd-0.5.14 → bcmd-0.5.16}/MANIFEST.in +0 -0
  8. {bcmd-0.5.14 → bcmd-0.5.16}/README.md +0 -0
  9. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd/__init__.py +0 -0
  10. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd/common/__init__.py +0 -0
  11. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd/common/func.py +0 -0
  12. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd/common/password.py +0 -0
  13. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd/main.py +0 -0
  14. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd/resources/project/.gitignore +0 -0
  15. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd/resources/project/.vscode/launch.json +0 -0
  16. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd/resources/project/.vscode/settings.json +0 -0
  17. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd/resources/project/.vscode/tasks.json +0 -0
  18. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd/resources/project/main.py +0 -0
  19. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd/tasks/__init__.py +0 -0
  20. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd/tasks/bin.py +0 -0
  21. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd/tasks/code.py +0 -0
  22. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd/tasks/crypto.py +0 -0
  23. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd/tasks/debian.py +0 -0
  24. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd/tasks/download.py +0 -0
  25. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd/tasks/json.py +0 -0
  26. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd/tasks/lib.py +0 -0
  27. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd/tasks/math.py +0 -0
  28. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd/tasks/mirror.py +0 -0
  29. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd/tasks/pdf.py +0 -0
  30. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd/tasks/project.py +0 -0
  31. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd/tasks/proxy.py +0 -0
  32. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd/tasks/time.py +0 -0
  33. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd/tasks/upgrade.py +0 -0
  34. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd/tasks/venv.py +0 -0
  35. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd.egg-info/SOURCES.txt +0 -0
  36. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd.egg-info/dependency_links.txt +0 -0
  37. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd.egg-info/entry_points.txt +0 -0
  38. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd.egg-info/requires.txt +0 -0
  39. {bcmd-0.5.14 → bcmd-0.5.16}/bcmd.egg-info/top_level.txt +0 -0
  40. {bcmd-0.5.14 → bcmd-0.5.16}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: bcmd
3
- Version: 0.5.14
3
+ Version: 0.5.16
4
4
  Summary: Commands for Beni
5
5
  Author-email: Beni Mang <benimang@126.com>
6
6
  Maintainer-email: Beni Mang <benimang@126.com>
@@ -0,0 +1,304 @@
1
+ import asyncio
2
+ import os
3
+ import random
4
+ from enum import StrEnum
5
+ from pathlib import Path
6
+ from typing import Final, List, Tuple
7
+
8
+ import httpx
9
+ import typer
10
+ from beni import bcolor, bfile, bhttp, binput, block, bpath, btask
11
+ from beni.bbyte import BytesReader, BytesWriter
12
+ from beni.bfunc import syncCall
13
+ from beni.btype import Null, XPath
14
+ from PIL import Image, ImageDraw, ImageFont
15
+
16
+ app: Final = btask.newSubApp('图片工具集')
17
+
18
+
19
+ class _OutputType(StrEnum):
20
+ '输出类型'
21
+ normal = '0'
22
+ replace_ = '1'
23
+ crc_replace = '2'
24
+
25
+
26
+ @app.command()
27
+ @syncCall
28
+ async def convert(
29
+ path: Path = typer.Option(None, '--path', '-p', help='指定目录或具体图片文件,默认当前目录'),
30
+ src_format: str = typer.Option('jpg|jpeg|png', '--src-format', '-s', help='如果path是目录,指定源格式,可以指定多个,默认值:jpg|jpeg|png'),
31
+ dst_format: str = typer.Option('webp', '--dst-format', '-d', help='目标格式,只能是单个'),
32
+ rgb: bool = typer.Option(False, '--rgb', help='转换为RGB格式'),
33
+ quality: int = typer.Option(85, '--quality', '-q', help='图片质量,0-100,默认85'),
34
+ output_type: _OutputType = typer.Option(_OutputType.normal, '--output-type', help='输出类型,0:普通输出,1:删除源文件,2:输出文件使用CRC32命名并删除源文件'),
35
+ ):
36
+ '图片格式转换'
37
+ path = path or Path(os.getcwd())
38
+ fileList: list[Path] = []
39
+ if path.is_file():
40
+ fileList.append(path)
41
+ elif path.is_dir():
42
+ extNameList = [x for x in src_format.strip().split('|')]
43
+ fileList = [x for x in bpath.listFile(path, True) if x.suffix[1:].lower() in extNameList]
44
+ if not fileList:
45
+ return bcolor.printRed(f'未找到图片文件({path})')
46
+ for file in fileList:
47
+ with Image.open(file) as img:
48
+ if rgb:
49
+ img = img.convert('RGB')
50
+ with bpath.useTempFile() as tempFile:
51
+ img.save(tempFile, format=dst_format, quality=quality)
52
+ outputFile = file.with_suffix(f'.{dst_format}')
53
+ if output_type == _OutputType.crc_replace:
54
+ outputFile = outputFile.with_stem(await bfile.crc(tempFile))
55
+ bpath.copy(tempFile, outputFile)
56
+ if output_type in [_OutputType.replace_, _OutputType.crc_replace]:
57
+ if outputFile != file:
58
+ bpath.remove(file)
59
+ bcolor.printGreen(f'{file} -> {outputFile}')
60
+
61
+
62
+ # ------------------------------------------------------------------------------------
63
+
64
+ @app.command()
65
+ @syncCall
66
+ async def tiny(
67
+ path: Path = typer.Option(None, '--path', help='指定目录或具体图片文件,默认当前目录'),
68
+ optimization: int = typer.Option(25, '--optimization', help='指定优化大小,如果没有达到优化效果就不处理,单位:%,默认25'),
69
+ isKeepOriginal: bool = typer.Option(False, '--keep-original', help='保留原始图片'),
70
+ ):
71
+
72
+ keyList = [
73
+ 'MB3QmtvZ8HKRkXcDnxhWCNTXzvx6cNF3',
74
+ '7L7X2CJ35GM1bChSHdT14yZPLx7FlpNk',
75
+ 'q8YLcvrXVW2NYcr5mMyzQhsSHF4j7gny',
76
+ ]
77
+ random.shuffle(keyList)
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
+
165
+ btask.assertTrue(0 < optimization < 100, '优化大小必须在0-100之间')
166
+ compression = 100 - optimization
167
+ await block.setLimit(_TinyFile.runTiny, len(keyList))
168
+
169
+ # 整理文件列表
170
+ fileList = []
171
+ path = path or Path(os.getcwd())
172
+ if path.is_file():
173
+ fileList.append(path)
174
+ elif path.is_dir():
175
+ fileList = [x for x in bpath.listFile(path, True) if x.suffix[1:].lower() in ['jpg', 'jpeg', 'png']]
176
+ else:
177
+ btask.abort('未找到图片文件', path)
178
+ fileList.sort()
179
+
180
+ # 将文件列表整理成 _TinyFile 对象
181
+ fileList = [_TinyFile(x) for x in fileList]
182
+ await asyncio.gather(*[x.updateInfo() for x in fileList])
183
+
184
+ # 过滤掉已经处理过的图片
185
+ for i in range(len(fileList)):
186
+ file = fileList[i]
187
+ if file.compression == 0:
188
+ # 未处理过的图片,进行图片的压缩处理
189
+ pass
190
+ elif not file.isTiny and file.compression < compression:
191
+ # 之前测试的压缩率不符合要求,不过现在符合了,进行图片的压缩处理
192
+ pass
193
+ else:
194
+ # 要忽略掉的文件
195
+ if file.isTiny:
196
+ bcolor.printYellow(f'{file.file}({file.compression - 100:.2f}% / 已压缩 / {file.getSizeDisplay()})')
197
+ else:
198
+ bcolor.printMagenta(f'{file.file}({file.compression - 100:.2f}% / 不处理 / {file.getSizeDisplay()})')
199
+
200
+ fileList[i] = Null
201
+ fileList = [x for x in fileList if x]
202
+
203
+ # 开始压缩
204
+ taskList = [
205
+ x.runTiny(keyList[i % len(keyList)], compression, isKeepOriginal)
206
+ for i, x in enumerate(fileList)
207
+ ]
208
+ await asyncio.gather(*taskList)
209
+
210
+
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)
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]
245
+
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 # 原位置向下偏移边距
254
+ )
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')
@@ -1,4 +1,5 @@
1
1
  import getpass
2
+ import stat
2
3
  from pathlib import Path
3
4
  from typing import Final
4
5
 
@@ -16,16 +17,14 @@ MAX_ENCRYPT_SIZE = 199 * 1024
16
17
  @app.command()
17
18
  @syncCall
18
19
  async def unzip(
19
- file: Path = typer.Argument(Path.cwd(), help='加密文件'),
20
+ target: Path = typer.Argument(Path.cwd(), help='加密文件'),
20
21
  password: str = typer.Option('', '--password', '-p', help='密码'),
21
22
  ):
22
23
  '解压缩加密文件成目录'
23
- assert file.is_file(), f'不是文件 {file}'
24
- workspace = file.with_suffix('')
25
- assert not workspace.exists(), f'目录已存在 {workspace}'
24
+ assert target.is_file(), f'不是文件 {target}'
26
25
  password = password or getpass.getpass('请输入密码: ')
27
26
  with bpath.useTempFile() as tempFile:
28
- data = await bfile.readBytes(file)
27
+ data = await bfile.readBytes(target)
29
28
  if SEP not in data:
30
29
  data = bcrypto.decrypt(data, password)
31
30
  else:
@@ -34,28 +33,31 @@ async def unzip(
34
33
  data = partA + partB
35
34
  data = shuffleSequence(data)
36
35
  await bfile.writeBytes(tempFile, data)
37
- await bzip.sevenUnzip(tempFile, workspace)
38
- await bpath.removeSecure(file)
39
- bcolor.printGreen(workspace)
36
+ tempPath = target.with_suffix('.tmp')
37
+ await bzip.sevenUnzip(tempFile, tempPath)
38
+
39
+ # 调整文件权限,完全擦除
40
+ target.chmod(stat.S_IWRITE)
41
+ await bpath.removeSecure(target)
42
+
43
+ bpath.move(tempPath, target)
40
44
  bcolor.printGreen('OK')
41
45
 
42
46
 
43
47
  @app.command()
44
48
  @syncCall
45
49
  async def zip(
46
- workspace: Path = typer.Argument(Path.cwd(), help='输出目录'),
50
+ target: Path = typer.Argument(Path.cwd(), help='输出目录'),
47
51
  password: str = typer.Option('', '--password', '-p', help='密码'),
48
52
  ):
49
53
  '将目录压缩成加密文件'
50
- workspace = workspace.absolute()
51
- assert workspace.is_dir(), f'不是目录 {workspace}'
52
- zipFile = workspace.with_suffix('.dat')
53
- assert not zipFile.exists(), f'文件已存在 {zipFile}'
54
+ target = target.absolute()
55
+ assert target.is_dir(), f'不是目录 {target}'
54
56
  password = password or genPassword()
55
57
  with bpath.useTempFile() as tempFile:
56
- await bzip.sevenZipFolder(tempFile, workspace)
58
+ await bzip.sevenZipFolder(tempFile, target)
57
59
  data = await bfile.readBytes(tempFile)
58
- bpath.remove(tempFile)
60
+ bpath.remove(tempFile) # 为了安全所以立即删除
59
61
  data = shuffleSequence(data)
60
62
  if len(data) < MAX_ENCRYPT_SIZE:
61
63
  data = bcrypto.encrypt(data, password)
@@ -63,9 +65,17 @@ async def zip(
63
65
  partA, partB = data[:MAX_ENCRYPT_SIZE], data[MAX_ENCRYPT_SIZE:]
64
66
  partA = bcrypto.encrypt(partA, password)
65
67
  data = partA + SEP + partB
66
- await bfile.writeBytes(zipFile, data)
67
- await bpath.removeSecure(workspace)
68
- bcolor.printGreen(zipFile)
68
+ tempZipFile = target.with_suffix('.tmp')
69
+ await bfile.writeBytes(tempZipFile, data)
70
+
71
+ # 调整目录权限,完全擦除
72
+ target.chmod(stat.S_IWRITE)
73
+ for file in target.glob('**/*'):
74
+ file.chmod(stat.S_IWRITE)
75
+ await bpath.removeSecure(target)
76
+
77
+ bpath.move(tempZipFile, target)
78
+
69
79
  bcolor.printGreen('OK')
70
80
 
71
81
 
@@ -77,9 +87,8 @@ async def change_pass(
77
87
  new_password: str = typer.Option('', '--new-password', '-n', help='新密码'),
78
88
  ):
79
89
  with bpath.useTempPath() as tempPath:
80
- tempFile = tempPath / file.name
81
- bpath.copy(file, tempFile)
82
- unzip(tempFile, password)
83
- workspace = tempFile.with_suffix('')
84
- zip(workspace, new_password)
85
- bpath.copy(tempFile, file)
90
+ target = tempPath / file.name
91
+ bpath.copy(file, target)
92
+ unzip(target, password)
93
+ zip(target, new_password)
94
+ bpath.copy(target, file)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: bcmd
3
- Version: 0.5.14
3
+ Version: 0.5.16
4
4
  Summary: Commands for Beni
5
5
  Author-email: Beni Mang <benimang@126.com>
6
6
  Maintainer-email: Beni Mang <benimang@126.com>
@@ -3,7 +3,7 @@
3
3
 
4
4
  [project]
5
5
  name = 'bcmd'
6
- version = '0.5.14'
6
+ version = '0.5.16'
7
7
  description = 'Commands for Beni'
8
8
  requires-python = '>=3.10'
9
9
  keywords = ['benimang', 'beni', 'bcmd']
@@ -1,212 +0,0 @@
1
- import asyncio
2
- import os
3
- import random
4
- from enum import StrEnum
5
- from pathlib import Path
6
- from typing import Final
7
-
8
- import httpx
9
- import typer
10
- from beni import bcolor, bfile, bhttp, block, bpath, btask
11
- from beni.bbyte import BytesReader, BytesWriter
12
- from beni.bfunc import syncCall
13
- from beni.btype import Null, XPath
14
- from PIL import Image
15
-
16
- app: Final = btask.newSubApp('图片工具集')
17
-
18
-
19
- class _OutputType(StrEnum):
20
- '输出类型'
21
- normal = '0'
22
- replace_ = '1'
23
- crc_replace = '2'
24
-
25
-
26
- @app.command()
27
- @syncCall
28
- async def convert(
29
- path: Path = typer.Option(None, '--path', '-p', help='指定目录或具体图片文件,默认当前目录'),
30
- src_format: str = typer.Option('jpg|jpeg|png', '--src-format', '-s', help='如果path是目录,指定源格式,可以指定多个,默认值:jpg|jpeg|png'),
31
- dst_format: str = typer.Option('webp', '--dst-format', '-d', help='目标格式,只能是单个'),
32
- rgb: bool = typer.Option(False, '--rgb', help='转换为RGB格式'),
33
- quality: int = typer.Option(85, '--quality', '-q', help='图片质量,0-100,默认85'),
34
- output_type: _OutputType = typer.Option(_OutputType.normal, '--output-type', help='输出类型,0:普通输出,1:删除源文件,2:输出文件使用CRC32命名并删除源文件'),
35
- ):
36
- '图片格式转换'
37
- path = path or Path(os.getcwd())
38
- fileList: list[Path] = []
39
- if path.is_file():
40
- fileList.append(path)
41
- elif path.is_dir():
42
- extNameList = [x for x in src_format.strip().split('|')]
43
- fileList = [x for x in bpath.listFile(path, True) if x.suffix[1:].lower() in extNameList]
44
- if not fileList:
45
- return bcolor.printRed(f'未找到图片文件({path})')
46
- for file in fileList:
47
- with Image.open(file) as img:
48
- if rgb:
49
- img = img.convert('RGB')
50
- with bpath.useTempFile() as tempFile:
51
- img.save(tempFile, format=dst_format, quality=quality)
52
- outputFile = file.with_suffix(f'.{dst_format}')
53
- if output_type == _OutputType.crc_replace:
54
- outputFile = outputFile.with_stem(await bfile.crc(tempFile))
55
- bpath.copy(tempFile, outputFile)
56
- if output_type in [_OutputType.replace_, _OutputType.crc_replace]:
57
- if outputFile != file:
58
- bpath.remove(file)
59
- bcolor.printGreen(f'{file} -> {outputFile}')
60
-
61
-
62
- # ------------------------------------------------------------------------------------
63
-
64
- @app.command()
65
- @syncCall
66
- async def tiny(
67
- path: Path = typer.Option(None, '--path', help='指定目录或具体图片文件,默认当前目录'),
68
- optimization: int = typer.Option(25, '--optimization', help='指定优化大小,如果没有达到优化效果就不处理,单位:%,默认25'),
69
- isKeepOriginal: bool = typer.Option(False, '--keep-original', help='保留原始图片'),
70
- ):
71
-
72
- keyList = [
73
- 'MB3QmtvZ8HKRkXcDnxhWCNTXzvx6cNF3',
74
- '7L7X2CJ35GM1bChSHdT14yZPLx7FlpNk',
75
- 'q8YLcvrXVW2NYcr5mMyzQhsSHF4j7gny',
76
- ]
77
- random.shuffle(keyList)
78
-
79
- btask.assertTrue(0 < optimization < 100, '优化大小必须在0-100之间')
80
- compression = 100 - optimization
81
- await block.setLimit(_TinyFile.runTiny, len(keyList))
82
-
83
- # 整理文件列表
84
- fileList = []
85
- path = path or Path(os.getcwd())
86
- if path.is_file():
87
- fileList.append(path)
88
- elif path.is_dir():
89
- fileList = [x for x in bpath.listFile(path, True) if x.suffix[1:].lower() in ['jpg', 'jpeg', 'png']]
90
- else:
91
- btask.abort('未找到图片文件', path)
92
- fileList.sort()
93
-
94
- # 将文件列表整理成 _TinyFile 对象
95
- fileList = [_TinyFile(x) for x in fileList]
96
- await asyncio.gather(*[x.updateInfo() for x in fileList])
97
-
98
- # 过滤掉已经处理过的图片
99
- for i in range(len(fileList)):
100
- file = fileList[i]
101
- if file.compression == 0:
102
- # 未处理过的图片,进行图片的压缩处理
103
- pass
104
- elif not file.isTiny and file.compression < compression:
105
- # 之前测试的压缩率不符合要求,不过现在符合了,进行图片的压缩处理
106
- pass
107
- else:
108
- # 要忽略掉的文件
109
- if file.isTiny:
110
- bcolor.printYellow(f'{file.file}({file.compression - 100:.2f}% / 已压缩 / {file.getSizeDisplay()})')
111
- else:
112
- bcolor.printMagenta(f'{file.file}({file.compression - 100:.2f}% / 不处理 / {file.getSizeDisplay()})')
113
-
114
- fileList[i] = Null
115
- fileList = [x for x in fileList if x]
116
-
117
- # 开始压缩
118
- taskList = [
119
- x.runTiny(keyList[i % len(keyList)], compression, isKeepOriginal)
120
- for i, x in enumerate(fileList)
121
- ]
122
- await asyncio.gather(*taskList)
123
-
124
-
125
- # ------------------------------------------------------------------------------------
126
-
127
-
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()
176
- )
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()})')
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes