Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
fd6b740a35
|
|||
|
04ef9b65a5
|
|||
|
cba7f9fb55
|
|||
|
97b30ace84
|
|||
|
ffcb2801b2
|
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(uv run *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
176
README.md
176
README.md
@@ -17,26 +17,102 @@
|
|||||||
|
|
||||||
## 新手使用说明
|
## 新手使用说明
|
||||||
|
|
||||||
如果你是第一次使用命令行工具,请按以下步骤操作。
|
如果你是第一次使用命令行工具,请按以下步骤操作。我们提供了一键安装脚本,尽量让你少输入命令。
|
||||||
|
|
||||||
### 准备工作
|
### 安装前准备
|
||||||
|
|
||||||
- **PowerPoint**:需要安装 Microsoft PowerPoint(Office 2016 或更新版本)
|
- **PowerPoint**:需要安装 Microsoft PowerPoint(Office 2016 或更新版本)
|
||||||
- **网络连接**:安装过程中需要下载工具
|
- **网络连接**:安装过程中需要下载工具
|
||||||
- **理解命令行工具**:你需要理解命令行的基本操作,包括文件路径、参数传递等。
|
- **解压项目**:把下载的 `pptopic.zip` 解压到任意位置,例如桌面
|
||||||
|
|
||||||
### PowerShell的基本知识
|
### 一键安装(推荐)
|
||||||
|
|
||||||
PowerShell在Windows中是自带的,所以一般情况下无需额外安装。如果你不知道如何打开,可以在开始菜单搜索 "PowerShell",通常就能看见,点击后即可打开。
|
项目根目录下有一个 `setup.bat`,它会自动完成:
|
||||||
|
|
||||||
在PowerShell中跳转目录,只需要输入 `cd` 加上目录路径即可。目录路径可以从资源管理器中在地址栏中复制。如果你的目录路径中包含空格,需要在路径中添加引号。
|
1. 安装 `uv`(Python 包管理器)
|
||||||
|
2. 安装 Python 3.13
|
||||||
|
3. 安装 `pngquant`(图片压缩工具,强烈推荐)
|
||||||
|
4. 安装 `pptopic`
|
||||||
|
5. 验证安装结果
|
||||||
|
|
||||||
|
#### 方法 A:双击运行(最简单)
|
||||||
|
|
||||||
|
1. 打开解压后的 `pptopic` 文件夹
|
||||||
|
2. 找到 `setup.bat` 文件
|
||||||
|
3. **双击** `setup.bat`
|
||||||
|
4. 等待安装完成,窗口会显示版本信息
|
||||||
|
|
||||||
|
> 如果 Windows 提示“Windows 已保护你的电脑”,请点击“更多信息” → “仍要运行”。
|
||||||
|
|
||||||
|
#### 方法 B:右键在终端中运行
|
||||||
|
|
||||||
|
如果双击运行被系统拦截,可以按以下步骤:
|
||||||
|
|
||||||
|
1. 打开解压后的 `pptopic` 文件夹
|
||||||
|
2. 在文件夹**空白处**按住 `Shift` 键,同时点击**鼠标右键**
|
||||||
|
3. 选择“在此处打开 PowerShell 窗口”(Windows 11 可能显示为“在终端中打开”)
|
||||||
|
4. 在弹出的窗口中输入以下命令,然后按回车:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# 这是一个例子
|
.\setup.bat
|
||||||
cd "C:\Users\User SomeX\Desktop\OneFolder"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 第一步:安装 uv
|
> 打开的终端可能是 PowerShell(蓝色窗口)或命令提示符 cmd(黑色窗口),两种情况下 `.\setup.bat` 都能正常运行。如果是 cmd,也可以省略前面的 `.\` 直接输入 `setup.bat`。
|
||||||
|
|
||||||
|
等待安装完成即可。
|
||||||
|
|
||||||
|
#### 安装完成后
|
||||||
|
|
||||||
|
如果看到类似下面的输出,说明安装成功:
|
||||||
|
|
||||||
|
```
|
||||||
|
--- uv 版本 ---
|
||||||
|
uv 0.11.20
|
||||||
|
|
||||||
|
--- pngquant 版本 ---
|
||||||
|
2.17.0
|
||||||
|
|
||||||
|
--- pptopic 版本 ---
|
||||||
|
pptopic version: 0.3.2
|
||||||
|
Under the MIT License.
|
||||||
|
|
||||||
|
--- PowerPoint 检测 ---
|
||||||
|
PowerPoint 已安装(检测到 PowerPoint.Application 注册项)。
|
||||||
|
```
|
||||||
|
|
||||||
|
> 如果你没有安装 PowerPoint,最后一行会显示警告,提示你先安装 PowerPoint,否则 `pptopic export` 命令无法运行。
|
||||||
|
|
||||||
|
然后你可以直接在当前窗口使用 pptopic:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 导出 PPTX 为长图
|
||||||
|
pptopic export presentation.pptx
|
||||||
|
|
||||||
|
# 工作中常用命令:导出 PPTX 为长图并优化图片
|
||||||
|
# 其中,29999 是微信接受的最大高度
|
||||||
|
pptopic export presentation.pptx --optimize --max-height 29999 -o result.png
|
||||||
|
```
|
||||||
|
|
||||||
|
### 一键安装脚本的参数(可选)
|
||||||
|
|
||||||
|
如果你不想安装 pngquant,可以使用以下方式运行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 在 PowerShell 中直接运行 setup.ps1,跳过 pngquant
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\setup.ps1 -SkipPngquant
|
||||||
|
```
|
||||||
|
|
||||||
|
其他可用参数:
|
||||||
|
|
||||||
|
- `-SkipPngquant`:跳过 pngquant 安装
|
||||||
|
- `-Force`:强制重新安装 uv 和 pngquant
|
||||||
|
- `-SkipPptopicInstall`:只安装环境,不安装 pptopic
|
||||||
|
|
||||||
|
### 手动安装(备用)
|
||||||
|
|
||||||
|
如果一键安装脚本在你的电脑上无法运行,可以按以下步骤手动安装。
|
||||||
|
|
||||||
|
#### 第一步:安装 uv
|
||||||
|
|
||||||
`uv` 是一个 Python 包管理器,用来安装 pptopic 及其依赖。
|
`uv` 是一个 Python 包管理器,用来安装 pptopic 及其依赖。
|
||||||
|
|
||||||
@@ -46,7 +122,7 @@ cd "C:\Users\User SomeX\Desktop\OneFolder"
|
|||||||
cd "<你的解压目录>\pptopic"
|
cd "<你的解压目录>\pptopic"
|
||||||
```
|
```
|
||||||
|
|
||||||
赋予PowerShell执行权限:
|
赋予 PowerShell 执行权限:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Set-ExecutionPolicy Bypass -Scope CurrentUser
|
Set-ExecutionPolicy Bypass -Scope CurrentUser
|
||||||
@@ -70,7 +146,7 @@ uv --version
|
|||||||
> - 如果你已安装 [scoop](https://scoop.sh/),也可以直接 `scoop install uv`。
|
> - 如果你已安装 [scoop](https://scoop.sh/),也可以直接 `scoop install uv`。
|
||||||
> - 如果想安装特定版本的 uv,可以在运行脚本前设置环境变量:`$env:UV_INSTALLER_VERSION = "0.11.20"`
|
> - 如果想安装特定版本的 uv,可以在运行脚本前设置环境变量:`$env:UV_INSTALLER_VERSION = "0.11.20"`
|
||||||
|
|
||||||
### 第二步:安装 Python
|
#### 第二步:安装 Python
|
||||||
|
|
||||||
使用 uv 安装 Python 3.13 或更新版本:
|
使用 uv 安装 Python 3.13 或更新版本:
|
||||||
|
|
||||||
@@ -78,9 +154,9 @@ uv --version
|
|||||||
uv python install 3.13
|
uv python install 3.13
|
||||||
```
|
```
|
||||||
|
|
||||||
### 第三步(可选):安装 pngquant 图片优化工具
|
#### 第三步(可选):安装 pngquant 图片优化工具
|
||||||
|
|
||||||
虽然是可选安装,但我超级建议你安装,因为ppt导出后,图片通常较大,不进行图片无损压缩,会导致文件大小过大。
|
虽然是可选安装,但我超级建议你安装,因为 ppt 导出后,图片通常较大,不进行图片无损压缩,会导致文件大小过大。
|
||||||
|
|
||||||
如果你需要对导出的图片进行压缩优化,运行:
|
如果你需要对导出的图片进行压缩优化,运行:
|
||||||
|
|
||||||
@@ -101,13 +177,13 @@ pngquant --version
|
|||||||
> - 自定义安装目录:`.\install-pngquant.ps1 -InstallDir "D:\Tools\pngquant"`
|
> - 自定义安装目录:`.\install-pngquant.ps1 -InstallDir "D:\Tools\pngquant"`
|
||||||
> - 强制重新安装:`.\install-pngquant.ps1 -Force`
|
> - 强制重新安装:`.\install-pngquant.ps1 -Force`
|
||||||
|
|
||||||
### 第四步:安装pptopic
|
#### 第四步:安装 pptopic
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
uv tool install -e .
|
uv tool install -e .
|
||||||
```
|
```
|
||||||
|
|
||||||
### 第六步:开始使用
|
#### 第五步:开始使用
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# 导出 PPTX 为长图
|
# 导出 PPTX 为长图
|
||||||
@@ -117,17 +193,27 @@ pptopic export presentation.pptx
|
|||||||
pptopic optimize image.png
|
pptopic optimize image.png
|
||||||
|
|
||||||
# 工作中常用命令:导出 PPTX 为长图并优化图片
|
# 工作中常用命令:导出 PPTX 为长图并优化图片
|
||||||
# 其中,29999是微信接受的最大高度
|
# 其中,29999 是微信接受的最大高度
|
||||||
pptopic export presentation.pptx --optimize --max-height 29999 -o result.png
|
pptopic export presentation.pptx --optimize --max-height 29999 -o result.png
|
||||||
```
|
```
|
||||||
|
|
||||||
至此安装完成。如果任何步骤遇到问题,请参考下方详细说明。
|
至此安装完成。
|
||||||
|
|
||||||
## 一般安装说明
|
## 一般安装说明
|
||||||
|
|
||||||
|
### 安装 pptopic
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.lyz.one/SidneyZhang/pptopic.git
|
||||||
|
cd pptopic
|
||||||
|
uv tool install -e .
|
||||||
|
```
|
||||||
|
|
||||||
### 安装 uv
|
### 安装 uv
|
||||||
|
|
||||||
使用项目自带的安装脚本(推荐):
|
项目使用 `uv` 进行安装和项目管理,如果你没有安装,这里提供一个简单的安装指引:
|
||||||
|
|
||||||
|
只是简单安装,可以使用项目自带的安装脚本(推荐):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\uv-installer.ps1
|
.\uv-installer.ps1
|
||||||
@@ -154,14 +240,6 @@ Set-ExecutionPolicy Bypass -Scope CurrentUser
|
|||||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||||
```
|
```
|
||||||
|
|
||||||
### 安装 pptopic
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://git.lyz.one/SidneyZhang/pptopic.git
|
|
||||||
cd pptopic
|
|
||||||
uv tool install -e .
|
|
||||||
```
|
|
||||||
|
|
||||||
## 使用
|
## 使用
|
||||||
|
|
||||||
### 导出 PPTX 为长图
|
### 导出 PPTX 为长图
|
||||||
@@ -184,13 +262,14 @@ pptopic optimize image.png
|
|||||||
pptopic optimize --help
|
pptopic optimize --help
|
||||||
```
|
```
|
||||||
|
|
||||||
## 图片优化
|
## 图片优化引擎
|
||||||
|
|
||||||
为了获得最佳的图片压缩效果,推荐使用 [pngquant](https://pngquant.org/) 进行图片压缩。
|
为了获得最佳的图片压缩效果,推荐使用 [pngquant](https://pngquant.org/) 进行图片压缩。
|
||||||
|
当然也可以使用其他图片压缩工具,如 [libvips](https://www.libvips.org/) 等,不过需要自己设置引擎的优化参数。
|
||||||
|
|
||||||
### 自动安装脚本
|
### PngQuant安装脚本
|
||||||
|
|
||||||
使用提供的 PowerShell 脚本安装 pngquant:
|
可以使用提供的 PowerShell 脚本安装 pngquant:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\install-pngquant.ps1
|
.\install-pngquant.ps1
|
||||||
@@ -212,7 +291,7 @@ pptopic optimize --help
|
|||||||
.\install-pngquant.ps1 -NoModifyPath
|
.\install-pngquant.ps1 -NoModifyPath
|
||||||
```
|
```
|
||||||
|
|
||||||
### 手动安装
|
也可以直接使用 scoop 安装 pngquant,或者手动下载安装。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scoop install pngquant
|
scoop install pngquant
|
||||||
@@ -220,6 +299,43 @@ scoop install pngquant
|
|||||||
|
|
||||||
或从 [pngquant.org](https://pngquant.org/) 下载 Windows 版本手动配置。
|
或从 [pngquant.org](https://pngquant.org/) 下载 Windows 版本手动配置。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 运行 `setup.bat` 时提示“无法加载文件,因为在此系统上禁止运行脚本”
|
||||||
|
|
||||||
|
这说明当前电脑通过组策略严格限制了脚本执行。`setup.bat` 已经尝试用 `-ExecutionPolicy Bypass` 绕过,但仍可能被拦截。
|
||||||
|
|
||||||
|
解决方法:使用 **方法 B**,在 PowerShell 窗口中手动运行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\setup.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
不需要以管理员身份运行,本安装脚本只修改当前用户的环境变量。
|
||||||
|
|
||||||
|
### 安装完成后关闭窗口,新窗口中找不到 `pptopic` 命令
|
||||||
|
|
||||||
|
请重新运行一次 `setup.bat`。如果问题依旧,请检查系统环境变量中的 PATH 是否包含以下目录:
|
||||||
|
|
||||||
|
- `%USERPROFILE%\.local\bin`(uv 和 pptopic 的位置)
|
||||||
|
- `%APPDATA%\pngquant`(pngquant 的位置)
|
||||||
|
|
||||||
|
### 不想安装 pngquant 怎么办?
|
||||||
|
|
||||||
|
可以运行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\setup.ps1 -SkipPngquant
|
||||||
|
```
|
||||||
|
|
||||||
|
但强烈建议安装,否则导出的长图文件会比较大。
|
||||||
|
|
||||||
|
### 安装时报“未检测到 PowerPoint”
|
||||||
|
|
||||||
|
安装脚本会在最后一步检测 PowerPoint 是否安装。如果看到这个警告,说明系统里没有 PowerPoint,`pptopic export` 命令将无法运行(它需要通过 PowerPoint 导出幻灯片)。
|
||||||
|
|
||||||
|
请安装 Microsoft PowerPoint(Office 2016 或更新版本)后重试。PowerPoint 不在一键安装脚本的自动安装范围内,需要自行安装。
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pptopic"
|
name = "pptopic"
|
||||||
version = "0.3.1"
|
version = "0.3.2"
|
||||||
description = "导出长图"
|
description = "导出长图"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
@@ -18,6 +18,7 @@ dependencies = [
|
|||||||
"pywin32>=311",
|
"pywin32>=311",
|
||||||
"simtoolsz>=0.2.12.3",
|
"simtoolsz>=0.2.12.3",
|
||||||
"typer>=0.21.2",
|
"typer>=0.21.2",
|
||||||
|
"yaspin>=3.4.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@@ -67,4 +68,4 @@ exclude_lines = [
|
|||||||
|
|
||||||
[[tool.uv.index]]
|
[[tool.uv.index]]
|
||||||
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||||
default = true
|
default = true
|
||||||
|
|||||||
24
setup.bat
Normal file
24
setup.bat
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul
|
||||||
|
echo.
|
||||||
|
echo ========================================
|
||||||
|
echo pptopic 一键安装程序
|
||||||
|
echo ========================================
|
||||||
|
echo.
|
||||||
|
echo 即将自动安装:uv、Python 3.13、pngquant 和 pptopic
|
||||||
|
echo 安装过程中请勿关闭此窗口...
|
||||||
|
echo.
|
||||||
|
|
||||||
|
powershell -ExecutionPolicy Bypass -File "%~dp0setup.ps1" %*
|
||||||
|
|
||||||
|
if %errorlevel% neq 0 (
|
||||||
|
echo.
|
||||||
|
echo 安装过程中出现错误,请查看上方提示。
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
exit /b %errorlevel%
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo 按任意键关闭此窗口...
|
||||||
|
pause >nul
|
||||||
359
setup.ps1
Normal file
359
setup.ps1
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
pptopic 一键安装脚本
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
自动完成以下安装步骤:
|
||||||
|
1. 安装 uv(Python 包管理器)
|
||||||
|
2. 安装 Python 3.13
|
||||||
|
3. 安装 pngquant(可选但推荐)
|
||||||
|
4. 安装 pptopic
|
||||||
|
5. 验证安装结果
|
||||||
|
|
||||||
|
本脚本会自动处理 PowerShell 执行策略和当前会话 PATH 刷新,
|
||||||
|
安装完成后无需手动重启 PowerShell。
|
||||||
|
|
||||||
|
.PARAMETER SkipPngquant
|
||||||
|
跳过 pngquant 安装。
|
||||||
|
|
||||||
|
.PARAMETER Force
|
||||||
|
强制重新安装 uv 和 pngquant。
|
||||||
|
|
||||||
|
.PARAMETER SkipPptopicInstall
|
||||||
|
只安装环境,不安装 pptopic(调试用)。
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\setup.ps1
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\setup.ps1 -SkipPngquant
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[switch]$SkipPngquant,
|
||||||
|
[switch]$Force,
|
||||||
|
[switch]$SkipPptopicInstall
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$InformationPreference = "Continue"
|
||||||
|
|
||||||
|
$ProjectRoot = $PSScriptRoot
|
||||||
|
if ([string]::IsNullOrWhiteSpace($ProjectRoot)) {
|
||||||
|
$ProjectRoot = (Get-Location).Path
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 工具函数
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
function Test-CommandExists {
|
||||||
|
param([string]$Name)
|
||||||
|
try {
|
||||||
|
$cmd = Get-Command $Name -ErrorAction Stop
|
||||||
|
return $cmd.Source
|
||||||
|
} catch {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Add-ToCurrentPath {
|
||||||
|
param([string]$LiteralPath)
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($LiteralPath)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentPaths = $env:Path -split ';' | ForEach-Object { $_.TrimEnd('\') }
|
||||||
|
$normalizedPath = $LiteralPath.TrimEnd('\')
|
||||||
|
|
||||||
|
if ($normalizedPath -in $currentPaths) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path $LiteralPath)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$env:Path = "$LiteralPath;$env:Path"
|
||||||
|
Write-Information "已临时将 $LiteralPath 加入当前会话 PATH"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-UvInstallDir {
|
||||||
|
# uv-installer.ps1 的安装位置优先级
|
||||||
|
$candidates = @()
|
||||||
|
if ($env:XDG_BIN_HOME) {
|
||||||
|
$candidates += $env:XDG_BIN_HOME
|
||||||
|
}
|
||||||
|
if ($env:XDG_DATA_HOME) {
|
||||||
|
$candidates += (Join-Path $env:XDG_DATA_HOME "../bin")
|
||||||
|
}
|
||||||
|
$candidates += (Join-Path $HOME ".local\bin")
|
||||||
|
|
||||||
|
foreach ($candidate in $candidates) {
|
||||||
|
$resolved = $null
|
||||||
|
try {
|
||||||
|
$resolved = (Resolve-Path $candidate -ErrorAction SilentlyContinue).Path
|
||||||
|
} catch {
|
||||||
|
$resolved = $candidate
|
||||||
|
}
|
||||||
|
if ($resolved -and (Test-Path $resolved)) {
|
||||||
|
return $resolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 默认返回最后一个候选
|
||||||
|
return Join-Path $HOME ".local\bin"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-NativeCommand {
|
||||||
|
# 安全地调用原生程序(uv/pngquant 等)。
|
||||||
|
# 这些程序的进度/提示信息常写到 stderr(例如 "Python 3.13 is already installed"),
|
||||||
|
# 而本脚本 $ErrorActionPreference="Stop" 会把 stderr 当作终止错误抛出,导致重跑误失败。
|
||||||
|
# 此函数临时关闭 Stop,合并 stdout/stderr,并按退出码判断成败。
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$LiteralCommand,
|
||||||
|
[string]$FailureMessage
|
||||||
|
)
|
||||||
|
|
||||||
|
$prevEAP = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
try {
|
||||||
|
$output = Invoke-Expression "$LiteralCommand 2>&1"
|
||||||
|
} finally {
|
||||||
|
$ErrorActionPreference = $prevEAP
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
$msg = if ($FailureMessage) { $FailureMessage } else { "命令失败" }
|
||||||
|
throw "$msg(退出码 $LASTEXITCODE):$($output -join "`n")"
|
||||||
|
}
|
||||||
|
|
||||||
|
return $output
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-PowerPointInstalled {
|
||||||
|
# 通过注册表 ProgID 检测,不启动 PowerPoint 进程
|
||||||
|
try {
|
||||||
|
$null = Get-Item "Registry::HKEY_CLASSES_ROOT\PowerPoint.Application" -ErrorAction Stop
|
||||||
|
return $true
|
||||||
|
} catch {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-PngquantInstallDir {
|
||||||
|
# pngquant 可能的安装位置:默认 %APPDATA%\pngquant,或 scoop 的 shims 目录
|
||||||
|
$candidates = @("$env:APPDATA\pngquant")
|
||||||
|
if ($env:SCOOP) {
|
||||||
|
$candidates += (Join-Path $env:SCOOP "shims")
|
||||||
|
}
|
||||||
|
$candidates += (Join-Path $HOME "scoop\shims")
|
||||||
|
|
||||||
|
foreach ($candidate in $candidates) {
|
||||||
|
if (Test-Path $candidate) {
|
||||||
|
return $candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 找不到就返回默认(即便不存在,Add-ToCurrentPath 内部会跳过)
|
||||||
|
return "$env:APPDATA\pngquant"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Step {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$Title,
|
||||||
|
[Parameter(Mandatory)][scriptblock]$Action
|
||||||
|
)
|
||||||
|
|
||||||
|
Write-Information ""
|
||||||
|
Write-Information "========================================"
|
||||||
|
Write-Information " $Title"
|
||||||
|
Write-Information "========================================"
|
||||||
|
|
||||||
|
try {
|
||||||
|
& $Action
|
||||||
|
} catch {
|
||||||
|
Write-Information ""
|
||||||
|
Write-Information "失败:$_"
|
||||||
|
throw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 主流程
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
Write-Information ""
|
||||||
|
Write-Information "========================================"
|
||||||
|
Write-Information " pptopic 一键安装程序"
|
||||||
|
Write-Information "========================================"
|
||||||
|
Write-Information ""
|
||||||
|
Write-Information "项目目录:$ProjectRoot"
|
||||||
|
|
||||||
|
if ($SkipPngquant) {
|
||||||
|
Write-Information "已指定 -SkipPngquant,将跳过 pngquant 安装。"
|
||||||
|
}
|
||||||
|
if ($Force) {
|
||||||
|
Write-Information "已指定 -Force,将强制重新安装 uv / pngquant。"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 第一步:安装 uv
|
||||||
|
Invoke-Step -Title "步骤 1/5:安装 uv" -Action {
|
||||||
|
$uvPath = Test-CommandExists -Name "uv"
|
||||||
|
|
||||||
|
if ($uvPath -and -not $Force) {
|
||||||
|
Write-Information "uv 已存在:$uvPath"
|
||||||
|
} else {
|
||||||
|
$installer = Join-Path $ProjectRoot "uv-installer.ps1"
|
||||||
|
if (-not (Test-Path $installer)) {
|
||||||
|
throw "找不到 uv 安装脚本:$installer"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Information "正在运行 uv-installer.ps1..."
|
||||||
|
& $installer
|
||||||
|
}
|
||||||
|
|
||||||
|
# uv-installer.ps1 会修改注册表 PATH,但当前会话不会立即生效,需要手动加入
|
||||||
|
$uvInstallDir = Get-UvInstallDir
|
||||||
|
Add-ToCurrentPath -LiteralPath $uvInstallDir
|
||||||
|
|
||||||
|
# 再次验证
|
||||||
|
$uvPath = Test-CommandExists -Name "uv"
|
||||||
|
if (-not $uvPath) {
|
||||||
|
throw "uv 安装后仍无法在当前会话中找到,请尝试重启终端后重试。"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Information "uv 路径:$uvPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 第二步:安装 Python 3.13
|
||||||
|
Invoke-Step -Title "步骤 2/5:安装 Python 3.13" -Action {
|
||||||
|
Write-Information "正在使用 uv 安装 Python 3.13..."
|
||||||
|
# uv 在 Python 已安装时会向 stderr 输出 "Python 3.13 is already installed"(退出码仍为 0),
|
||||||
|
# 通过 Invoke-NativeCommand 绕过 Stop 模式对 stderr 的误判。
|
||||||
|
$output = Invoke-NativeCommand -LiteralCommand "uv python install 3.13" -FailureMessage "uv python install 失败"
|
||||||
|
Write-Information ($output -join "`n")
|
||||||
|
Write-Information "Python 安装完成。"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 第三步:安装 pngquant
|
||||||
|
if (-not $SkipPngquant) {
|
||||||
|
Invoke-Step -Title "步骤 3/5:安装 pngquant" -Action {
|
||||||
|
$pngquantPath = Test-CommandExists -Name "pngquant"
|
||||||
|
|
||||||
|
if ($pngquantPath -and -not $Force) {
|
||||||
|
Write-Information "pngquant 已存在:$pngquantPath"
|
||||||
|
} else {
|
||||||
|
$installer = Join-Path $ProjectRoot "install-pngquant.ps1"
|
||||||
|
if (-not (Test-Path $installer)) {
|
||||||
|
throw "找不到 pngquant 安装脚本:$installer"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Force) {
|
||||||
|
Write-Information "正在运行 install-pngquant.ps1 -Force..."
|
||||||
|
& $installer -Force
|
||||||
|
} else {
|
||||||
|
Write-Information "正在运行 install-pngquant.ps1..."
|
||||||
|
& $installer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# install-pngquant.ps1 内部已刷新当前会话 PATH,这里再做一次保险
|
||||||
|
# 覆盖默认安装位置和 scoop 安装位置两种情况
|
||||||
|
Add-ToCurrentPath -LiteralPath (Get-PngquantInstallDir)
|
||||||
|
|
||||||
|
$pngquantPath = Test-CommandExists -Name "pngquant"
|
||||||
|
if (-not $pngquantPath) {
|
||||||
|
throw "pngquant 安装后仍无法在当前会话中找到,请尝试重启终端后重试。"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Information "pngquant 路径:$pngquantPath"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Information ""
|
||||||
|
Write-Information "========================================"
|
||||||
|
Write-Information " 步骤 3/5:跳过 pngquant 安装"
|
||||||
|
Write-Information "========================================"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 第四步:安装 pptopic
|
||||||
|
if (-not $SkipPptopicInstall) {
|
||||||
|
Invoke-Step -Title "步骤 4/5:安装 pptopic" -Action {
|
||||||
|
$pyproject = Join-Path $ProjectRoot "pyproject.toml"
|
||||||
|
if (-not (Test-Path $pyproject)) {
|
||||||
|
throw "找不到 pyproject.toml:$pyproject"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Information "正在使用 uv tool install -e $ProjectRoot 安装 pptopic..."
|
||||||
|
# uv 的进度/解析信息会写 stderr,通过 Invoke-NativeCommand 绕过 Stop 模式误判。
|
||||||
|
$output = Invoke-NativeCommand -LiteralCommand "uv tool install -e `"$ProjectRoot`"" -FailureMessage "uv tool install 失败"
|
||||||
|
Write-Information ($output -join "`n")
|
||||||
|
Write-Information "pptopic 安装完成。"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Information ""
|
||||||
|
Write-Information "========================================"
|
||||||
|
Write-Information " 步骤 4/5:跳过 pptopic 安装"
|
||||||
|
Write-Information "========================================"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 第五步:验证
|
||||||
|
Invoke-Step -Title "步骤 5/5:验证安装" -Action {
|
||||||
|
# 版本命令可能写到 stderr(部分 CLI 行为不一致),
|
||||||
|
# 在 Stop 模式下会误报失败,统一通过 Invoke-NativeCommand 安全调用。
|
||||||
|
|
||||||
|
Write-Information ""
|
||||||
|
Write-Information "--- uv 版本 ---"
|
||||||
|
$v = Invoke-NativeCommand -LiteralCommand "uv --version" -FailureMessage "uv 版本查询失败"
|
||||||
|
Write-Information ($v -join "`n")
|
||||||
|
|
||||||
|
if (-not $SkipPngquant) {
|
||||||
|
Write-Information ""
|
||||||
|
Write-Information "--- pngquant 版本 ---"
|
||||||
|
$v = Invoke-NativeCommand -LiteralCommand "pngquant --version" -FailureMessage "pngquant 版本查询失败"
|
||||||
|
Write-Information ($v -join "`n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $SkipPptopicInstall) {
|
||||||
|
Write-Information ""
|
||||||
|
Write-Information "--- pptopic 版本 ---"
|
||||||
|
$v = Invoke-NativeCommand -LiteralCommand "pptopic version" -FailureMessage "pptopic 版本查询失败"
|
||||||
|
Write-Information ($v -join "`n")
|
||||||
|
}
|
||||||
|
|
||||||
|
# PowerPoint 是运行时必需依赖(win32com 调用),但安装阶段无法自动安装,
|
||||||
|
# 这里只做检测并给出明确提示,把缺失问题前置,避免用户装完后才发现用不了。
|
||||||
|
Write-Information ""
|
||||||
|
Write-Information "--- PowerPoint 检测 ---"
|
||||||
|
if (Test-PowerPointInstalled) {
|
||||||
|
Write-Information "PowerPoint 已安装(检测到 PowerPoint.Application 注册项)。"
|
||||||
|
} else {
|
||||||
|
Write-Information "警告:未检测到 PowerPoint。"
|
||||||
|
Write-Information "pptopic 导出 PPTX 时需要调用 PowerPoint(Office 2016 或更新版本)。"
|
||||||
|
Write-Information "请先安装 PowerPoint,否则 'pptopic export' 命令将无法运行。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 完成
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
Write-Information ""
|
||||||
|
Write-Information "========================================"
|
||||||
|
Write-Information " pptopic 一键安装完成!"
|
||||||
|
Write-Information "========================================"
|
||||||
|
Write-Information ""
|
||||||
|
|
||||||
|
if (-not $SkipPptopicInstall) {
|
||||||
|
Write-Information "你可以立即在当前窗口使用以下命令:"
|
||||||
|
Write-Information " pptopic export 你的文件.pptx"
|
||||||
|
Write-Information " pptopic export 你的文件.pptx --optimize --max-height 29999 -o result.png"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Information ""
|
||||||
|
Write-Information "提示:本脚本已自动刷新当前 PowerShell 会话的 PATH,无需重启终端。"
|
||||||
|
Write-Information "如果关闭此窗口后在新窗口中找不到命令,请重新运行一次 setup.bat。"
|
||||||
|
Write-Information ""
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from pptopic.main import main
|
from pptopic.main import main
|
||||||
|
|
||||||
__version__ = "0.3.0"
|
__version__ = "0.3.2"
|
||||||
|
|
||||||
__author__ = "Sidney Zhang <zly@lyzhang.me>"
|
__author__ = "Sidney Zhang <zly@lyzhang.me>"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
from typing import Self
|
from typing import Self
|
||||||
@@ -10,8 +12,9 @@ import numpy as np
|
|||||||
import win32com.client as wcc
|
import win32com.client as wcc
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from simtoolsz.utils import lastFile
|
from simtoolsz.utils import lastFile
|
||||||
|
from yaspin import yaspin
|
||||||
|
|
||||||
__all__ = ["convertPPT", "PPT_longPic", "imageOptimization"]
|
__all__ = ["convertPPT", "PPT_longPic", "imageOptimization", "batchImageOptimization"]
|
||||||
|
|
||||||
|
|
||||||
class convertPPT:
|
class convertPPT:
|
||||||
@@ -113,7 +116,7 @@ def PPT_longPic(
|
|||||||
new_img = img.resize((nwidth, nheight), resample=Image.Resampling.LANCZOS)
|
new_img = img.resize((nwidth, nheight), resample=Image.Resampling.LANCZOS)
|
||||||
canvas.paste(new_img, box=(0, (i - 1) * nheight))
|
canvas.paste(new_img, box=(0, (i - 1) * nheight))
|
||||||
|
|
||||||
filepath = pptFile.parent if Path(saveto) == Path(".") else Path(saveto)
|
filepath = Path(saveto).resolve()
|
||||||
if saveName:
|
if saveName:
|
||||||
if Path(saveName).suffix:
|
if Path(saveName).suffix:
|
||||||
canvas.save(filepath / saveName)
|
canvas.save(filepath / saveName)
|
||||||
@@ -123,6 +126,31 @@ def PPT_longPic(
|
|||||||
canvas.save(filepath / pptFile.name, format=sType)
|
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(
|
def imageOptimization(
|
||||||
imageFile: str | Path | Image.Image,
|
imageFile: str | Path | Image.Image,
|
||||||
saveFile: str | Path | None = None,
|
saveFile: str | Path | None = None,
|
||||||
@@ -130,6 +158,7 @@ def imageOptimization(
|
|||||||
max_height: int = None,
|
max_height: int = None,
|
||||||
engine: str | None = "pngquant",
|
engine: str | None = "pngquant",
|
||||||
engine_conf: str | None = None,
|
engine_conf: str | None = None,
|
||||||
|
progress_desc: str | None = None,
|
||||||
) -> Image.Image | None:
|
) -> Image.Image | None:
|
||||||
"""图片优化、无损压缩
|
"""图片优化、无损压缩
|
||||||
默认建议使用pngquant进行无损压缩,也可以设置为其他图片无损压缩引擎,
|
默认建议使用pngquant进行无损压缩,也可以设置为其他图片无损压缩引擎,
|
||||||
@@ -173,7 +202,12 @@ def imageOptimization(
|
|||||||
|
|
||||||
if engine:
|
if engine:
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
print("未安装pngquant,不能进行图片优化压缩。\n可使用`scoop install pngquant`进行安装。")
|
print("未安装pngquant,不能进行图片优化压缩。\n可使用`scoop install pngquant`进行安装。")
|
||||||
raise e
|
raise e
|
||||||
@@ -185,3 +219,44 @@ def imageOptimization(
|
|||||||
return res
|
return res
|
||||||
else:
|
else:
|
||||||
shutil.copyfile(lastFile(Path(tmpFolder), "*.*"), saveFile)
|
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
|
import typer
|
||||||
from simtoolsz.utils import today
|
from simtoolsz.utils import today
|
||||||
|
|
||||||
from pptopic.lib import PPT_longPic, imageOptimization
|
from pptopic.lib import PPT_longPic, batchImageOptimization, imageOptimization
|
||||||
|
|
||||||
app = typer.Typer()
|
app = typer.Typer()
|
||||||
|
|
||||||
@@ -86,6 +86,45 @@ def optimize(
|
|||||||
raise typer.Exit(code=1)
|
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()
|
@app.command()
|
||||||
def version():
|
def version():
|
||||||
"""显示版本信息"""
|
"""显示版本信息"""
|
||||||
|
|||||||
@@ -1,150 +1,102 @@
|
|||||||
"""Tests for convertPPT class."""
|
"""Tests for convertPPT class."""
|
||||||
|
|
||||||
import pytest
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import Mock, MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from pptopic.lib import convertPPT
|
from pptopic.lib import convertPPT
|
||||||
|
|
||||||
|
|
||||||
class TestConvertPPTClass:
|
class TestConvertPPT:
|
||||||
"""Test convertPPT class initialization and properties."""
|
"""Test convertPPT class."""
|
||||||
|
|
||||||
def test_ttypes_mapping(self):
|
TTYPE_VALUES = [("JPG", 17), ("PNG", 18), ("PDF", 32), ("XPS", 33)]
|
||||||
"""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')
|
@pytest.mark.parametrize("ttype,expected", TTYPE_VALUES)
|
||||||
def test_init_with_valid_file(self, tmp_path):
|
def test_ttypes_mapping(self, ttype, expected):
|
||||||
"""Test initialization with a valid file path."""
|
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 = tmp_path / "test.pptx"
|
||||||
test_file.touch()
|
test_file.touch()
|
||||||
|
if as_path:
|
||||||
with patch('pptopic.lib.wcc.Dispatch') as mock_dispatch:
|
test_file = Path(test_file)
|
||||||
mock_app = MagicMock()
|
|
||||||
mock_dispatch.return_value = mock_app
|
with patch("pptopic.lib.wcc.Dispatch"):
|
||||||
|
|
||||||
ppt = convertPPT(test_file, trans="JPG")
|
ppt = convertPPT(test_file, trans="JPG")
|
||||||
assert ppt.savetype == "JPG"
|
assert ppt.savetype == "JPG"
|
||||||
mock_dispatch.assert_called_once_with('PowerPoint.Application')
|
|
||||||
|
|
||||||
@patch('pptopic.lib.sys.platform', 'win32')
|
@patch("pptopic.lib.sys.platform", "win32")
|
||||||
def test_init_with_path_object(self, tmp_path):
|
def test_init_nonexistent_file(self):
|
||||||
"""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"):
|
with pytest.raises(FileNotFoundError, match="File not found"):
|
||||||
convertPPT("nonexistent.pptx")
|
convertPPT("nonexistent.pptx")
|
||||||
|
|
||||||
@patch('pptopic.lib.sys.platform', 'win32')
|
@patch("pptopic.lib.sys.platform", "win32")
|
||||||
def test_init_with_invalid_trans_type(self, tmp_path):
|
def test_init_invalid_trans_type(self, tmp_path):
|
||||||
"""Test initialization with invalid save type raises ValueError."""
|
|
||||||
test_file = tmp_path / "test.pptx"
|
test_file = tmp_path / "test.pptx"
|
||||||
test_file.touch()
|
test_file.touch()
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Save type is not supported"):
|
with pytest.raises(ValueError, match="Save type is not supported"):
|
||||||
convertPPT(test_file, trans="BMP")
|
convertPPT(test_file, trans="BMP")
|
||||||
|
|
||||||
@patch('pptopic.lib.sys.platform', 'win32')
|
@pytest.mark.parametrize("ttype,expected", [("jpg", "JPG"), ("Pdf", "PDF")])
|
||||||
def test_init_with_case_insensitive_trans(self, tmp_path):
|
@patch("pptopic.lib.sys.platform", "win32")
|
||||||
"""Test that trans parameter is case insensitive."""
|
def test_init_case_insensitive_trans(self, ttype, expected, tmp_path):
|
||||||
test_file = tmp_path / "test.pptx"
|
test_file = tmp_path / "test.pptx"
|
||||||
test_file.touch()
|
test_file.touch()
|
||||||
|
with patch("pptopic.lib.wcc.Dispatch"):
|
||||||
with patch('pptopic.lib.wcc.Dispatch'):
|
ppt = convertPPT(test_file, trans=ttype)
|
||||||
ppt = convertPPT(test_file, trans="jpg")
|
assert ppt.savetype == expected
|
||||||
assert ppt.savetype == "JPG"
|
|
||||||
|
|
||||||
ppt = convertPPT(test_file, trans="Pdf")
|
|
||||||
assert ppt.savetype == "PDF"
|
|
||||||
|
|
||||||
|
@patch("pptopic.lib.sys.platform", "linux")
|
||||||
class TestConvertPTTNonWindows:
|
def test_init_non_windows_raises_error(self):
|
||||||
"""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"):
|
with pytest.raises(SystemError, match="Only support Windows system"):
|
||||||
convertPPT("test.pptx")
|
convertPPT("test.pptx")
|
||||||
|
|
||||||
|
@patch("pptopic.lib.sys.platform", "win32")
|
||||||
class TestConvertPPTContextManager:
|
def test_context_manager(self, tmp_path):
|
||||||
"""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 = tmp_path / "test.pptx"
|
||||||
test_file.touch()
|
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_app = MagicMock()
|
||||||
mock_dispatch.return_value = mock_app
|
mock_dispatch.return_value = mock_app
|
||||||
|
|
||||||
with convertPPT(test_file) as ppt:
|
with convertPPT(test_file) as ppt:
|
||||||
assert ppt.savetype == "JPG"
|
assert ppt.savetype == "JPG"
|
||||||
|
|
||||||
mock_app.Quit.assert_called_once()
|
mock_app.Quit.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
class TestConvertPPTSaveAs:
|
"save_as,expected",
|
||||||
"""Test saveAs method."""
|
[(None, "JPG"), ("PDF", "PDF")],
|
||||||
|
ids=["default_jpg", "explicit_pdf"],
|
||||||
@patch('pptopic.lib.sys.platform', 'win32')
|
)
|
||||||
def test_saveas_default(self, tmp_path):
|
@patch("pptopic.lib.sys.platform", "win32")
|
||||||
"""Test saveAs with default parameter."""
|
def test_saveas(self, save_as, expected, tmp_path):
|
||||||
test_file = tmp_path / "test.pptx"
|
test_file = tmp_path / "test.pptx"
|
||||||
test_file.touch()
|
test_file.touch()
|
||||||
|
with patch("pptopic.lib.wcc.Dispatch"):
|
||||||
with patch('pptopic.lib.wcc.Dispatch'):
|
|
||||||
ppt = convertPPT(test_file, trans="PNG")
|
ppt = convertPPT(test_file, trans="PNG")
|
||||||
ppt.saveAs()
|
ppt.saveAs(save_as)
|
||||||
assert ppt.savetype == "JPG"
|
assert ppt.savetype == expected
|
||||||
|
|
||||||
@patch('pptopic.lib.sys.platform', 'win32')
|
@patch("pptopic.lib.sys.platform", "win32")
|
||||||
def test_saveas_with_valid_type(self, tmp_path):
|
def test_saveas_invalid_type(self, tmp_path):
|
||||||
"""Test saveAs with valid save type."""
|
|
||||||
test_file = tmp_path / "test.pptx"
|
test_file = tmp_path / "test.pptx"
|
||||||
test_file.touch()
|
test_file.touch()
|
||||||
|
with patch("pptopic.lib.wcc.Dispatch"):
|
||||||
with patch('pptopic.lib.wcc.Dispatch'):
|
ppt = convertPPT(test_file)
|
||||||
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"):
|
with pytest.raises(ValueError, match="Save type is not supported"):
|
||||||
ppt.saveAs("BMP")
|
ppt.saveAs("BMP")
|
||||||
|
|
||||||
|
@patch("pptopic.lib.sys.platform", "win32")
|
||||||
class TestConvertPTTOpenClassmethod:
|
|
||||||
"""Test open class method."""
|
|
||||||
|
|
||||||
@patch('pptopic.lib.sys.platform', 'win32')
|
|
||||||
def test_open_classmethod(self, tmp_path):
|
def test_open_classmethod(self, tmp_path):
|
||||||
"""Test open class method creates instance."""
|
|
||||||
test_file = tmp_path / "test.pptx"
|
test_file = tmp_path / "test.pptx"
|
||||||
test_file.touch()
|
test_file.touch()
|
||||||
|
with patch("pptopic.lib.wcc.Dispatch"):
|
||||||
with patch('pptopic.lib.wcc.Dispatch'):
|
|
||||||
ppt = convertPPT.open(test_file, trans="PNG")
|
ppt = convertPPT.open(test_file, trans="PNG")
|
||||||
assert ppt.savetype == "PNG"
|
assert ppt.savetype == "PNG"
|
||||||
assert isinstance(ppt, convertPPT)
|
assert isinstance(ppt, convertPPT)
|
||||||
|
|||||||
@@ -1,244 +1,123 @@
|
|||||||
"""Tests for PPT_longPic function."""
|
"""Tests for PPT_longPic function."""
|
||||||
|
|
||||||
import pytest
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import Mock, MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
from PIL import Image
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from pptopic.lib import PPT_longPic
|
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:
|
class TestPPTLongPic:
|
||||||
"""Test PPT_longPic function."""
|
"""Test PPT_longPic function."""
|
||||||
|
|
||||||
@patch('pptopic.lib.convertPPT')
|
# -- width variations --------------------------------------------------
|
||||||
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')
|
@pytest.mark.parametrize(
|
||||||
def test_ppt_longpic_without_save_name(self, mock_convert_ppt, tmp_path):
|
"width,expected_size",
|
||||||
"""Test PPT_longPic without saveName uses default JPG."""
|
[
|
||||||
ppt_file = tmp_path / "test.pptx"
|
(None, (100, 100)), # no scaling → uses img.size
|
||||||
ppt_file.touch()
|
(50, (50, 50)), # pixel width → (w, h*w//W)
|
||||||
|
("50%", (50, 50)), # percentage → half
|
||||||
mock_ppt = MagicMock()
|
],
|
||||||
mock_convert_ppt.return_value.__enter__.return_value = mock_ppt
|
ids=["none", "pixel", "percentage"],
|
||||||
|
)
|
||||||
with patch('pptopic.lib.Path.glob') as mock_glob:
|
@patch("pptopic.lib.convertPPT")
|
||||||
mock_img_path = MagicMock()
|
def test_width_variations(self, mock_convert_ppt, width, expected_size, tmp_path):
|
||||||
mock_img_path.suffix = '.jpg'
|
"""PPT_longPic handles None / pixel / percentage width correctly."""
|
||||||
mock_glob.return_value = [mock_img_path]
|
img_paths, mock_img, mock_canvas = _make_img_mocks(tmp_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):
|
mock_convert_ppt.return_value.__enter__.return_value = MagicMock()
|
||||||
"""Test PPT_longPic with invalid image type raises ValueError."""
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
mock_canvas.save.assert_called_once()
|
||||||
|
|
||||||
|
# -- saveName / saveto variations -------------------------------------
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
mock_convert_ppt.return_value.__enter__.return_value = MagicMock()
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_canvas.save.assert_called_once()
|
||||||
|
|
||||||
|
# -- error paths -------------------------------------------------------
|
||||||
|
|
||||||
|
def test_invalid_image_type(self, tmp_path):
|
||||||
ppt_file = tmp_path / "test.pptx"
|
ppt_file = tmp_path / "test.pptx"
|
||||||
ppt_file.touch()
|
ppt_file.touch()
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Unable to save this type"):
|
with pytest.raises(ValueError, match="Unable to save this type"):
|
||||||
PPT_longPic(ppt_file, saveName="output.bmp")
|
PPT_longPic(ppt_file, saveName="output.bmp")
|
||||||
|
|
||||||
@patch('pptopic.lib.convertPPT')
|
@patch("pptopic.lib.convertPPT")
|
||||||
def test_ppt_longpic_no_images_generated(self, mock_convert_ppt, tmp_path):
|
def test_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 = tmp_path / "test.pptx"
|
||||||
ppt_file.touch()
|
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"):
|
with pytest.raises(ValueError, match="No images generated"):
|
||||||
PPT_longPic(ppt_file)
|
PPT_longPic(ppt_file)
|
||||||
|
|
||||||
@patch('pptopic.lib.convertPPT')
|
# -- multiple images ---------------------------------------------------
|
||||||
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')
|
@patch("pptopic.lib.convertPPT")
|
||||||
def test_ppt_longpic_with_pixel_width(self, mock_convert_ppt, tmp_path):
|
def test_multiple_images(self, mock_convert_ppt, tmp_path):
|
||||||
"""Test PPT_longPic with pixel width."""
|
"""PPT_longPic pastes every slide into the canvas."""
|
||||||
ppt_file = tmp_path / "test.pptx"
|
img_paths, mock_img, mock_canvas = _make_img_mocks(tmp_path, num_images=3)
|
||||||
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')
|
mock_convert_ppt.return_value.__enter__.return_value = MagicMock()
|
||||||
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')
|
with patch("pptopic.lib.Path.glob", return_value=img_paths):
|
||||||
def test_ppt_longpic_save_to_current_dir(self, mock_convert_ppt, tmp_path):
|
with patch("pptopic.lib.Image.open") as mock_open:
|
||||||
"""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
|
mock_open.return_value.__enter__.return_value = mock_img
|
||||||
|
with patch("pptopic.lib.Image.new", return_value=mock_canvas):
|
||||||
with patch('pptopic.lib.Image.new') as mock_new:
|
PPT_longPic(tmp_path / "test.pptx", saveto=tmp_path)
|
||||||
mock_canvas = MagicMock()
|
|
||||||
mock_new.return_value = mock_canvas
|
|
||||||
|
|
||||||
PPT_longPic(ppt_file, saveto=".")
|
|
||||||
|
|
||||||
mock_canvas.save.assert_called_once()
|
|
||||||
|
|
||||||
@patch('pptopic.lib.convertPPT')
|
assert mock_canvas.paste.call_count == 3
|
||||||
def test_ppt_longpic_multiple_images(self, mock_convert_ppt, tmp_path):
|
mock_canvas.save.assert_called_once()
|
||||||
"""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()
|
|
||||||
|
|||||||
@@ -1,227 +1,325 @@
|
|||||||
"""Tests for imageOptimization function."""
|
"""Tests for image optimization functions."""
|
||||||
|
|
||||||
import pytest
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import Mock, MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
from PIL import Image
|
|
||||||
import numpy as np
|
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:
|
class TestImageOptimization:
|
||||||
"""Test imageOptimization function."""
|
"""Tests for imageOptimization function."""
|
||||||
|
|
||||||
@patch('pptopic.lib.Image.open')
|
@patch("pptopic.lib.Image.open")
|
||||||
@patch('pptopic.lib.cv2.cvtColor')
|
@patch("pptopic.lib.cv2.cvtColor")
|
||||||
@patch('pptopic.lib.cv2.resize')
|
@patch("pptopic.lib.cv2.resize")
|
||||||
@patch('pptopic.lib.subprocess.run')
|
@patch("pptopic.lib.subprocess.run")
|
||||||
@patch('pptopic.lib.lastFile')
|
@patch("pptopic.lib.lastFile")
|
||||||
def test_image_optimization_with_file_path(self, mock_last_file, mock_subprocess,
|
def test_with_file_path(self, mock_last_file, mock_subprocess, mock_resize, mock_cvtcolor, mock_open, tmp_path):
|
||||||
mock_resize, mock_cvtcolor, mock_open, tmp_path):
|
"""Optimize via file path returns a PIL Image."""
|
||||||
"""Test imageOptimization with file path input."""
|
|
||||||
mock_img = MagicMock()
|
mock_img = MagicMock()
|
||||||
mock_img.size = (100, 100)
|
mock_img.size = (100, 100)
|
||||||
mock_img.mode = 'RGB'
|
mock_img.mode = "RGB"
|
||||||
mock_open.return_value = mock_img
|
mock_open.return_value = mock_img
|
||||||
|
|
||||||
mock_array = np.array([[[255, 0, 0]]])
|
mock_cvtcolor.return_value = np.array([[[255, 0, 0]]])
|
||||||
mock_cvtcolor.return_value = mock_array
|
mock_resize.return_value = np.array([[[255, 0, 0]]])
|
||||||
|
|
||||||
mock_resized = np.array([[[255, 0, 0]]])
|
|
||||||
mock_resize.return_value = mock_resized
|
|
||||||
|
|
||||||
mock_pil_img = MagicMock()
|
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 patch('pptopic.lib.Image.open', return_value=mock_img):
|
with patch("pptopic.lib.Image.open", return_value=mock_img):
|
||||||
mock_last_file.return_value = tmp_path / "optimized.png"
|
mock_last_file.return_value = tmp_path / "optimized.png"
|
||||||
|
|
||||||
result = imageOptimization(tmp_path / "test.png")
|
result = imageOptimization(tmp_path / "test.png")
|
||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
|
|
||||||
@patch('pptopic.lib.cv2.cvtColor')
|
@patch("pptopic.lib.cv2.cvtColor")
|
||||||
@patch('pptopic.lib.cv2.resize')
|
@patch("pptopic.lib.cv2.resize")
|
||||||
@patch('pptopic.lib.subprocess.run')
|
@patch("pptopic.lib.subprocess.run")
|
||||||
@patch('pptopic.lib.lastFile')
|
@patch("pptopic.lib.lastFile")
|
||||||
def test_image_optimization_with_pil_image(self, mock_last_file, mock_subprocess,
|
def test_with_pil_image(self, mock_last_file, mock_subprocess, mock_resize, mock_cvtcolor, tmp_path):
|
||||||
mock_resize, mock_cvtcolor, tmp_path):
|
"""Optimize a PIL Image directly."""
|
||||||
"""Test imageOptimization with PIL Image input."""
|
|
||||||
mock_img = MagicMock()
|
mock_img = MagicMock()
|
||||||
mock_img.size = (100, 100)
|
mock_img.size = (100, 100)
|
||||||
mock_img.mode = 'RGB'
|
mock_img.mode = "RGB"
|
||||||
|
|
||||||
mock_array = np.array([[[255, 0, 0]]])
|
mock_cvtcolor.return_value = np.array([[[255, 0, 0]]])
|
||||||
mock_cvtcolor.return_value = mock_array
|
mock_resize.return_value = np.array([[[255, 0, 0]]])
|
||||||
|
|
||||||
mock_resized = np.array([[[255, 0, 0]]])
|
|
||||||
mock_resize.return_value = mock_resized
|
|
||||||
|
|
||||||
mock_pil_img = MagicMock()
|
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 patch('pptopic.lib.Image.open', return_value=mock_img):
|
with patch("pptopic.lib.Image.open", return_value=mock_img):
|
||||||
mock_last_file.return_value = tmp_path / "optimized.png"
|
mock_last_file.return_value = tmp_path / "optimized.png"
|
||||||
|
|
||||||
result = imageOptimization(mock_img)
|
result = imageOptimization(mock_img)
|
||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
|
|
||||||
@patch('pptopic.lib.Image.open')
|
@patch("pptopic.lib.Image.open")
|
||||||
@patch('pptopic.lib.cv2.cvtColor')
|
@patch("pptopic.lib.cv2.cvtColor")
|
||||||
@patch('pptopic.lib.cv2.resize')
|
@patch("pptopic.lib.cv2.resize")
|
||||||
@patch('pptopic.lib.subprocess.run')
|
@patch("pptopic.lib.subprocess.run")
|
||||||
@patch('pptopic.lib.shutil.copyfile')
|
@patch("pptopic.lib.shutil.copyfile")
|
||||||
def test_image_optimization_with_save_file(self, mock_copyfile, mock_subprocess,
|
def test_with_save_file(self, mock_copyfile, mock_subprocess, mock_resize, mock_cvtcolor, mock_open, tmp_path):
|
||||||
mock_resize, mock_cvtcolor, mock_open, tmp_path):
|
"""When saveFile is provided, result is None and copyfile is called."""
|
||||||
"""Test imageOptimization with saveFile parameter."""
|
|
||||||
mock_img = MagicMock()
|
mock_img = MagicMock()
|
||||||
mock_img.size = (100, 100)
|
mock_img.size = (100, 100)
|
||||||
mock_img.mode = 'RGB'
|
mock_img.mode = "RGB"
|
||||||
mock_open.return_value = mock_img
|
mock_open.return_value = mock_img
|
||||||
|
|
||||||
mock_array = np.array([[[255, 0, 0]]])
|
mock_cvtcolor.return_value = np.array([[[255, 0, 0]]])
|
||||||
mock_cvtcolor.return_value = mock_array
|
mock_resize.return_value = np.array([[[255, 0, 0]]])
|
||||||
|
|
||||||
mock_resized = np.array([[[255, 0, 0]]])
|
|
||||||
mock_resize.return_value = mock_resized
|
|
||||||
|
|
||||||
mock_pil_img = MagicMock()
|
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 patch('pptopic.lib.lastFile') as mock_last_file:
|
with patch("pptopic.lib.lastFile") as mock_last_file:
|
||||||
mock_last_file.return_value = tmp_path / "optimized.png"
|
mock_last_file.return_value = tmp_path / "optimized.png"
|
||||||
|
|
||||||
result = imageOptimization(tmp_path / "test.png", saveFile=tmp_path / "output.png")
|
result = imageOptimization(tmp_path / "test.png", saveFile=tmp_path / "output.png")
|
||||||
|
|
||||||
assert result is None
|
assert result is None
|
||||||
mock_copyfile.assert_called_once()
|
mock_copyfile.assert_called_once()
|
||||||
|
|
||||||
@patch('pptopic.lib.Image.open')
|
@patch("pptopic.lib.Image.open")
|
||||||
@patch('pptopic.lib.cv2.cvtColor')
|
@patch("pptopic.lib.cv2.cvtColor")
|
||||||
@patch('pptopic.lib.cv2.resize')
|
@patch("pptopic.lib.cv2.resize")
|
||||||
@patch('pptopic.lib.subprocess.run')
|
@patch("pptopic.lib.subprocess.run")
|
||||||
@patch('pptopic.lib.lastFile')
|
@patch("pptopic.lib.lastFile")
|
||||||
def test_image_optimization_with_max_width(self, mock_last_file, mock_subprocess,
|
def test_with_max_width(self, mock_last_file, mock_subprocess, mock_resize, mock_cvtcolor, mock_open, tmp_path):
|
||||||
mock_resize, mock_cvtcolor, mock_open, tmp_path):
|
"""max_width triggers resize."""
|
||||||
"""Test imageOptimization with max_width parameter."""
|
|
||||||
mock_img = MagicMock()
|
mock_img = MagicMock()
|
||||||
mock_img.size = (200, 100)
|
mock_img.size = (200, 100)
|
||||||
mock_img.mode = 'RGB'
|
mock_img.mode = "RGB"
|
||||||
mock_open.return_value = mock_img
|
mock_open.return_value = mock_img
|
||||||
|
|
||||||
mock_array = np.zeros((100, 200, 3), dtype=np.uint8)
|
mock_cvtcolor.return_value = np.zeros((100, 200, 3), dtype=np.uint8)
|
||||||
mock_cvtcolor.return_value = mock_array
|
mock_resize.return_value = np.zeros((50, 100, 3), dtype=np.uint8)
|
||||||
|
|
||||||
mock_resized = np.zeros((50, 100, 3), dtype=np.uint8)
|
|
||||||
mock_resize.return_value = mock_resized
|
|
||||||
|
|
||||||
mock_pil_img = MagicMock()
|
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 patch('pptopic.lib.Image.open', return_value=mock_img):
|
with patch("pptopic.lib.Image.open", return_value=mock_img):
|
||||||
mock_last_file.return_value = tmp_path / "optimized.png"
|
mock_last_file.return_value = tmp_path / "optimized.png"
|
||||||
|
|
||||||
result = imageOptimization(tmp_path / "test.png", max_width=100)
|
result = imageOptimization(tmp_path / "test.png", max_width=100)
|
||||||
|
|
||||||
mock_resize.assert_called_once()
|
mock_resize.assert_called_once()
|
||||||
assert result is not None
|
assert result is not None
|
||||||
|
|
||||||
@patch('pptopic.lib.Image.open')
|
@patch("pptopic.lib.Image.open")
|
||||||
@patch('pptopic.lib.cv2.cvtColor')
|
@patch("pptopic.lib.cv2.cvtColor")
|
||||||
@patch('pptopic.lib.cv2.resize')
|
@patch("pptopic.lib.cv2.resize")
|
||||||
@patch('pptopic.lib.subprocess.run')
|
@patch("pptopic.lib.subprocess.run")
|
||||||
@patch('pptopic.lib.lastFile')
|
@patch("pptopic.lib.lastFile")
|
||||||
def test_image_optimization_with_max_height(self, mock_last_file, mock_subprocess,
|
def test_with_max_height(self, mock_last_file, mock_subprocess, mock_resize, mock_cvtcolor, mock_open, tmp_path):
|
||||||
mock_resize, mock_cvtcolor, mock_open, tmp_path):
|
"""max_height triggers resize."""
|
||||||
"""Test imageOptimization with max_height parameter."""
|
|
||||||
mock_img = MagicMock()
|
mock_img = MagicMock()
|
||||||
mock_img.size = (100, 200)
|
mock_img.size = (100, 200)
|
||||||
mock_img.mode = 'RGB'
|
mock_img.mode = "RGB"
|
||||||
mock_open.return_value = mock_img
|
mock_open.return_value = mock_img
|
||||||
|
|
||||||
mock_array = np.zeros((200, 100, 3), dtype=np.uint8)
|
mock_cvtcolor.return_value = np.zeros((200, 100, 3), dtype=np.uint8)
|
||||||
mock_cvtcolor.return_value = mock_array
|
mock_resize.return_value = np.zeros((100, 50, 3), dtype=np.uint8)
|
||||||
|
|
||||||
mock_resized = np.zeros((100, 50, 3), dtype=np.uint8)
|
|
||||||
mock_resize.return_value = mock_resized
|
|
||||||
|
|
||||||
mock_pil_img = MagicMock()
|
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 patch('pptopic.lib.Image.open', return_value=mock_img):
|
with patch("pptopic.lib.Image.open", return_value=mock_img):
|
||||||
mock_last_file.return_value = tmp_path / "optimized.png"
|
mock_last_file.return_value = tmp_path / "optimized.png"
|
||||||
|
|
||||||
result = imageOptimization(tmp_path / "test.png", max_height=100)
|
result = imageOptimization(tmp_path / "test.png", max_height=100)
|
||||||
|
|
||||||
mock_resize.assert_called_once()
|
mock_resize.assert_called_once()
|
||||||
assert result is not None
|
assert result is not None
|
||||||
|
|
||||||
@patch('pptopic.lib.Image.open')
|
@patch("pptopic.lib.Image.open")
|
||||||
@patch('pptopic.lib.cv2.cvtColor')
|
@patch("pptopic.lib.cv2.cvtColor")
|
||||||
@patch('pptopic.lib.cv2.resize')
|
@patch("pptopic.lib.cv2.resize")
|
||||||
@patch('pptopic.lib.subprocess.run')
|
@patch("pptopic.lib.subprocess.run")
|
||||||
@patch('pptopic.lib.lastFile')
|
@patch("pptopic.lib.lastFile")
|
||||||
def test_image_optimization_with_fractional_width(self, mock_last_file, mock_subprocess,
|
def test_with_fractional_width(self, mock_last_file, mock_subprocess, mock_resize, mock_cvtcolor, mock_open, tmp_path):
|
||||||
mock_resize, mock_cvtcolor, mock_open, tmp_path):
|
"""max_width < 1 is treated as a scale factor."""
|
||||||
"""Test imageOptimization with fractional max_width (< 1)."""
|
|
||||||
mock_img = MagicMock()
|
mock_img = MagicMock()
|
||||||
mock_img.size = (200, 100)
|
mock_img.size = (200, 100)
|
||||||
mock_img.mode = 'RGB'
|
mock_img.mode = "RGB"
|
||||||
mock_open.return_value = mock_img
|
mock_open.return_value = mock_img
|
||||||
|
|
||||||
mock_array = np.zeros((100, 200, 3), dtype=np.uint8)
|
mock_cvtcolor.return_value = np.zeros((100, 200, 3), dtype=np.uint8)
|
||||||
mock_cvtcolor.return_value = mock_array
|
mock_resize.return_value = np.zeros((50, 100, 3), dtype=np.uint8)
|
||||||
|
|
||||||
mock_resized = np.zeros((50, 100, 3), dtype=np.uint8)
|
|
||||||
mock_resize.return_value = mock_resized
|
|
||||||
|
|
||||||
mock_pil_img = MagicMock()
|
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 patch('pptopic.lib.Image.open', return_value=mock_img):
|
with patch("pptopic.lib.Image.open", return_value=mock_img):
|
||||||
mock_last_file.return_value = tmp_path / "optimized.png"
|
mock_last_file.return_value = tmp_path / "optimized.png"
|
||||||
|
|
||||||
result = imageOptimization(tmp_path / "test.png", max_width=0.5)
|
result = imageOptimization(tmp_path / "test.png", max_width=0.5)
|
||||||
|
|
||||||
mock_resize.assert_called_once()
|
mock_resize.assert_called_once()
|
||||||
assert result is not None
|
assert result is not None
|
||||||
|
|
||||||
@patch('pptopic.lib.Image.open')
|
@patch("pptopic.lib.Image.open")
|
||||||
@patch('pptopic.lib.cv2.cvtColor')
|
@patch("pptopic.lib.cv2.cvtColor")
|
||||||
@patch('pptopic.lib.subprocess.run')
|
@patch("pptopic.lib.subprocess.run")
|
||||||
@patch('pptopic.lib.lastFile')
|
@patch("pptopic.lib.lastFile")
|
||||||
def test_image_optimization_without_engine(self, mock_last_file, mock_subprocess,
|
def test_without_engine(self, mock_last_file, mock_subprocess, mock_cvtcolor, mock_open, tmp_path):
|
||||||
mock_cvtcolor, mock_open, tmp_path):
|
"""engine=None skips subprocess and saves via PIL."""
|
||||||
"""Test imageOptimization with engine=None."""
|
|
||||||
mock_img = MagicMock()
|
mock_img = MagicMock()
|
||||||
mock_img.size = (100, 100)
|
mock_img.size = (100, 100)
|
||||||
mock_img.mode = 'RGB'
|
mock_img.mode = "RGB"
|
||||||
mock_open.return_value = mock_img
|
mock_open.return_value = mock_img
|
||||||
|
|
||||||
mock_array = np.array([[[255, 0, 0]]])
|
mock_cvtcolor.return_value = np.array([[[255, 0, 0]]])
|
||||||
mock_cvtcolor.return_value = mock_array
|
|
||||||
|
|
||||||
mock_pil_img = MagicMock()
|
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 patch('pptopic.lib.Image.open', return_value=mock_img):
|
with patch("pptopic.lib.Image.open", return_value=mock_img):
|
||||||
mock_last_file.return_value = tmp_path / "optimized.png"
|
mock_last_file.return_value = tmp_path / "optimized.png"
|
||||||
|
|
||||||
result = imageOptimization(tmp_path / "test.png", engine=None)
|
result = imageOptimization(tmp_path / "test.png", engine=None)
|
||||||
|
|
||||||
mock_subprocess.assert_not_called()
|
mock_subprocess.assert_not_called()
|
||||||
assert result is not None
|
assert result is not None
|
||||||
|
|
||||||
@patch('pptopic.lib.Image.open')
|
@patch("pptopic.lib.Image.open")
|
||||||
@patch('pptopic.lib.cv2.cvtColor')
|
@patch("pptopic.lib.cv2.cvtColor")
|
||||||
@patch('pptopic.lib.subprocess.run')
|
@patch("pptopic.lib.subprocess.run")
|
||||||
def test_image_optimization_engine_failure(self, mock_subprocess, mock_cvtcolor, mock_open):
|
def test_engine_failure(self, mock_subprocess, mock_cvtcolor, mock_open):
|
||||||
"""Test imageOptimization when engine subprocess fails."""
|
"""Generic engine error re-raises after printing help text."""
|
||||||
mock_img = MagicMock()
|
mock_img = MagicMock()
|
||||||
mock_img.size = (100, 100)
|
mock_img.size = (100, 100)
|
||||||
mock_img.mode = 'RGB'
|
mock_img.mode = "RGB"
|
||||||
mock_open.return_value = mock_img
|
mock_open.return_value = mock_img
|
||||||
|
|
||||||
mock_array = np.array([[[255, 0, 0]]])
|
mock_cvtcolor.return_value = np.array([[[255, 0, 0]]])
|
||||||
mock_cvtcolor.return_value = mock_array
|
|
||||||
|
|
||||||
mock_subprocess.side_effect = Exception("Engine not found")
|
mock_subprocess.side_effect = Exception("Engine not found")
|
||||||
|
|
||||||
mock_pil_img = MagicMock()
|
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):
|
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] 优化图片中"
|
||||||
|
|||||||
161
uv-installer.ps1
161
uv-installer.ps1
@@ -33,6 +33,90 @@ param (
|
|||||||
[Parameter(HelpMessage = "Print Help")]
|
[Parameter(HelpMessage = "Print Help")]
|
||||||
[switch]$Help
|
[switch]$Help
|
||||||
)
|
)
|
||||||
|
function WebProxyFromUrl {
|
||||||
|
param([string]$ProxyUrl)
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($ProxyUrl)) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Parse the proxy URL
|
||||||
|
$uri = [System.Uri]$ProxyUrl
|
||||||
|
|
||||||
|
# Create WebProxy instance
|
||||||
|
$webProxy = New-Object System.Net.WebProxy($uri)
|
||||||
|
|
||||||
|
# Set credentials if provided in URL
|
||||||
|
if (-not [string]::IsNullOrEmpty($uri.UserInfo)) {
|
||||||
|
$userInfo = $uri.UserInfo.Split(':')
|
||||||
|
$username = [System.Uri]::UnescapeDataString($userInfo[0])
|
||||||
|
$password = if ($null -eq $userInfo[1]) { "" } else { [System.Uri]::UnescapeDataString($userInfo[1]) }
|
||||||
|
$webProxy.Credentials = New-Object System.Net.NetworkCredential($username, $password)
|
||||||
|
}
|
||||||
|
|
||||||
|
return $webProxy
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Verbose("Failed to parse proxy URL '$ProxyUrl': $($_.Exception.Message)")
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function WebProxyFromEnvironment {
|
||||||
|
$httpsProxy = [System.Environment]::GetEnvironmentVariable("HTTPS_PROXY")
|
||||||
|
$allProxy = [System.Environment]::GetEnvironmentVariable("ALL_PROXY")
|
||||||
|
$proxyUrl = if (-not [string]::IsNullOrWhiteSpace($httpsProxy)) { $httpsProxy } else { $allProxy }
|
||||||
|
$webProxy = WebProxyFromUrl -ProxyUrl $proxyUrl
|
||||||
|
return $webProxy
|
||||||
|
}
|
||||||
|
|
||||||
|
# Downloads a URL to a local file using HttpClient with:
|
||||||
|
# * a configurable per-request timeout (WebClient has none)
|
||||||
|
# * proxy support from HTTPS_PROXY / ALL_PROXY
|
||||||
|
# * bearer auth, attached ONLY when the target host is a trusted first-party
|
||||||
|
# (github.com / astral.sh). This prevents leaking UV_GITHUB_TOKEN to
|
||||||
|
# third-party mirrors configured in $ArtifactDownloadUrls.
|
||||||
|
function Invoke-HttpDownload {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Url,
|
||||||
|
[Parameter(Mandatory = $true)][string]$Destination,
|
||||||
|
[int]$TimeoutSec = 30,
|
||||||
|
[switch]$IncludeAuth
|
||||||
|
)
|
||||||
|
|
||||||
|
$handler = New-Object System.Net.Http.HttpClientHandler
|
||||||
|
$proxy = WebProxyFromEnvironment
|
||||||
|
if ($null -ne $proxy) {
|
||||||
|
$handler.Proxy = $proxy
|
||||||
|
$handler.UseProxy = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
$client = New-Object System.Net.Http.HttpClient($handler)
|
||||||
|
$client.Timeout = [TimeSpan]::FromSeconds($TimeoutSec)
|
||||||
|
$client.DefaultRequestHeaders.Add("User-Agent", "PowerShell/uv-installer")
|
||||||
|
|
||||||
|
if ($IncludeAuth -and $auth_token -and ($Url -match 'github\.com|astral\.sh')) {
|
||||||
|
$client.DefaultRequestHeaders.Add("Authorization", "Bearer $auth_token")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $client.GetAsync($Url, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).Result
|
||||||
|
if (-not $response.IsSuccessStatusCode) {
|
||||||
|
throw "HTTP $([int]$response.StatusCode) downloading $Url"
|
||||||
|
}
|
||||||
|
$stream = $response.Content.ReadAsStreamAsync().Result
|
||||||
|
$fileStream = [System.IO.File]::Create($Destination)
|
||||||
|
try {
|
||||||
|
$stream.CopyTo($fileStream)
|
||||||
|
} finally {
|
||||||
|
$fileStream.Dispose()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$client.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Get-LatestVersion {
|
function Get-LatestVersion {
|
||||||
if ($env:UV_INSTALLER_VERSION) {
|
if ($env:UV_INSTALLER_VERSION) {
|
||||||
return $env:UV_INSTALLER_VERSION
|
return $env:UV_INSTALLER_VERSION
|
||||||
@@ -41,10 +125,22 @@ function Get-LatestVersion {
|
|||||||
$fallback_version = "0.11.20"
|
$fallback_version = "0.11.20"
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$http = New-Object System.Net.Http.HttpClient
|
$handler = New-Object System.Net.Http.HttpClientHandler
|
||||||
$http.Timeout = [TimeSpan]::FromSeconds(5)
|
$proxy = WebProxyFromEnvironment
|
||||||
|
if ($null -ne $proxy) {
|
||||||
|
$handler.Proxy = $proxy
|
||||||
|
$handler.UseProxy = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
$http = New-Object System.Net.Http.HttpClient($handler)
|
||||||
|
# Bumped from 5s -> 10s. The API call is small but in proxy/restricted
|
||||||
|
# networks 5s was almost always too tight and forced the fallback version.
|
||||||
|
$http.Timeout = [TimeSpan]::FromSeconds(10)
|
||||||
$http.DefaultRequestHeaders.Add("User-Agent", "PowerShell/uv-installer")
|
$http.DefaultRequestHeaders.Add("User-Agent", "PowerShell/uv-installer")
|
||||||
$http.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json")
|
$http.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json")
|
||||||
|
if ($auth_token) {
|
||||||
|
$http.DefaultRequestHeaders.Add("Authorization", "Bearer $auth_token")
|
||||||
|
}
|
||||||
$response = $http.GetStringAsync("https://api.github.com/repos/astral-sh/uv/releases/latest").Result
|
$response = $http.GetStringAsync("https://api.github.com/repos/astral-sh/uv/releases/latest").Result
|
||||||
$json = $response | ConvertFrom-Json
|
$json = $response | ConvertFrom-Json
|
||||||
$version = $json.tag_name
|
$version = $json.tag_name
|
||||||
@@ -60,6 +156,9 @@ function Get-LatestVersion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$app_name = 'uv'
|
$app_name = 'uv'
|
||||||
|
# NOTE: $auth_token must be defined *before* Get-LatestVersion / Invoke-HttpDownload
|
||||||
|
# are invoked, since both reference it.
|
||||||
|
$auth_token = $env:UV_GITHUB_TOKEN
|
||||||
$app_version = Get-LatestVersion
|
$app_version = Get-LatestVersion
|
||||||
if ($env:UV_DOWNLOAD_URL) {
|
if ($env:UV_DOWNLOAD_URL) {
|
||||||
$ArtifactDownloadUrls = @($env:UV_DOWNLOAD_URL)
|
$ArtifactDownloadUrls = @($env:UV_DOWNLOAD_URL)
|
||||||
@@ -72,6 +171,10 @@ if ($env:UV_DOWNLOAD_URL) {
|
|||||||
$installer_base_url = $env:UV_INSTALLER_GITHUB_BASE_URL
|
$installer_base_url = $env:UV_INSTALLER_GITHUB_BASE_URL
|
||||||
$ArtifactDownloadUrls = @("$installer_base_url/astral-sh/uv/releases/download/$app_version")
|
$ArtifactDownloadUrls = @("$installer_base_url/astral-sh/uv/releases/download/$app_version")
|
||||||
} else {
|
} else {
|
||||||
|
# Default download sources, ordered by priority.
|
||||||
|
# Mirror entries exist to work around GitHub connectivity issues in mainland China.
|
||||||
|
# If you are outside China or these mirrors become unavailable, you can safely
|
||||||
|
# delete the two mirror lines and rely on the official URLs (first + last).
|
||||||
$ArtifactDownloadUrls = @(
|
$ArtifactDownloadUrls = @(
|
||||||
"https://releases.astral.sh/github/uv/releases/download/$app_version",
|
"https://releases.astral.sh/github/uv/releases/download/$app_version",
|
||||||
"https://mirror.ghproxy.com/https://github.com/astral-sh/uv/releases/download/$app_version",
|
"https://mirror.ghproxy.com/https://github.com/astral-sh/uv/releases/download/$app_version",
|
||||||
@@ -80,8 +183,6 @@ if ($env:UV_DOWNLOAD_URL) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
$auth_token = $env:UV_GITHUB_TOKEN
|
|
||||||
|
|
||||||
$receipt = @"
|
$receipt = @"
|
||||||
{"binaries":["CARGO_DIST_BINS"],"binary_aliases":{},"cdylibs":["CARGO_DIST_DYLIBS"],"cstaticlibs":["CARGO_DIST_STATICLIBS"],"install_layout":"unspecified","install_prefix":"AXO_INSTALL_PREFIX","modify_path":true,"provider":{"source":"cargo-dist","version":"0.31.0"},"source":{"app_name":"uv","name":"uv","owner":"astral-sh","release_type":"github"},"version":"$app_version"}
|
{"binaries":["CARGO_DIST_BINS"],"binary_aliases":{},"cdylibs":["CARGO_DIST_DYLIBS"],"cstaticlibs":["CARGO_DIST_STATICLIBS"],"install_layout":"unspecified","install_prefix":"AXO_INSTALL_PREFIX","modify_path":true,"provider":{"source":"cargo-dist","version":"0.31.0"},"source":{"app_name":"uv","name":"uv","owner":"astral-sh","release_type":"github"},"version":"$app_version"}
|
||||||
"@
|
"@
|
||||||
@@ -269,44 +370,6 @@ function Get-Arch() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function WebProxyFromUrl {
|
|
||||||
param([string]$ProxyUrl)
|
|
||||||
|
|
||||||
if ([string]::IsNullOrWhiteSpace($ProxyUrl)) {
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
# Parse the proxy URL
|
|
||||||
$uri = [System.Uri]$ProxyUrl
|
|
||||||
|
|
||||||
# Create WebProxy instance
|
|
||||||
$webProxy = New-Object System.Net.WebProxy($uri)
|
|
||||||
|
|
||||||
# Set credentials if provided in URL
|
|
||||||
if (-not [string]::IsNullOrEmpty($uri.UserInfo)) {
|
|
||||||
$userInfo = $uri.UserInfo.Split(':')
|
|
||||||
$username = [System.Uri]::UnescapeDataString($userInfo[0])
|
|
||||||
$password = if ($null -eq $userInfo[1]) { "" } else { [System.Uri]::UnescapeDataString($userInfo[1]) }
|
|
||||||
$webProxy.Credentials = New-Object System.Net.NetworkCredential($username, $password)
|
|
||||||
}
|
|
||||||
|
|
||||||
return $webProxy
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Write-Verbose("Failed to parse proxy URL '$ProxyUrl': $($_.Exception.Message)")
|
|
||||||
return $null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function WebProxyFromEnvironment {
|
|
||||||
$httpsProxy = [System.Environment]::GetEnvironmentVariable("HTTPS_PROXY")
|
|
||||||
$allProxy = [System.Environment]::GetEnvironmentVariable("ALL_PROXY")
|
|
||||||
$proxyUrl = if (-not [string]::IsNullOrWhiteSpace($httpsProxy)) { $httpsProxy } else { $allProxy }
|
|
||||||
$webProxy = WebProxyFromUrl -ProxyUrl $proxyUrl
|
|
||||||
return $webProxy
|
|
||||||
}
|
|
||||||
|
|
||||||
function Download($download_url, $platforms, $arch) {
|
function Download($download_url, $platforms, $arch) {
|
||||||
# Lookup what we expect this platform to look like
|
# Lookup what we expect this platform to look like
|
||||||
$info = $platforms[$arch]
|
$info = $platforms[$arch]
|
||||||
@@ -324,15 +387,7 @@ function Download($download_url, $platforms, $arch) {
|
|||||||
$url = "$download_url/$artifact_name"
|
$url = "$download_url/$artifact_name"
|
||||||
Write-Verbose " from $url"
|
Write-Verbose " from $url"
|
||||||
Write-Verbose " to $dir_path"
|
Write-Verbose " to $dir_path"
|
||||||
$wc = New-Object Net.Webclient
|
Invoke-HttpDownload -Url $url -Destination $dir_path -TimeoutSec 30 -IncludeAuth
|
||||||
$proxy = WebProxyFromEnvironment
|
|
||||||
if ($null -ne $proxy) {
|
|
||||||
$wc.Proxy = $proxy
|
|
||||||
}
|
|
||||||
if ($auth_token) {
|
|
||||||
$wc.Headers["Authorization"] = "Bearer $auth_token"
|
|
||||||
}
|
|
||||||
$wc.downloadFile($url, $dir_path)
|
|
||||||
|
|
||||||
Write-Verbose "Unpacking to $tmp"
|
Write-Verbose "Unpacking to $tmp"
|
||||||
|
|
||||||
@@ -377,7 +432,7 @@ function Download($download_url, $platforms, $arch) {
|
|||||||
$updater_url = "$download_url/$updater_id"
|
$updater_url = "$download_url/$updater_id"
|
||||||
$out_name = "$tmp\uv-update.exe"
|
$out_name = "$tmp\uv-update.exe"
|
||||||
|
|
||||||
$wc.downloadFile($updater_url, $out_name)
|
Invoke-HttpDownload -Url $updater_url -Destination $out_name -TimeoutSec 30 -IncludeAuth
|
||||||
$bin_paths += $out_name
|
$bin_paths += $out_name
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,7 +604,7 @@ function Invoke-Installer($artifacts, $platforms) {
|
|||||||
# .NET's APIs which actually do what you tell them (also apparently utf8NoBOM is the
|
# .NET's APIs which actually do what you tell them (also apparently utf8NoBOM is the
|
||||||
# default in newer .NETs but I'd rather not rely on that at this point).
|
# default in newer .NETs but I'd rather not rely on that at this point).
|
||||||
$Utf8NoBomEncoding = New-Object System.Text.UTF8Encoding $False
|
$Utf8NoBomEncoding = New-Object System.Text.UTF8Encoding $False
|
||||||
[IO.File]::WriteAllLines("$receipt_home/uv-receipt.json", "$receipt", $Utf8NoBomEncoding)
|
[IO.File]::WriteAllLines("$receipt_home\uv-receipt.json", "$receipt", $Utf8NoBomEncoding)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Respect the environment, but CLI takes precedence
|
# Respect the environment, but CLI takes precedence
|
||||||
|
|||||||
25
uv.lock
generated
25
uv.lock
generated
@@ -360,7 +360,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pptopic"
|
name = "pptopic"
|
||||||
version = "0.3.0"
|
version = "0.3.2"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "numpy" },
|
{ name = "numpy" },
|
||||||
@@ -369,6 +369,7 @@ dependencies = [
|
|||||||
{ name = "pywin32" },
|
{ name = "pywin32" },
|
||||||
{ name = "simtoolsz" },
|
{ name = "simtoolsz" },
|
||||||
{ name = "typer" },
|
{ name = "typer" },
|
||||||
|
{ name = "yaspin" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
@@ -389,6 +390,7 @@ requires-dist = [
|
|||||||
{ name = "pywin32", specifier = ">=311" },
|
{ name = "pywin32", specifier = ">=311" },
|
||||||
{ name = "simtoolsz", specifier = ">=0.2.12.3" },
|
{ name = "simtoolsz", specifier = ">=0.2.12.3" },
|
||||||
{ name = "typer", specifier = ">=0.21.2" },
|
{ name = "typer", specifier = ">=0.21.2" },
|
||||||
|
{ name = "yaspin", specifier = ">=3.4.0" },
|
||||||
]
|
]
|
||||||
provides-extras = ["dev"]
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "typer"
|
name = "typer"
|
||||||
version = "0.21.2"
|
version = "0.21.2"
|
||||||
@@ -574,3 +585,15 @@ sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5e/a7/c202b344c5ca7d
|
|||||||
wheels = [
|
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" },
|
{ 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