feat(lib): 添加批量图片优化功能与实时进度显示
- 新增 `batchImageOptimization` 函数支持批量处理图片 - 为图片优化添加 spinner 进度指示器和实时计时显示 - 更新版本号至 0.3.2
This commit is contained in:
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(uv run *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from pptopic.main import main
|
||||
|
||||
__version__ = "0.3.0"
|
||||
__version__ = "0.3.2"
|
||||
|
||||
__author__ = "Sidney Zhang <zly@lyzhang.me>"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
"""显示版本信息"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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", return_value=mock_canvas):
|
||||
PPT_longPic(tmp_path / "test.pptx", width=width, saveto=tmp_path)
|
||||
|
||||
with patch('pptopic.lib.Image.new') as mock_new:
|
||||
mock_canvas = MagicMock()
|
||||
mock_new.return_value = mock_canvas
|
||||
mock_canvas.save.assert_called_once()
|
||||
|
||||
PPT_longPic(ppt_file, saveName="output.jpg", saveto=tmp_path)
|
||||
# -- saveName / saveto variations -------------------------------------
|
||||
|
||||
mock_canvas.save.assert_called_once()
|
||||
@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)
|
||||
|
||||
@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()
|
||||
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') 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
|
||||
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",
|
||||
saveName=save_name,
|
||||
saveto=saveto if saveto else tmp_path,
|
||||
)
|
||||
|
||||
with patch('pptopic.lib.Image.new') as mock_new:
|
||||
mock_canvas = MagicMock()
|
||||
mock_new.return_value = mock_canvas
|
||||
mock_canvas.save.assert_called_once()
|
||||
|
||||
PPT_longPic(ppt_file, saveto=tmp_path)
|
||||
# -- error paths -------------------------------------------------------
|
||||
|
||||
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."""
|
||||
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=".")
|
||||
|
||||
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()
|
||||
assert mock_canvas.paste.call_count == 3
|
||||
mock_canvas.save.assert_called_once()
|
||||
|
||||
@@ -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
25
uv.lock
generated
@@ -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" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user