feat(lib): 添加批量图片优化功能与实时进度显示

- 新增 `batchImageOptimization` 函数支持批量处理图片
- 为图片优化添加 spinner 进度指示器和实时计时显示
- 更新版本号至 0.3.2
This commit is contained in:
2026-06-18 16:17:38 +08:00
parent ffcb2801b2
commit 97b30ace84
9 changed files with 553 additions and 479 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(uv run *)"
]
}
}

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "pptopic"
version = "0.3.1"
version = "0.3.2"
description = "导出长图"
readme = "README.md"
requires-python = ">=3.13"
@@ -18,6 +18,7 @@ dependencies = [
"pywin32>=311",
"simtoolsz>=0.2.12.3",
"typer>=0.21.2",
"yaspin>=3.4.0",
]
[project.optional-dependencies]

View File

@@ -1,6 +1,6 @@
from pptopic.main import main
__version__ = "0.3.0"
__version__ = "0.3.2"
__author__ = "Sidney Zhang <zly@lyzhang.me>"

View File

@@ -1,6 +1,8 @@
import shutil
import subprocess
import sys
import threading
import time
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Self
@@ -10,8 +12,9 @@ import numpy as np
import win32com.client as wcc
from PIL import Image
from simtoolsz.utils import lastFile
from yaspin import yaspin
__all__ = ["convertPPT", "PPT_longPic", "imageOptimization"]
__all__ = ["convertPPT", "PPT_longPic", "imageOptimization", "batchImageOptimization"]
class convertPPT:
@@ -123,6 +126,31 @@ def PPT_longPic(
canvas.save(filepath / pptFile.name, format=sType)
def _run_engine_with_spinner(cmd: list, *, desc: str = "优化图片中") -> None:
"""运行外部优化引擎,显示 spinner 和实时耗时。"""
_stop = threading.Event()
_t0 = time.monotonic()
def _update_timer(sp: yaspin) -> None:
while not _stop.is_set():
elapsed = time.monotonic() - _t0
minutes, seconds = divmod(elapsed, 60)
sp.text = f"{desc}... {int(minutes):02d}:{seconds:05.2f}"
_stop.wait(0.2)
with yaspin(text=f"{desc}...") as spinner:
_timer = threading.Thread(target=_update_timer, args=(spinner,), daemon=True)
_timer.start()
try:
subprocess.run(cmd, shell=True, check=True)
except subprocess.CalledProcessError:
spinner.fail("图片优化失败")
raise
finally:
_stop.set()
_timer.join(timeout=1)
def imageOptimization(
imageFile: str | Path | Image.Image,
saveFile: str | Path | None = None,
@@ -130,6 +158,7 @@ def imageOptimization(
max_height: int = None,
engine: str | None = "pngquant",
engine_conf: str | None = None,
progress_desc: str | None = None,
) -> Image.Image | None:
"""图片优化、无损压缩
默认建议使用pngquant进行无损压缩也可以设置为其他图片无损压缩引擎
@@ -173,7 +202,12 @@ def imageOptimization(
if engine:
try:
subprocess.run([engine, engine_conf, tmpPath], shell=True, check=True)
_run_engine_with_spinner(
[engine, engine_conf, tmpPath],
desc=progress_desc or "优化图片中",
)
except subprocess.CalledProcessError:
raise
except Exception as e:
print("未安装pngquant不能进行图片优化压缩。\n可使用`scoop install pngquant`进行安装。")
raise e
@@ -185,3 +219,44 @@ def imageOptimization(
return res
else:
shutil.copyfile(lastFile(Path(tmpFolder), "*.*"), saveFile)
def batchImageOptimization(
image_files: list[str | Path],
save_dir: str | Path | None = None,
max_width: int = None,
max_height: int = None,
engine: str | None = "pngquant",
engine_conf: str | None = None,
) -> list[Path]:
"""批量优化图片,每张图片独立显示 spinner + 进度计数。
单个文件失败不中断整体流程,完成后返回成功处理的文件列表。
"""
results: list[Path] = []
total = len(image_files)
for i, image_file in enumerate(image_files, 1):
image_file = Path(image_file)
save_file: Path | None = None
if save_dir:
save_file = Path(save_dir) / image_file.name
try:
result = imageOptimization(
imageFile=image_file,
saveFile=save_file,
max_width=max_width,
max_height=max_height,
engine=engine,
engine_conf=engine_conf,
progress_desc=f"[{i}/{total}] 优化图片中",
)
if result is not None:
results.append(Path(image_file))
elif save_file:
results.append(save_file)
except Exception:
continue
return results

View File

@@ -3,7 +3,7 @@ from pathlib import Path
import typer
from simtoolsz.utils import today
from pptopic.lib import PPT_longPic, imageOptimization
from pptopic.lib import PPT_longPic, batchImageOptimization, imageOptimization
app = typer.Typer()
@@ -86,6 +86,45 @@ def optimize(
raise typer.Exit(code=1)
@app.command()
def batch_optimize(
input_dir: Path = typer.Argument(..., help="输入图片目录", exists=True, file_okay=False),
output_dir: Path = typer.Option(None, "--output", "-o", help="输出目录,默认覆盖原文件"),
pattern: str = typer.Option("*.png", "--pattern", "-p", help="文件匹配模式(默认 *.png"),
max_height: int = typer.Option(29999, "--max-height", help="优化图片的最大高度默认29999"),
engine: str = typer.Option("pngquant", "--engine", help="图片优化引擎默认pngquant"),
engine_conf: str = typer.Option(
"--skip-if-larger", "--engine-conf", help="图片优化引擎配置参数(默认--skip-if-larger"
),
):
"""
批量优化目录下的所有图片
示例:
pptopic batch-optimize ./images
pptopic batch-optimize ./images --pattern "*.jpg" --output ./optimized
pptopic batch-optimize ./images --max-height 20000 --engine pngquant
"""
try:
image_files = sorted(input_dir.glob(pattern))
if not image_files:
typer.echo(f"{input_dir} 中没有找到匹配 {pattern} 的文件", err=True)
raise typer.Exit(code=1)
results = batchImageOptimization(
image_files=image_files,
save_dir=output_dir,
max_height=max_height,
engine=engine,
engine_conf=engine_conf,
)
typer.echo(f"成功优化 {len(results)}/{len(image_files)} 张图片")
except Exception as e:
typer.echo(f"批量优化失败: {e}", err=True)
raise typer.Exit(code=1)
@app.command()
def version():
"""显示版本信息"""

View File

@@ -1,94 +1,66 @@
"""Tests for convertPPT class."""
import pytest
from pathlib import Path
from unittest.mock import Mock, MagicMock, patch
from unittest.mock import MagicMock, patch
import pytest
from pptopic.lib import convertPPT
class TestConvertPPTClass:
"""Test convertPPT class initialization and properties."""
class TestConvertPPT:
"""Test convertPPT class."""
def test_ttypes_mapping(self):
"""Test that TTYPES mapping is correct."""
assert convertPPT.TTYPES["JPG"] == 17
assert convertPPT.TTYPES["PNG"] == 18
assert convertPPT.TTYPES["PDF"] == 32
assert convertPPT.TTYPES["XPS"] == 33
TTYPE_VALUES = [("JPG", 17), ("PNG", 18), ("PDF", 32), ("XPS", 33)]
@patch('pptopic.lib.sys.platform', 'win32')
def test_init_with_valid_file(self, tmp_path):
"""Test initialization with a valid file path."""
@pytest.mark.parametrize("ttype,expected", TTYPE_VALUES)
def test_ttypes_mapping(self, ttype, expected):
assert convertPPT.TTYPES[ttype] == expected
@pytest.mark.parametrize("as_path", [False, True])
@patch("pptopic.lib.sys.platform", "win32")
def test_init_valid(self, as_path, tmp_path):
test_file = tmp_path / "test.pptx"
test_file.touch()
if as_path:
test_file = Path(test_file)
with patch('pptopic.lib.wcc.Dispatch') as mock_dispatch:
mock_app = MagicMock()
mock_dispatch.return_value = mock_app
with patch("pptopic.lib.wcc.Dispatch"):
ppt = convertPPT(test_file, trans="JPG")
assert ppt.savetype == "JPG"
mock_dispatch.assert_called_once_with('PowerPoint.Application')
@patch('pptopic.lib.sys.platform', 'win32')
def test_init_with_path_object(self, tmp_path):
"""Test initialization with Path object."""
test_file = tmp_path / "test.pptx"
test_file.touch()
with patch('pptopic.lib.wcc.Dispatch'):
ppt = convertPPT(Path(test_file), trans="PNG")
assert ppt.savetype == "PNG"
@patch('pptopic.lib.sys.platform', 'win32')
def test_init_with_nonexistent_file(self):
"""Test initialization with nonexistent file raises FileNotFoundError."""
@patch("pptopic.lib.sys.platform", "win32")
def test_init_nonexistent_file(self):
with pytest.raises(FileNotFoundError, match="File not found"):
convertPPT("nonexistent.pptx")
@patch('pptopic.lib.sys.platform', 'win32')
def test_init_with_invalid_trans_type(self, tmp_path):
"""Test initialization with invalid save type raises ValueError."""
@patch("pptopic.lib.sys.platform", "win32")
def test_init_invalid_trans_type(self, tmp_path):
test_file = tmp_path / "test.pptx"
test_file.touch()
with pytest.raises(ValueError, match="Save type is not supported"):
convertPPT(test_file, trans="BMP")
@patch('pptopic.lib.sys.platform', 'win32')
def test_init_with_case_insensitive_trans(self, tmp_path):
"""Test that trans parameter is case insensitive."""
@pytest.mark.parametrize("ttype,expected", [("jpg", "JPG"), ("Pdf", "PDF")])
@patch("pptopic.lib.sys.platform", "win32")
def test_init_case_insensitive_trans(self, ttype, expected, tmp_path):
test_file = tmp_path / "test.pptx"
test_file.touch()
with patch("pptopic.lib.wcc.Dispatch"):
ppt = convertPPT(test_file, trans=ttype)
assert ppt.savetype == expected
with patch('pptopic.lib.wcc.Dispatch'):
ppt = convertPPT(test_file, trans="jpg")
assert ppt.savetype == "JPG"
ppt = convertPPT(test_file, trans="Pdf")
assert ppt.savetype == "PDF"
class TestConvertPTTNonWindows:
"""Test convertPPT behavior on non-Windows systems."""
@patch('pptopic.lib.sys.platform', 'linux')
def test_init_on_non_windows_raises_error(self):
"""Test that initialization on non-Windows raises SystemError."""
@patch("pptopic.lib.sys.platform", "linux")
def test_init_non_windows_raises_error(self):
with pytest.raises(SystemError, match="Only support Windows system"):
convertPPT("test.pptx")
class TestConvertPPTContextManager:
"""Test convertPPT as context manager."""
@patch('pptopic.lib.sys.platform', 'win32')
def test_context_manager_enter_exit(self, tmp_path):
"""Test __enter__ and __exit__ methods."""
@patch("pptopic.lib.sys.platform", "win32")
def test_context_manager(self, tmp_path):
test_file = tmp_path / "test.pptx"
test_file.touch()
with patch('pptopic.lib.wcc.Dispatch') as mock_dispatch:
with patch("pptopic.lib.wcc.Dispatch") as mock_dispatch:
mock_app = MagicMock()
mock_dispatch.return_value = mock_app
@@ -97,54 +69,34 @@ class TestConvertPPTContextManager:
mock_app.Quit.assert_called_once()
class TestConvertPPTSaveAs:
"""Test saveAs method."""
@patch('pptopic.lib.sys.platform', 'win32')
def test_saveas_default(self, tmp_path):
"""Test saveAs with default parameter."""
@pytest.mark.parametrize(
"save_as,expected",
[(None, "JPG"), ("PDF", "PDF")],
ids=["default_jpg", "explicit_pdf"],
)
@patch("pptopic.lib.sys.platform", "win32")
def test_saveas(self, save_as, expected, tmp_path):
test_file = tmp_path / "test.pptx"
test_file.touch()
with patch('pptopic.lib.wcc.Dispatch'):
with patch("pptopic.lib.wcc.Dispatch"):
ppt = convertPPT(test_file, trans="PNG")
ppt.saveAs()
assert ppt.savetype == "JPG"
ppt.saveAs(save_as)
assert ppt.savetype == expected
@patch('pptopic.lib.sys.platform', 'win32')
def test_saveas_with_valid_type(self, tmp_path):
"""Test saveAs with valid save type."""
@patch("pptopic.lib.sys.platform", "win32")
def test_saveas_invalid_type(self, tmp_path):
test_file = tmp_path / "test.pptx"
test_file.touch()
with patch('pptopic.lib.wcc.Dispatch'):
ppt = convertPPT(test_file, trans="JPG")
ppt.saveAs("PDF")
assert ppt.savetype == "PDF"
@patch('pptopic.lib.sys.platform', 'win32')
def test_saveas_with_invalid_type(self, tmp_path):
"""Test saveAs with invalid save type raises ValueError."""
test_file = tmp_path / "test.pptx"
test_file.touch()
with patch('pptopic.lib.wcc.Dispatch'):
ppt = convertPPT(test_file, trans="JPG")
with patch("pptopic.lib.wcc.Dispatch"):
ppt = convertPPT(test_file)
with pytest.raises(ValueError, match="Save type is not supported"):
ppt.saveAs("BMP")
class TestConvertPTTOpenClassmethod:
"""Test open class method."""
@patch('pptopic.lib.sys.platform', 'win32')
@patch("pptopic.lib.sys.platform", "win32")
def test_open_classmethod(self, tmp_path):
"""Test open class method creates instance."""
test_file = tmp_path / "test.pptx"
test_file.touch()
with patch('pptopic.lib.wcc.Dispatch'):
with patch("pptopic.lib.wcc.Dispatch"):
ppt = convertPPT.open(test_file, trans="PNG")
assert ppt.savetype == "PNG"
assert isinstance(ppt, convertPPT)

View File

@@ -1,244 +1,123 @@
"""Tests for PPT_longPic function."""
import pytest
from pathlib import Path
from unittest.mock import Mock, MagicMock, patch
from PIL import Image
from unittest.mock import MagicMock, patch
import pytest
from pptopic.lib import PPT_longPic
def _make_img_mocks(tmp_path, num_images=1):
"""Build reusable mock objects for PPT_longPic tests.
Returns (img_paths, mock_img, mock_canvas):
- img_paths: real Path objects so ``sorted()`` works
- mock_img: PIL Image mock with .size, .width, .height, .resize
- mock_canvas: PIL Image.new mock
"""
img_paths = [tmp_path / f"slide{i}.jpg" for i in range(1, num_images + 1)]
mock_img = MagicMock()
mock_img.mode = "RGB"
mock_img.size = (100, 100)
mock_img.width = 100
mock_img.height = 100
mock_img.resize.return_value = mock_img
mock_canvas = MagicMock()
return img_paths, mock_img, mock_canvas
class TestPPTLongPic:
"""Test PPT_longPic function."""
@patch('pptopic.lib.convertPPT')
def test_ppt_longpic_with_save_name(self, mock_convert_ppt, tmp_path):
"""Test PPT_longPic with saveName parameter."""
ppt_file = tmp_path / "test.pptx"
ppt_file.touch()
# -- width variations --------------------------------------------------
mock_ppt = MagicMock()
mock_convert_ppt.return_value.__enter__.return_value = mock_ppt
@pytest.mark.parametrize(
"width,expected_size",
[
(None, (100, 100)), # no scaling → uses img.size
(50, (50, 50)), # pixel width → (w, h*w//W)
("50%", (50, 50)), # percentage → half
],
ids=["none", "pixel", "percentage"],
)
@patch("pptopic.lib.convertPPT")
def test_width_variations(self, mock_convert_ppt, width, expected_size, tmp_path):
"""PPT_longPic handles None / pixel / percentage width correctly."""
img_paths, mock_img, mock_canvas = _make_img_mocks(tmp_path)
with patch('pptopic.lib.Path.glob') as mock_glob:
mock_img_path = MagicMock()
mock_img_path.suffix = '.jpg'
mock_glob.return_value = [mock_img_path]
mock_convert_ppt.return_value.__enter__.return_value = MagicMock()
with patch('pptopic.lib.Image.open') as mock_open:
mock_img = MagicMock()
mock_img.mode = 'RGB'
mock_img.width = 100
mock_img.height = 100
mock_img.resize.return_value = mock_img
with patch("pptopic.lib.Path.glob", return_value=img_paths):
with patch("pptopic.lib.Image.open") as mock_open:
mock_open.return_value.__enter__.return_value = mock_img
with patch('pptopic.lib.Image.new') as mock_new:
mock_canvas = MagicMock()
mock_new.return_value = mock_canvas
PPT_longPic(ppt_file, saveName="output.jpg", saveto=tmp_path)
with patch("pptopic.lib.Image.new", return_value=mock_canvas):
PPT_longPic(tmp_path / "test.pptx", width=width, saveto=tmp_path)
mock_canvas.save.assert_called_once()
@patch('pptopic.lib.convertPPT')
def test_ppt_longpic_without_save_name(self, mock_convert_ppt, tmp_path):
"""Test PPT_longPic without saveName uses default JPG."""
ppt_file = tmp_path / "test.pptx"
ppt_file.touch()
# -- saveName / saveto variations -------------------------------------
mock_ppt = MagicMock()
mock_convert_ppt.return_value.__enter__.return_value = mock_ppt
@pytest.mark.parametrize(
"save_name,saveto",
[
(None, None), # defaults: JPG, ppt parent dir
("output.jpg", None), # named, ppt parent dir
(None, "."), # default name, custom dir
],
ids=["defaults", "named", "custom_dir"],
)
@patch("pptopic.lib.convertPPT")
def test_save_params(self, mock_convert_ppt, save_name, saveto, tmp_path):
"""PPT_longPic handles saveName / saveto combinations."""
img_paths, mock_img, mock_canvas = _make_img_mocks(tmp_path)
with patch('pptopic.lib.Path.glob') as mock_glob:
mock_img_path = MagicMock()
mock_img_path.suffix = '.jpg'
mock_glob.return_value = [mock_img_path]
mock_convert_ppt.return_value.__enter__.return_value = MagicMock()
with patch('pptopic.lib.Image.open') as mock_open:
mock_img = MagicMock()
mock_img.mode = 'RGB'
mock_img.width = 100
mock_img.height = 100
mock_img.resize.return_value = mock_img
with patch("pptopic.lib.Path.glob", return_value=img_paths):
with patch("pptopic.lib.Image.open") as mock_open:
mock_open.return_value.__enter__.return_value = mock_img
with patch('pptopic.lib.Image.new') as mock_new:
mock_canvas = MagicMock()
mock_new.return_value = mock_canvas
PPT_longPic(ppt_file, saveto=tmp_path)
with patch("pptopic.lib.Image.new", return_value=mock_canvas):
PPT_longPic(
tmp_path / "test.pptx",
saveName=save_name,
saveto=saveto if saveto else tmp_path,
)
mock_canvas.save.assert_called_once()
def test_ppt_longpic_invalid_image_type(self, tmp_path):
"""Test PPT_longPic with invalid image type raises ValueError."""
# -- error paths -------------------------------------------------------
def test_invalid_image_type(self, tmp_path):
ppt_file = tmp_path / "test.pptx"
ppt_file.touch()
with pytest.raises(ValueError, match="Unable to save this type"):
PPT_longPic(ppt_file, saveName="output.bmp")
@patch('pptopic.lib.convertPPT')
def test_ppt_longpic_no_images_generated(self, mock_convert_ppt, tmp_path):
"""Test PPT_longPic when no images are generated raises ValueError."""
@patch("pptopic.lib.convertPPT")
def test_no_images_generated(self, mock_convert_ppt, tmp_path):
ppt_file = tmp_path / "test.pptx"
ppt_file.touch()
mock_convert_ppt.return_value.__enter__.return_value = MagicMock()
mock_ppt = MagicMock()
mock_convert_ppt.return_value.__enter__.return_value = mock_ppt
with patch('pptopic.lib.Path.glob', return_value=[]):
with patch("pptopic.lib.Path.glob", return_value=[]):
with pytest.raises(ValueError, match="No images generated"):
PPT_longPic(ppt_file)
@patch('pptopic.lib.convertPPT')
def test_ppt_longpic_with_percentage_width(self, mock_convert_ppt, tmp_path):
"""Test PPT_longPic with percentage width string."""
ppt_file = tmp_path / "test.pptx"
ppt_file.touch()
# -- multiple images ---------------------------------------------------
mock_ppt = MagicMock()
mock_convert_ppt.return_value.__enter__.return_value = mock_ppt
@patch("pptopic.lib.convertPPT")
def test_multiple_images(self, mock_convert_ppt, tmp_path):
"""PPT_longPic pastes every slide into the canvas."""
img_paths, mock_img, mock_canvas = _make_img_mocks(tmp_path, num_images=3)
with patch('pptopic.lib.Path.glob') as mock_glob:
mock_img_path = MagicMock()
mock_img_path.suffix = '.jpg'
mock_glob.return_value = [mock_img_path]
mock_convert_ppt.return_value.__enter__.return_value = MagicMock()
with patch('pptopic.lib.Image.open') as mock_open:
mock_img = MagicMock()
mock_img.mode = 'RGB'
mock_img.width = 100
mock_img.height = 100
mock_img.resize.return_value = mock_img
with patch("pptopic.lib.Path.glob", return_value=img_paths):
with patch("pptopic.lib.Image.open") as mock_open:
mock_open.return_value.__enter__.return_value = mock_img
with patch("pptopic.lib.Image.new", return_value=mock_canvas):
PPT_longPic(tmp_path / "test.pptx", saveto=tmp_path)
with patch('pptopic.lib.Image.new') as mock_new:
mock_canvas = MagicMock()
mock_new.return_value = mock_canvas
PPT_longPic(ppt_file, width="50%", saveto=tmp_path)
mock_new.assert_called_once()
@patch('pptopic.lib.convertPPT')
def test_ppt_longpic_with_pixel_width(self, mock_convert_ppt, tmp_path):
"""Test PPT_longPic with pixel width."""
ppt_file = tmp_path / "test.pptx"
ppt_file.touch()
mock_ppt = MagicMock()
mock_convert_ppt.return_value.__enter__.return_value = mock_ppt
with patch('pptopic.lib.Path.glob') as mock_glob:
mock_img_path = MagicMock()
mock_img_path.suffix = '.jpg'
mock_glob.return_value = [mock_img_path]
with patch('pptopic.lib.Image.open') as mock_open:
mock_img = MagicMock()
mock_img.mode = 'RGB'
mock_img.width = 100
mock_img.height = 100
mock_img.resize.return_value = mock_img
mock_open.return_value.__enter__.return_value = mock_img
with patch('pptopic.lib.Image.new') as mock_new:
mock_canvas = MagicMock()
mock_new.return_value = mock_canvas
PPT_longPic(ppt_file, width=50, saveto=tmp_path)
mock_new.assert_called_once()
@patch('pptopic.lib.convertPPT')
def test_ppt_longpic_with_none_width(self, mock_convert_ppt, tmp_path):
"""Test PPT_longPic with None width (no scaling)."""
ppt_file = tmp_path / "test.pptx"
ppt_file.touch()
mock_ppt = MagicMock()
mock_convert_ppt.return_value.__enter__.return_value = mock_ppt
with patch('pptopic.lib.Path.glob') as mock_glob:
mock_img_path = MagicMock()
mock_img_path.suffix = '.jpg'
mock_glob.return_value = [mock_img_path]
with patch('pptopic.lib.Image.open') as mock_open:
mock_img = MagicMock()
mock_img.mode = 'RGB'
mock_img.width = 100
mock_img.height = 100
mock_img.resize.return_value = mock_img
mock_open.return_value.__enter__.return_value = mock_img
with patch('pptopic.lib.Image.new') as mock_new:
mock_canvas = MagicMock()
mock_new.return_value = mock_canvas
PPT_longPic(ppt_file, width=None, saveto=tmp_path)
mock_new.assert_called_once()
@patch('pptopic.lib.convertPPT')
def test_ppt_longpic_save_to_current_dir(self, mock_convert_ppt, tmp_path):
"""Test PPT_longPic saving to current directory."""
ppt_file = tmp_path / "test.pptx"
ppt_file.touch()
mock_ppt = MagicMock()
mock_convert_ppt.return_value.__enter__.return_value = mock_ppt
with patch('pptopic.lib.Path.glob') as mock_glob:
mock_img_path = MagicMock()
mock_img_path.suffix = '.jpg'
mock_glob.return_value = [mock_img_path]
with patch('pptopic.lib.Image.open') as mock_open:
mock_img = MagicMock()
mock_img.mode = 'RGB'
mock_img.width = 100
mock_img.height = 100
mock_img.resize.return_value = mock_img
mock_open.return_value.__enter__.return_value = mock_img
with patch('pptopic.lib.Image.new') as mock_new:
mock_canvas = MagicMock()
mock_new.return_value = mock_canvas
PPT_longPic(ppt_file, saveto=".")
assert mock_canvas.paste.call_count == 3
mock_canvas.save.assert_called_once()
@patch('pptopic.lib.convertPPT')
def test_ppt_longpic_multiple_images(self, mock_convert_ppt, tmp_path):
"""Test PPT_longPic with multiple images."""
ppt_file = tmp_path / "test.pptx"
ppt_file.touch()
mock_ppt = MagicMock()
mock_convert_ppt.return_value.__enter__.return_value = mock_ppt
with patch('pptopic.lib.Path.glob') as mock_glob:
mock_img_path1 = MagicMock()
mock_img_path1.suffix = '.jpg'
mock_img_path2 = MagicMock()
mock_img_path2.suffix = '.jpg'
mock_glob.return_value = [mock_img_path1, mock_img_path2]
with patch('pptopic.lib.Image.open') as mock_open:
mock_img = MagicMock()
mock_img.mode = 'RGB'
mock_img.width = 100
mock_img.height = 100
mock_img.resize.return_value = mock_img
mock_open.return_value.__enter__.return_value = mock_img
with patch('pptopic.lib.Image.new') as mock_new:
mock_canvas = MagicMock()
mock_new.return_value = mock_canvas
PPT_longPic(ppt_file, saveto=tmp_path)
mock_canvas.paste.assert_called()

View File

@@ -1,227 +1,325 @@
"""Tests for imageOptimization function."""
"""Tests for image optimization functions."""
import pytest
import subprocess
from pathlib import Path
from unittest.mock import Mock, MagicMock, patch
from PIL import Image
from unittest.mock import MagicMock, patch
import numpy as np
from pptopic.lib import imageOptimization
import pytest
from PIL import Image
from pptopic.lib import (
_run_engine_with_spinner,
batchImageOptimization,
imageOptimization,
)
def _make_yaspin_mock():
"""Return (mock_yaspin, mock_spinner) wired so __exit__ propagates exceptions."""
mock_spinner = MagicMock()
mock_spinner.__exit__ = MagicMock(return_value=False)
mock_spinner.__enter__ = MagicMock(return_value=mock_spinner)
mock_yaspin = MagicMock(return_value=mock_spinner)
return mock_yaspin, mock_spinner
# ---------------------------------------------------------------------------
# imageOptimization
# ---------------------------------------------------------------------------
class TestImageOptimization:
"""Test imageOptimization function."""
"""Tests for imageOptimization function."""
@patch('pptopic.lib.Image.open')
@patch('pptopic.lib.cv2.cvtColor')
@patch('pptopic.lib.cv2.resize')
@patch('pptopic.lib.subprocess.run')
@patch('pptopic.lib.lastFile')
def test_image_optimization_with_file_path(self, mock_last_file, mock_subprocess,
mock_resize, mock_cvtcolor, mock_open, tmp_path):
"""Test imageOptimization with file path input."""
@patch("pptopic.lib.Image.open")
@patch("pptopic.lib.cv2.cvtColor")
@patch("pptopic.lib.cv2.resize")
@patch("pptopic.lib.subprocess.run")
@patch("pptopic.lib.lastFile")
def test_with_file_path(self, mock_last_file, mock_subprocess, mock_resize, mock_cvtcolor, mock_open, tmp_path):
"""Optimize via file path returns a PIL Image."""
mock_img = MagicMock()
mock_img.size = (100, 100)
mock_img.mode = 'RGB'
mock_img.mode = "RGB"
mock_open.return_value = mock_img
mock_array = np.array([[[255, 0, 0]]])
mock_cvtcolor.return_value = mock_array
mock_resized = np.array([[[255, 0, 0]]])
mock_resize.return_value = mock_resized
mock_cvtcolor.return_value = np.array([[[255, 0, 0]]])
mock_resize.return_value = np.array([[[255, 0, 0]]])
mock_pil_img = MagicMock()
with patch('pptopic.lib.Image.fromarray', return_value=mock_pil_img):
with patch('pptopic.lib.Image.open', return_value=mock_img):
with patch("pptopic.lib.Image.fromarray", return_value=mock_pil_img):
with patch("pptopic.lib.Image.open", return_value=mock_img):
mock_last_file.return_value = tmp_path / "optimized.png"
result = imageOptimization(tmp_path / "test.png")
assert result is not None
@patch('pptopic.lib.cv2.cvtColor')
@patch('pptopic.lib.cv2.resize')
@patch('pptopic.lib.subprocess.run')
@patch('pptopic.lib.lastFile')
def test_image_optimization_with_pil_image(self, mock_last_file, mock_subprocess,
mock_resize, mock_cvtcolor, tmp_path):
"""Test imageOptimization with PIL Image input."""
@patch("pptopic.lib.cv2.cvtColor")
@patch("pptopic.lib.cv2.resize")
@patch("pptopic.lib.subprocess.run")
@patch("pptopic.lib.lastFile")
def test_with_pil_image(self, mock_last_file, mock_subprocess, mock_resize, mock_cvtcolor, tmp_path):
"""Optimize a PIL Image directly."""
mock_img = MagicMock()
mock_img.size = (100, 100)
mock_img.mode = 'RGB'
mock_img.mode = "RGB"
mock_array = np.array([[[255, 0, 0]]])
mock_cvtcolor.return_value = mock_array
mock_resized = np.array([[[255, 0, 0]]])
mock_resize.return_value = mock_resized
mock_cvtcolor.return_value = np.array([[[255, 0, 0]]])
mock_resize.return_value = np.array([[[255, 0, 0]]])
mock_pil_img = MagicMock()
with patch('pptopic.lib.Image.fromarray', return_value=mock_pil_img):
with patch('pptopic.lib.Image.open', return_value=mock_img):
with patch("pptopic.lib.Image.fromarray", return_value=mock_pil_img):
with patch("pptopic.lib.Image.open", return_value=mock_img):
mock_last_file.return_value = tmp_path / "optimized.png"
result = imageOptimization(mock_img)
assert result is not None
@patch('pptopic.lib.Image.open')
@patch('pptopic.lib.cv2.cvtColor')
@patch('pptopic.lib.cv2.resize')
@patch('pptopic.lib.subprocess.run')
@patch('pptopic.lib.shutil.copyfile')
def test_image_optimization_with_save_file(self, mock_copyfile, mock_subprocess,
mock_resize, mock_cvtcolor, mock_open, tmp_path):
"""Test imageOptimization with saveFile parameter."""
@patch("pptopic.lib.Image.open")
@patch("pptopic.lib.cv2.cvtColor")
@patch("pptopic.lib.cv2.resize")
@patch("pptopic.lib.subprocess.run")
@patch("pptopic.lib.shutil.copyfile")
def test_with_save_file(self, mock_copyfile, mock_subprocess, mock_resize, mock_cvtcolor, mock_open, tmp_path):
"""When saveFile is provided, result is None and copyfile is called."""
mock_img = MagicMock()
mock_img.size = (100, 100)
mock_img.mode = 'RGB'
mock_img.mode = "RGB"
mock_open.return_value = mock_img
mock_array = np.array([[[255, 0, 0]]])
mock_cvtcolor.return_value = mock_array
mock_resized = np.array([[[255, 0, 0]]])
mock_resize.return_value = mock_resized
mock_cvtcolor.return_value = np.array([[[255, 0, 0]]])
mock_resize.return_value = np.array([[[255, 0, 0]]])
mock_pil_img = MagicMock()
with patch('pptopic.lib.Image.fromarray', return_value=mock_pil_img):
with patch('pptopic.lib.lastFile') as mock_last_file:
with patch("pptopic.lib.Image.fromarray", return_value=mock_pil_img):
with patch("pptopic.lib.lastFile") as mock_last_file:
mock_last_file.return_value = tmp_path / "optimized.png"
result = imageOptimization(tmp_path / "test.png", saveFile=tmp_path / "output.png")
assert result is None
mock_copyfile.assert_called_once()
@patch('pptopic.lib.Image.open')
@patch('pptopic.lib.cv2.cvtColor')
@patch('pptopic.lib.cv2.resize')
@patch('pptopic.lib.subprocess.run')
@patch('pptopic.lib.lastFile')
def test_image_optimization_with_max_width(self, mock_last_file, mock_subprocess,
mock_resize, mock_cvtcolor, mock_open, tmp_path):
"""Test imageOptimization with max_width parameter."""
@patch("pptopic.lib.Image.open")
@patch("pptopic.lib.cv2.cvtColor")
@patch("pptopic.lib.cv2.resize")
@patch("pptopic.lib.subprocess.run")
@patch("pptopic.lib.lastFile")
def test_with_max_width(self, mock_last_file, mock_subprocess, mock_resize, mock_cvtcolor, mock_open, tmp_path):
"""max_width triggers resize."""
mock_img = MagicMock()
mock_img.size = (200, 100)
mock_img.mode = 'RGB'
mock_img.mode = "RGB"
mock_open.return_value = mock_img
mock_array = np.zeros((100, 200, 3), dtype=np.uint8)
mock_cvtcolor.return_value = mock_array
mock_resized = np.zeros((50, 100, 3), dtype=np.uint8)
mock_resize.return_value = mock_resized
mock_cvtcolor.return_value = np.zeros((100, 200, 3), dtype=np.uint8)
mock_resize.return_value = np.zeros((50, 100, 3), dtype=np.uint8)
mock_pil_img = MagicMock()
with patch('pptopic.lib.Image.fromarray', return_value=mock_pil_img):
with patch('pptopic.lib.Image.open', return_value=mock_img):
with patch("pptopic.lib.Image.fromarray", return_value=mock_pil_img):
with patch("pptopic.lib.Image.open", return_value=mock_img):
mock_last_file.return_value = tmp_path / "optimized.png"
result = imageOptimization(tmp_path / "test.png", max_width=100)
mock_resize.assert_called_once()
assert result is not None
@patch('pptopic.lib.Image.open')
@patch('pptopic.lib.cv2.cvtColor')
@patch('pptopic.lib.cv2.resize')
@patch('pptopic.lib.subprocess.run')
@patch('pptopic.lib.lastFile')
def test_image_optimization_with_max_height(self, mock_last_file, mock_subprocess,
mock_resize, mock_cvtcolor, mock_open, tmp_path):
"""Test imageOptimization with max_height parameter."""
@patch("pptopic.lib.Image.open")
@patch("pptopic.lib.cv2.cvtColor")
@patch("pptopic.lib.cv2.resize")
@patch("pptopic.lib.subprocess.run")
@patch("pptopic.lib.lastFile")
def test_with_max_height(self, mock_last_file, mock_subprocess, mock_resize, mock_cvtcolor, mock_open, tmp_path):
"""max_height triggers resize."""
mock_img = MagicMock()
mock_img.size = (100, 200)
mock_img.mode = 'RGB'
mock_img.mode = "RGB"
mock_open.return_value = mock_img
mock_array = np.zeros((200, 100, 3), dtype=np.uint8)
mock_cvtcolor.return_value = mock_array
mock_resized = np.zeros((100, 50, 3), dtype=np.uint8)
mock_resize.return_value = mock_resized
mock_cvtcolor.return_value = np.zeros((200, 100, 3), dtype=np.uint8)
mock_resize.return_value = np.zeros((100, 50, 3), dtype=np.uint8)
mock_pil_img = MagicMock()
with patch('pptopic.lib.Image.fromarray', return_value=mock_pil_img):
with patch('pptopic.lib.Image.open', return_value=mock_img):
with patch("pptopic.lib.Image.fromarray", return_value=mock_pil_img):
with patch("pptopic.lib.Image.open", return_value=mock_img):
mock_last_file.return_value = tmp_path / "optimized.png"
result = imageOptimization(tmp_path / "test.png", max_height=100)
mock_resize.assert_called_once()
assert result is not None
@patch('pptopic.lib.Image.open')
@patch('pptopic.lib.cv2.cvtColor')
@patch('pptopic.lib.cv2.resize')
@patch('pptopic.lib.subprocess.run')
@patch('pptopic.lib.lastFile')
def test_image_optimization_with_fractional_width(self, mock_last_file, mock_subprocess,
mock_resize, mock_cvtcolor, mock_open, tmp_path):
"""Test imageOptimization with fractional max_width (< 1)."""
@patch("pptopic.lib.Image.open")
@patch("pptopic.lib.cv2.cvtColor")
@patch("pptopic.lib.cv2.resize")
@patch("pptopic.lib.subprocess.run")
@patch("pptopic.lib.lastFile")
def test_with_fractional_width(self, mock_last_file, mock_subprocess, mock_resize, mock_cvtcolor, mock_open, tmp_path):
"""max_width < 1 is treated as a scale factor."""
mock_img = MagicMock()
mock_img.size = (200, 100)
mock_img.mode = 'RGB'
mock_img.mode = "RGB"
mock_open.return_value = mock_img
mock_array = np.zeros((100, 200, 3), dtype=np.uint8)
mock_cvtcolor.return_value = mock_array
mock_resized = np.zeros((50, 100, 3), dtype=np.uint8)
mock_resize.return_value = mock_resized
mock_cvtcolor.return_value = np.zeros((100, 200, 3), dtype=np.uint8)
mock_resize.return_value = np.zeros((50, 100, 3), dtype=np.uint8)
mock_pil_img = MagicMock()
with patch('pptopic.lib.Image.fromarray', return_value=mock_pil_img):
with patch('pptopic.lib.Image.open', return_value=mock_img):
with patch("pptopic.lib.Image.fromarray", return_value=mock_pil_img):
with patch("pptopic.lib.Image.open", return_value=mock_img):
mock_last_file.return_value = tmp_path / "optimized.png"
result = imageOptimization(tmp_path / "test.png", max_width=0.5)
mock_resize.assert_called_once()
assert result is not None
@patch('pptopic.lib.Image.open')
@patch('pptopic.lib.cv2.cvtColor')
@patch('pptopic.lib.subprocess.run')
@patch('pptopic.lib.lastFile')
def test_image_optimization_without_engine(self, mock_last_file, mock_subprocess,
mock_cvtcolor, mock_open, tmp_path):
"""Test imageOptimization with engine=None."""
@patch("pptopic.lib.Image.open")
@patch("pptopic.lib.cv2.cvtColor")
@patch("pptopic.lib.subprocess.run")
@patch("pptopic.lib.lastFile")
def test_without_engine(self, mock_last_file, mock_subprocess, mock_cvtcolor, mock_open, tmp_path):
"""engine=None skips subprocess and saves via PIL."""
mock_img = MagicMock()
mock_img.size = (100, 100)
mock_img.mode = 'RGB'
mock_img.mode = "RGB"
mock_open.return_value = mock_img
mock_array = np.array([[[255, 0, 0]]])
mock_cvtcolor.return_value = mock_array
mock_cvtcolor.return_value = np.array([[[255, 0, 0]]])
mock_pil_img = MagicMock()
with patch('pptopic.lib.Image.fromarray', return_value=mock_pil_img):
with patch('pptopic.lib.Image.open', return_value=mock_img):
with patch("pptopic.lib.Image.fromarray", return_value=mock_pil_img):
with patch("pptopic.lib.Image.open", return_value=mock_img):
mock_last_file.return_value = tmp_path / "optimized.png"
result = imageOptimization(tmp_path / "test.png", engine=None)
mock_subprocess.assert_not_called()
assert result is not None
@patch('pptopic.lib.Image.open')
@patch('pptopic.lib.cv2.cvtColor')
@patch('pptopic.lib.subprocess.run')
def test_image_optimization_engine_failure(self, mock_subprocess, mock_cvtcolor, mock_open):
"""Test imageOptimization when engine subprocess fails."""
@patch("pptopic.lib.Image.open")
@patch("pptopic.lib.cv2.cvtColor")
@patch("pptopic.lib.subprocess.run")
def test_engine_failure(self, mock_subprocess, mock_cvtcolor, mock_open):
"""Generic engine error re-raises after printing help text."""
mock_img = MagicMock()
mock_img.size = (100, 100)
mock_img.mode = 'RGB'
mock_img.mode = "RGB"
mock_open.return_value = mock_img
mock_array = np.array([[[255, 0, 0]]])
mock_cvtcolor.return_value = mock_array
mock_cvtcolor.return_value = np.array([[[255, 0, 0]]])
mock_subprocess.side_effect = Exception("Engine not found")
mock_pil_img = MagicMock()
with patch('pptopic.lib.Image.fromarray', return_value=mock_pil_img):
with patch("pptopic.lib.Image.fromarray", return_value=mock_pil_img):
with pytest.raises(Exception):
imageOptimization("test.png", engine="pngquant", engine_conf="--quality=85")
imageOptimization("test.png", engine="pngquant")
# ---------------------------------------------------------------------------
# _run_engine_with_spinner
# ---------------------------------------------------------------------------
class TestRunEngineWithSpinner:
"""Tests for _run_engine_with_spinner helper."""
def test_runs_subprocess_with_correct_cmd(self):
mock_yaspin, mock_spinner = _make_yaspin_mock()
cmd = ["pngquant", "--skip-if-larger", "/tmp/x.png"]
with patch("pptopic.lib.yaspin", mock_yaspin):
with patch("pptopic.lib.subprocess.run") as mock_run:
_run_engine_with_spinner(cmd, desc="优化中")
mock_run.assert_called_once_with(cmd, shell=True, check=True)
def test_called_process_error_calls_spinner_fail(self):
mock_yaspin, mock_spinner = _make_yaspin_mock()
cmd = ["pngquant", "/tmp/x.png"]
with patch("pptopic.lib.yaspin", mock_yaspin):
with patch("pptopic.lib.subprocess.run") as mock_run:
mock_run.side_effect = subprocess.CalledProcessError(1, cmd)
with pytest.raises(subprocess.CalledProcessError):
_run_engine_with_spinner(cmd)
mock_spinner.fail.assert_called_once_with("图片优化失败")
def test_timer_thread_cleans_up_in_finally(self):
"""Stop event is set and timer thread is joined after execution."""
mock_yaspin, mock_spinner = _make_yaspin_mock()
cmd = ["pngquant", "/tmp/x.png"]
with patch("pptopic.lib.yaspin", mock_yaspin):
with patch("pptopic.lib.subprocess.run"):
with patch("pptopic.lib.threading.Thread") as mock_thread_cls:
mock_thread = MagicMock()
mock_thread_cls.return_value = mock_thread
with patch("pptopic.lib.threading.Event") as mock_event_cls:
mock_event = MagicMock()
mock_event_cls.return_value = mock_event
_run_engine_with_spinner(cmd)
mock_event.set.assert_called_once()
mock_thread.join.assert_called_once_with(timeout=1)
# ---------------------------------------------------------------------------
# batchImageOptimization
# ---------------------------------------------------------------------------
class TestBatchImageOptimization:
"""Tests for batchImageOptimization function."""
def test_processes_each_file(self, tmp_path):
files = [tmp_path / f"img{i}.png" for i in range(1, 4)]
for f in files:
f.touch()
with patch("pptopic.lib.imageOptimization") as mock_opt:
mock_opt.return_value = None
results = batchImageOptimization(files)
assert mock_opt.call_count == 3
assert results == []
def test_result_appends_source_when_image_returned(self, tmp_path):
files = [tmp_path / "img1.png"]
files[0].touch()
with patch("pptopic.lib.imageOptimization") as mock_opt:
mock_opt.return_value = MagicMock(spec=Image.Image)
results = batchImageOptimization(files)
assert results == [files[0]]
def test_with_save_dir(self, tmp_path):
files = [tmp_path / "img1.png"]
files[0].touch()
save_dir = tmp_path / "out"
save_dir.mkdir()
with patch("pptopic.lib.imageOptimization") as mock_opt:
mock_opt.return_value = None
results = batchImageOptimization(files, save_dir=save_dir)
assert results == [save_dir / "img1.png"]
def test_single_failure_does_not_abort_batch(self, tmp_path):
files = [tmp_path / f"img{i}.png" for i in range(1, 4)]
for f in files:
f.touch()
with patch("pptopic.lib.imageOptimization") as mock_opt:
mock_opt.side_effect = [Exception("fail"), None, None]
results = batchImageOptimization(files)
assert mock_opt.call_count == 3
assert results == []
def test_progress_desc_includes_file_index(self, tmp_path):
files = [tmp_path / f"img{i}.png" for i in range(1, 4)]
for f in files:
f.touch()
with patch("pptopic.lib.imageOptimization") as mock_opt:
mock_opt.return_value = None
batchImageOptimization(files)
assert mock_opt.call_count == 3
assert mock_opt.call_args_list[0][1]["progress_desc"] == "[1/3] 优化图片中"
assert mock_opt.call_args_list[1][1]["progress_desc"] == "[2/3] 优化图片中"
assert mock_opt.call_args_list[2][1]["progress_desc"] == "[3/3] 优化图片中"

25
uv.lock generated
View File

@@ -360,7 +360,7 @@ wheels = [
[[package]]
name = "pptopic"
version = "0.3.0"
version = "0.3.2"
source = { editable = "." }
dependencies = [
{ name = "numpy" },
@@ -369,6 +369,7 @@ dependencies = [
{ name = "pywin32" },
{ name = "simtoolsz" },
{ name = "typer" },
{ name = "yaspin" },
]
[package.optional-dependencies]
@@ -389,6 +390,7 @@ requires-dist = [
{ name = "pywin32", specifier = ">=311" },
{ name = "simtoolsz", specifier = ">=0.2.12.3" },
{ name = "typer", specifier = ">=0.21.2" },
{ name = "yaspin", specifier = ">=3.4.0" },
]
provides-extras = ["dev"]
@@ -551,6 +553,15 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "termcolor"
version = "3.3.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" },
]
[[package]]
name = "typer"
version = "0.21.2"
@@ -574,3 +585,15 @@ sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5e/a7/c202b344c5ca7d
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
]
[[package]]
name = "yaspin"
version = "3.4.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "termcolor" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8d/c5/826a862dcfcb9e85321f96d6f1b4b96b3b9bf37df6f63dce9cffd0b17053/yaspin-3.4.0.tar.gz", hash = "sha256:a83a81ac7a9d161e116fb668a7e4d10d87fb18d02b4b08a17b7e472f465f3c90", size = 42396, upload-time = "2025-12-06T12:33:51.889Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/93/6f/7403e6ae864a0a7f1cdd8814d39690062766e141339127f2b3469201ff6f/yaspin-3.4.0-py3-none-any.whl", hash = "sha256:2a40572a38d39846d0df0a421733459481b7da17789f7a2618c3181bb0a82819", size = 21822, upload-time = "2025-12-06T12:33:50.633Z" },
]