diff --git a/README.md b/README.md index e69de29..4f69e67 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,86 @@ +# 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 f49979d..f4ded6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,7 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + [project] name = "pptopic" version = "0.1.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 f290c04..b7438d0 100644 --- a/src/pptopic/lib.py +++ b/src/pptopic/lib.py @@ -24,101 +24,109 @@ class convertPPT(object): "PDF" : 32, "XPS" : 33 } - __all__ = ["savetype", "trans", "close", "open"] - def __init__(self, file:str|Path, trans:str = "JPG") -> None: + 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: return self + def __exit__(self, exc_type, exc_value, traceback) -> None: self.close() + @property - def savetype(self) -> str : + def savetype(self) -> str: return self.__saveType[1] + @classmethod - def open(cls, file:str|Path, trans:str = "JPG") -> Self : - 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: + def open(cls, file: str | Path, trans: str = "JPG") -> Self: + return cls(file, trans=trans.upper()) + + def saveAs(self, saveType: str | None = None) -> None: + if saveType is None: self.__saveType = (convertPPT.TTYPES["JPG"], "JPG") - def trans(self, saveto:str|Path = ".", - width:int|None = None) -> None : + 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() - if width is not None : + output = Path(saveto).absolute() + if width is not None: ppt.Export(output, self.__saveType[1], width) else: ppt.SaveAs(output, self.__saveType[0]) - def close(self, to_console:bool = False) -> None : + + def close(self, to_console: bool = False) -> None: self.__inUsing.Quit() - if to_console : + if to_console: print("File converted successfully.") -def PPT_longPic(pptFile:str|Path, saveName:str|None = None, width:int|str|None = None, - saveto:str|Path = ".") -> None : +def PPT_longPic(pptFile: str | Path, saveName: str | None = None, width: int | str | None = None, + saveto: str | Path = ".") -> None: """ width : 画幅宽度,可以直接指定宽度像素,也可以使用字符串数据输入百分比。 (700; "22.1%") 指定为None的时候,不进行图像缩放。 """ - if saveName : - sType = saveName.split(".")[-1] if "." in saveName else "JPG" - if sType.upper() not in ["JPG", "PNG"] : + pptFile = Path(pptFile) + if saveName: + sType = Path(saveName).suffix[1:].upper() if Path(saveName).suffix else "JPG" + if sType not in ["JPG", "PNG"]: raise ValueError("Unable to save this type `{}` of image.".format(sType)) else: sType = "JPG" + with TemporaryDirectory() as tmpdirname: with convertPPT(pptFile, trans=sType) as ppt: ppt.trans(saveto=tmpdirname) - picList = list(Path(tmpdirname).glob("*.{}".format(sType))) - with Image.open(picList[0]) as img : + picList = sorted(Path(tmpdirname).glob("*.{}".format(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 - nwidth, nheight = (int(img.width*qw), int(img.height*qw)) + 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)) + 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("幻灯片{}.{}".format(i,sType))) 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) - if saveName : - if '.' in saveName : - canvas.save(filepath.joinpath(saveName)) - else: - canvas.save(filepath.joinpath(saveName), format=sType) - else: - canvas.save(filepath.joinpath(Path(pptFile).stem), format=sType) -def imageOptimization(imageFile:str|Path|Image.Image, saveFile:str|Path|None = None, - max_width:int = None, max_height:int = None, - engine:str|None = "pngquant", - engine_conf:str|None = None) -> Image.Image|None : + 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)) + + filepath = pptFile.parent if Path(saveto) == Path('.') else Path(saveto) + if saveName: + if Path(saveName).suffix: + canvas.save(filepath / saveName) + else: + canvas.save(filepath / saveName, format=sType) + else: + canvas.save(filepath / pptFile.name, format=sType) + +def imageOptimization(imageFile: str | Path | Image.Image, saveFile: str | Path | None = None, + max_width: int = None, max_height: int = None, + engine: str | None = "pngquant", + engine_conf: str | None = None) -> Image.Image | None: """图片优化、无损压缩 默认建议使用pngquant进行无损压缩,也可以设置为其他图片无损压缩引擎, 不需要针对性压缩,可设定engine为None。 @@ -126,42 +134,50 @@ def imageOptimization(imageFile:str|Path|Image.Image, saveFile:str|Path|None = N if isinstance(imageFile, (str, Path)): imageFile = Image.open(imageFile) img = cv2.cvtColor(np.array(imageFile), cv2.COLOR_RGB2BGR) - if max_width or max_height : + + if max_width or max_height: height, width, _ = img.shape - oShape = (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) - 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) + new_width, new_height = width, height + + if max_width: + if max_width < 1: + 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 max_height < 1: + 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) - if engine : + with TemporaryDirectory(prefix="pytoolsz") as tmpFolder: + 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) - if saveFile is None : + res.save(tmpPath, compress_level=7, quality=95) + + if saveFile is None: res = Image.open(lastFile(Path(tmpFolder), "*.*")) return res - else : + else: shutil.copyfile(lastFile(Path(tmpFolder), "*.*"), saveFile) \ No newline at end of file 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")