diff --git a/README.md b/README.md index 46d1394..4f69e67 100644 --- a/README.md +++ b/README.md @@ -1,89 +1,86 @@ -# pptopic - -将 PowerPoint 演示文稿导出为长图的工具。 - -## 功能 - -- 📊 **PPT 转长图**:将 PPTX 文件导出为一张连续的长图 -- 🖼️ **图片优化**:支持使用 `pngquant` 进行无损压缩 -- ⚙️ **灵活配置**:可自定义输出尺寸、格式、保存路径等 - -## 环境要求 - -- **Windows 系统**(依赖 PowerPoint COM 接口) -- Python ≥ 3.13 -- 已安装 Microsoft PowerPoint - -## 安装 - -```bash -pip install pptopic -``` - -如需图片优化功能,请安装 `pngquant`: - -```bash -scoop install pngquant -``` - -也可以使用你熟悉的图片优化引擎替代 `pngquant` ,但一般情况下,我更推荐 `pngquant` 。 - -## 使用 - -### 导出长图 - -```bash -# 基本用法 -pptopic export presentation.pptx - -# 指定输出文件名和目录 -pptopic export presentation.pptx -o result.png -d ./output - -# 自定义宽度并启用优化 -pptopic export presentation.pptx --width 1200 --optimize -``` - -### 优化图片 - -```bash -# 优化现有图片 -pptopic optimize image.png - -# 指定输出路径和最大高度 -pptopic optimize image.png -o optimized.png --max-height 20000 -``` - -## 命令选项 - -### export 命令 - -| 选项 | 简写 | 说明 | -|------|------|------| -| `--output` | `-o` | 输出文件名 | -| `--dir` | `-d` | 输出目录(默认当前目录) | -| `--width` | `-w` | 导出图片宽度(像素) | -| `--format` | `-f` | 输出格式:JPG 或 PNG(默认 PNG) | -| `--optimize` | - | 启用图片优化 | -| `--max-height` | - | 优化时最大高度(默认 29999) | - -### optimize 命令 - -| 选项 | 简写 | 说明 | -|------|------|------| -| `--output` | `-o` | 输出文件路径 | -| `--max-height` | - | 最大高度限制 | -| `--engine` | - | 优化引擎(默认 pngquant) | - -## 示例 - -```bash -# 导出并优化 -pptopic export presentation.pptx --optimize - -# 导出为 JPG 格式 -pptopic export presentation.pptx -f JPG -o result -``` - -## 许可证 - -MIT License +# pptopic + +将 PowerPoint 演示文稿导出为长图的工具。 + +## 功能 + +- 将 PPTX 文件的所有幻灯片垂直拼接为一张长图 +- 支持 JPG 和 PNG 格式输出 +- 支持自定义输出宽度和目录 +- 内置图片优化功能,使用图片优化引擎进行无损压缩 + +## 安装 + +### 安装 uv + +```bash +# 使用 scoop +scoop install uv +``` + +### 安装 pptopic + +```bash +uv pip install -e . +``` + +## 使用 + +### 导出 PPTX 为长图 + +```bash +# 基本用法 +pptopic export presentation.pptx + +# 指定输出文件名 +pptopic export presentation.pptx --output result.png + +# 优化导出的图片 +pptopic export presentation.pptx --optimize + +# 自定义宽度和最大高度 +pptopic export presentation.pptx --width 1080 --optimize --max-height 20000 +``` + +### 单独优化图片 + +```bash +# 优化图片 +pptopic optimize image.png + +# 指定输出文件 +pptopic optimize image.png --output optimized.png + +# 自定义优化参数 +pptopic optimize image.png --max-height 20000 --engine pngquant +``` + +## 系统要求 + +- Windows 系统(使用 win32com 调用 PowerPoint) +- Python >= 3.13 +- Microsoft PowerPoint + +## 图片优化 + +推荐使用 [pngquant](https://pngquant.org/) 进行图片压缩。 + +### 自动安装脚本 + +使用提供的 PowerShell 脚本自动安装 pngquant: + +```powershell +.\install-pngquant.ps1 +``` + +该脚本会自动: +1. 检查 pngquant 是否已安装 +2. 如果未安装,检查 scoop 是否已安装 +3. 如果 scoop 未安装,自动安装 scoop +4. 使用 scoop 安装 pngquant + +### 手动安装 + +```bash +scoop install pngquant +``` diff --git a/install-pngquant.ps1 b/install-pngquant.ps1 new file mode 100644 index 0000000..d77012c --- /dev/null +++ b/install-pngquant.ps1 @@ -0,0 +1,114 @@ +param( + [switch]$Force +) + +$ErrorActionPreference = "Stop" + +function Test-PngquantInstalled { + try { + $result = Get-Command pngquant -ErrorAction Stop + Write-Host "pngquant 已安装: $($result.Source)" -ForegroundColor Green + return $true + } catch { + Write-Host "pngquant 未安装" -ForegroundColor Yellow + return $false + } +} + +function Test-ScoopInstalled { + try { + $result = Get-Command scoop -ErrorAction Stop + Write-Host "scoop 已安装: $($result.Source)" -ForegroundColor Green + return $true + } catch { + Write-Host "scoop 未安装" -ForegroundColor Yellow + return $false + } +} + +function Install-Scoop { + Write-Host "正在安装 scoop..." -ForegroundColor Cyan + + $scoopInstallScript = "irm get.scoop.sh | iex" + + try { + Invoke-Expression $scoopInstallScript + Write-Host "scoop 安装成功" -ForegroundColor Green + + $env:Path = [System.Environment]::GetEnvironmentVariable("Path","User") + ";" + [System.Environment]::GetEnvironmentVariable("Path","Machine") + + return $true + } catch { + Write-Host "scoop 安装失败: $_" -ForegroundColor Red + return $false + } +} + +function Install-Pngquant { + Write-Host "正在使用 scoop 安装 pngquant..." -ForegroundColor Cyan + + try { + scoop install pngquant + Write-Host "pngquant 安装成功" -ForegroundColor Green + return $true + } catch { + Write-Host "pngquant 安装失败: $_" -ForegroundColor Red + return $false + } +} + +Write-Host "========================================" -ForegroundColor Cyan +Write-Host " pngquant 安装脚本" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" + +if (Test-PngquantInstalled) { + Write-Host "" + Write-Host "pngquant 已经安装,无需重复安装。" -ForegroundColor Green + if (-not $Force) { + exit 0 + } + Write-Host "使用 -Force 参数强制重新安装..." -ForegroundColor Yellow +} + +Write-Host "" + +if (-not (Test-ScoopInstalled)) { + Write-Host "" + Write-Host "scoop 未安装,正在安装 scoop..." -ForegroundColor Yellow + + if (-not (Install-Scoop)) { + Write-Host "" + Write-Host "scoop 安装失败,请手动安装后重试。" -ForegroundColor Red + Write-Host "手动安装命令: irm get.scoop.sh | iex" -ForegroundColor Yellow + exit 1 + } + + Write-Host "" + Write-Host "请重新运行此脚本以继续安装 pngquant。" -ForegroundColor Yellow + Write-Host "或者手动运行: scoop install pngquant" -ForegroundColor Yellow + exit 0 +} + +Write-Host "" + +if (Install-Pngquant) { + Write-Host "" + Write-Host "========================================" -ForegroundColor Green + Write-Host " pngquant 安装完成!" -ForegroundColor Green + Write-Host "========================================" -ForegroundColor Green + + $pngquantPath = Get-Command pngquant -ErrorAction SilentlyContinue + if ($pngquantPath) { + Write-Host "安装路径: $($pngquantPath.Source)" -ForegroundColor Cyan + } + + Write-Host "" + Write-Host "验证安装:" -ForegroundColor Cyan + pngquant --version +} else { + Write-Host "" + Write-Host "pngquant 安装失败。" -ForegroundColor Red + Write-Host "请尝试手动安装: scoop install pngquant" -ForegroundColor Yellow + exit 1 +} diff --git a/pyproject.toml b/pyproject.toml index 4b67f5a..4eb0eef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,7 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + [project] name = "pptopic" version = "0.3.0" @@ -16,9 +20,47 @@ dependencies = [ "typer>=0.21.2", ] +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-cov>=4.0.0", + "pytest-mock>=3.10.0", +] + [project.scripts] pptopic = "pptopic:main" -[build-system] -requires = ["uv_build>=0.9.18,<0.10.0"] -build-backend = "uv_build" +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "-ra", + "-q", + "--strict-markers", + "--strict-config", +] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "integration: marks tests as integration tests", + "unit: marks tests as unit tests", +] + +[tool.coverage.run] +source = ["src/pptopic"] +omit = [ + "*/tests/*", + "*/test_*.py", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", + "@abstractmethod", +] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..71fa3f5 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,16 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "-ra", + "-q", + "--strict-markers", + "--strict-config", +] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "integration: marks tests as integration tests", + "unit: marks tests as unit tests", +] diff --git a/src/pptopic/lib.py b/src/pptopic/lib.py index a5aeecf..8d4cc9a 100644 --- a/src/pptopic/lib.py +++ b/src/pptopic/lib.py @@ -22,17 +22,17 @@ class convertPPT: """ TTYPES = {"JPG": 17, "PNG": 18, "PDF": 32, "XPS": 33} - __all__ = ["savetype", "trans", "close", "open"] def __init__(self, file: str | Path, trans: str = "JPG") -> None: if sys.platform != "win32": raise SystemError("Only support Windows system.") - if not Path(file).exists(): - raise FileNotFoundError("File not found! Please check the file path.") - if trans.upper() not in convertPPT.TTYPES.keys(): - raise ValueError("Save type is not supported.") self.__file = Path(file) - self.__saveType = (convertPPT.TTYPES[trans.upper()], trans.upper()) + if not self.__file.exists(): + raise FileNotFoundError("File not found! Please check the file path.") + trans_upper = trans.upper() + if trans_upper not in convertPPT.TTYPES: + raise ValueError("Save type is not supported.") + self.__saveType = (convertPPT.TTYPES[trans_upper], trans_upper) self.__inUsing = wcc.Dispatch("PowerPoint.Application") def __enter__(self) -> Self: @@ -50,22 +50,20 @@ class convertPPT: return cls(file, trans=trans.upper()) def saveAs(self, saveType: str | None = None) -> None: - if saveType not in convertPPT.TTYPES.keys(): - raise ValueError("Save type is not supported.") - if saveType is not None: - self.__saveType = (convertPPT.TTYPES[saveType.upper()], saveType.upper()) - else: + if saveType is None: self.__saveType = (convertPPT.TTYPES["JPG"], "JPG") + else: + saveType_upper = saveType.upper() + if saveType_upper not in convertPPT.TTYPES: + raise ValueError("Save type is not supported.") + self.__saveType = (convertPPT.TTYPES[saveType_upper], saveType_upper) def trans(self, saveto: str | Path = ".", width: int | None = None) -> None: """ saveto : 保存路径,默认为当前路径。 """ ppt = self.__inUsing.Presentations.Open(self.__file.absolute()) - if Path(saveto) == Path("."): - output = self.__file.absolute() - else: - output = Path(saveto).absolute() + output = Path(saveto).absolute() if width is not None: ppt.Export(output, self.__saveType[1], width) else: @@ -85,40 +83,44 @@ def PPT_longPic( (700; "22.1%") 指定为None的时候,不进行图像缩放。 """ + pptFile = Path(pptFile) if saveName: - sType = saveName.split(".")[-1] if "." in saveName else "JPG" - if sType.upper() not in ["JPG", "PNG"]: + sType = Path(saveName).suffix[1:].upper() if Path(saveName).suffix else "JPG" + if sType not in ["JPG", "PNG"]: raise ValueError(f"Unable to save this type `{sType}` of image.") else: sType = "JPG" + with TemporaryDirectory() as tmpdirname: with convertPPT(pptFile, trans=sType) as ppt: ppt.trans(saveto=tmpdirname) - picList = list(Path(tmpdirname).glob(f"*.{sType}")) + picList = sorted(Path(tmpdirname).glob(f"*.{sType}")) + if not picList: + raise ValueError("No images generated from PPT conversion.") + with Image.open(picList[0]) as img: if isinstance(width, str): - qw = float(width[:-1]) / 100.0 + qw = float(width.rstrip("%")) / 100.0 nwidth, nheight = (int(img.width * qw), int(img.height * qw)) elif width is None: nwidth, nheight = img.size else: nwidth, nheight = (width, int(img.height * width / img.width)) canvas = Image.new(img.mode, (nwidth, nheight * len(picList))) - for i in range(1, len(picList) + 1): - with Image.open(Path(tmpdirname).joinpath(f"幻灯片{i}.{sType}")) as img: + + for i, picPath in enumerate(picList, 1): + with Image.open(picPath) as img: new_img = img.resize((nwidth, nheight), resample=Image.Resampling.LANCZOS) canvas.paste(new_img, box=(0, (i - 1) * nheight)) - if Path(saveto) == Path("."): - filepath = Path(pptFile).parent - else: - filepath = Path(saveto) + + filepath = pptFile.parent if Path(saveto) == Path(".") else Path(saveto) if saveName: - if "." in saveName: - canvas.save(filepath.joinpath(saveName)) + if Path(saveName).suffix: + canvas.save(filepath / saveName) else: - canvas.save(filepath.joinpath(saveName), format=sType) + canvas.save(filepath / saveName, format=sType) else: - canvas.save(filepath.joinpath(Path(pptFile).stem), format=sType) + canvas.save(filepath / pptFile.name, format=sType) def imageOptimization( @@ -136,40 +138,48 @@ def imageOptimization( if isinstance(imageFile, str | Path): imageFile = Image.open(imageFile) img = cv2.cvtColor(np.array(imageFile), cv2.COLOR_RGB2BGR) + if max_width or max_height: height, width, _ = img.shape - oShape = (width, height) + new_width, new_height = width, height + if max_width: - if width > max_width: - height = int(height * max_width / width) - width = max_width if max_width < 1: - width = int(width * max_width) - height = int(height * max_width) + new_width = int(width * max_width) + new_height = int(height * max_width) + elif width > max_width: + new_width = max_width + new_height = int(height * max_width / width) + if max_height: - if height > max_height: - width = int(width * max_height / height) - height = max_height if max_height < 1: - if width < oShape[0]: - if height / oShape[1] > max_height: - height = int(oShape[1] * max_height) - width = int(oShape[0] * max_height) + if new_width < width: + if new_height / height > max_height: + new_height = int(height * max_height) + new_width = int(width * max_height) else: - height = int(height * max_height) - width = int(width * max_height) - img = cv2.resize(img, (width, height)) + new_height = int(new_height * max_height) + new_width = int(new_width * max_height) + elif new_height > max_height: + new_width = int(new_width * max_height / new_height) + new_height = max_height + + img = cv2.resize(img, (new_width, new_height)) + res = Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB)) with TemporaryDirectory(prefix="pytoolsz") as tmpFolder: - res.save(Path(tmpFolder) / "tmp.png", compress_level=2, quality=100) + tmpPath = Path(tmpFolder) / "tmp.png" + res.save(tmpPath, compress_level=2, quality=100) + if engine: try: - subprocess.run([engine, engine_conf, Path(tmpFolder) / "tmp.png"], shell=True, check=True) + subprocess.run([engine, engine_conf, tmpPath], shell=True, check=True) except Exception as e: print("未安装pngquant,不能进行图片优化压缩。\n可使用`scoop install pngquant`进行安装。") raise e else: - res.save(Path(tmpFolder) / "tmp.png", compress_level=7, quality=95) + res.save(tmpPath, compress_level=7, quality=95) + if saveFile is None: res = Image.open(lastFile(Path(tmpFolder), "*.*")) return res diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..403d5dc --- /dev/null +++ b/tests/README.md @@ -0,0 +1,49 @@ +# 测试 + +此目录包含 pptopic 库的测试文件。 + +## 运行测试 + +运行所有测试: + +```bash +pytest +``` + +运行测试并生成覆盖率报告: + +```bash +pytest --cov=pptopic --cov-report=html +``` + +运行特定测试文件: + +```bash +pytest tests/test_convert.py +``` + +运行特定测试类: + +```bash +pytest tests/test_convert.py::TestConvertPPTClass +``` + +运行特定测试函数: + +```bash +pytest tests/test_convert.py::TestConvertPPTClass::test_init_with_valid_file +``` + +## 测试结构 + +- `test_convert.py` - `convertPPT` 类的测试 +- `test_longpic.py` - `PPT_longPic` 函数的测试 +- `test_optimization.py` - `imageOptimization` 函数的测试 + +## 测试标记 + +测试可以使用以下标记: + +- `slow` - 慢速测试(可以使用 `-m "not slow"` 跳过) +- `integration` - 集成测试 +- `unit` - 单元测试 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..eecac51 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +"""Test suite for pptopic library.""" + +__version__ = "0.1.0" diff --git a/tests/test_convert.py b/tests/test_convert.py new file mode 100644 index 0000000..92882d8 --- /dev/null +++ b/tests/test_convert.py @@ -0,0 +1,150 @@ +"""Tests for convertPPT class.""" + +import pytest +from pathlib import Path +from unittest.mock import Mock, MagicMock, patch +from pptopic.lib import convertPPT + + +class TestConvertPPTClass: + """Test convertPPT class initialization and properties.""" + + 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 + + @patch('pptopic.lib.sys.platform', 'win32') + def test_init_with_valid_file(self, tmp_path): + """Test initialization with a valid file path.""" + test_file = tmp_path / "test.pptx" + test_file.touch() + + with patch('pptopic.lib.wcc.Dispatch') as mock_dispatch: + mock_app = MagicMock() + mock_dispatch.return_value = mock_app + + 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.""" + 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.""" + 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.""" + test_file = tmp_path / "test.pptx" + test_file.touch() + + 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.""" + 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.""" + test_file = tmp_path / "test.pptx" + test_file.touch() + + with patch('pptopic.lib.wcc.Dispatch') as mock_dispatch: + mock_app = MagicMock() + mock_dispatch.return_value = mock_app + + with convertPPT(test_file) as ppt: + assert ppt.savetype == "JPG" + + 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.""" + test_file = tmp_path / "test.pptx" + test_file.touch() + + with patch('pptopic.lib.wcc.Dispatch'): + ppt = convertPPT(test_file, trans="PNG") + ppt.saveAs() + assert ppt.savetype == "JPG" + + @patch('pptopic.lib.sys.platform', 'win32') + def test_saveas_with_valid_type(self, tmp_path): + """Test saveAs with valid save type.""" + 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 pytest.raises(ValueError, match="Save type is not supported"): + ppt.saveAs("BMP") + + +class TestConvertPTTOpenClassmethod: + """Test open class method.""" + + @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'): + ppt = convertPPT.open(test_file, trans="PNG") + assert ppt.savetype == "PNG" + assert isinstance(ppt, convertPPT) diff --git a/tests/test_longpic.py b/tests/test_longpic.py new file mode 100644 index 0000000..a6a3d76 --- /dev/null +++ b/tests/test_longpic.py @@ -0,0 +1,244 @@ +"""Tests for PPT_longPic function.""" + +import pytest +from pathlib import Path +from unittest.mock import Mock, MagicMock, patch +from PIL import Image +from pptopic.lib import PPT_longPic + + +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() + + 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, saveName="output.jpg", 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() + + 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=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.""" + 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.""" + 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', 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() + + 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_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() diff --git a/tests/test_optimization.py b/tests/test_optimization.py new file mode 100644 index 0000000..b62da5b --- /dev/null +++ b/tests/test_optimization.py @@ -0,0 +1,227 @@ +"""Tests for imageOptimization function.""" + +import pytest +from pathlib import Path +from unittest.mock import Mock, MagicMock, patch +from PIL import Image +import numpy as np +from pptopic.lib import imageOptimization + + +class TestImageOptimization: + """Test 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.""" + mock_img = MagicMock() + mock_img.size = (100, 100) + 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_pil_img = MagicMock() + 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.""" + mock_img = MagicMock() + mock_img.size = (100, 100) + 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_pil_img = MagicMock() + 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.""" + mock_img = MagicMock() + mock_img.size = (100, 100) + 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_pil_img = MagicMock() + 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.""" + mock_img = MagicMock() + mock_img.size = (200, 100) + 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_pil_img = MagicMock() + 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.""" + mock_img = MagicMock() + mock_img.size = (100, 200) + 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_pil_img = MagicMock() + 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).""" + mock_img = MagicMock() + mock_img.size = (200, 100) + 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_pil_img = MagicMock() + 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.""" + mock_img = MagicMock() + mock_img.size = (100, 100) + mock_img.mode = 'RGB' + mock_open.return_value = mock_img + + mock_array = np.array([[[255, 0, 0]]]) + mock_cvtcolor.return_value = mock_array + + 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): + 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.""" + mock_img = MagicMock() + mock_img.size = (100, 100) + mock_img.mode = 'RGB' + mock_open.return_value = mock_img + + mock_array = np.array([[[255, 0, 0]]]) + mock_cvtcolor.return_value = mock_array + + mock_subprocess.side_effect = Exception("Engine not found") + + mock_pil_img = MagicMock() + with patch('pptopic.lib.Image.fromarray', return_value=mock_pil_img): + with pytest.raises(Exception): + imageOptimization("test.png", engine="pngquant", engine_conf="--quality=85")