5 Commits
v0.3.1 ... main

Author SHA1 Message Date
fd6b740a35 docs: 完善安装脚本说明与错误处理文档 2026-06-22 17:19:09 +08:00
04ef9b65a5 docs: 重构 README 新手使用说明,添加一键安装脚本文档 2026-06-22 15:42:44 +08:00
cba7f9fb55 fix: 修复保存路径解析逻辑,统一使用 resolve() 处理 saveto 参数 2026-06-18 17:04:20 +08:00
97b30ace84 feat(lib): 添加批量图片优化功能与实时进度显示
- 新增 `batchImageOptimization` 函数支持批量处理图片
- 为图片优化添加 spinner 进度指示器和实时计时显示
- 更新版本号至 0.3.2
2026-06-18 16:17:38 +08:00
ffcb2801b2 docs: 重新整理 README 安装步骤顺序并优化表述 2026-06-12 17:06:49 +08:00
13 changed files with 1191 additions and 563 deletions

View File

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

170
README.md
View File

@@ -17,26 +17,102 @@
## 新手使用说明 ## 新手使用说明
如果你是第一次使用命令行工具,请按以下步骤操作。 如果你是第一次使用命令行工具,请按以下步骤操作。我们提供了一键安装脚本,尽量让你少输入命令。
### 准备工作 ### 安装前准备
- **PowerPoint**:需要安装 Microsoft PowerPointOffice 2016 或更新版本) - **PowerPoint**:需要安装 Microsoft PowerPointOffice 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 及其依赖。
@@ -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,7 +154,7 @@ 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 为长图
@@ -121,13 +197,23 @@ pptopic optimize image.png
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 PowerPointOffice 2016 或更新版本后重试。PowerPoint 不在一键安装脚本的自动安装范围内,需要自行安装。
## License ## License
MIT License MIT License

View File

@@ -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]

24
setup.bat Normal file
View 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
View File

@@ -0,0 +1,359 @@
#Requires -Version 5.1
<#
.SYNOPSIS
pptopic 一键安装脚本
.DESCRIPTION
自动完成以下安装步骤:
1. 安装 uvPython 包管理器)
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 时需要调用 PowerPointOffice 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 ""

View File

@@ -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>"

View File

@@ -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

View File

@@ -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():
"""显示版本信息""" """显示版本信息"""

View File

@@ -1,94 +1,66 @@
"""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:
test_file = Path(test_file)
with patch('pptopic.lib.wcc.Dispatch') as mock_dispatch: with patch("pptopic.lib.wcc.Dispatch"):
mock_app = MagicMock()
mock_dispatch.return_value = mock_app
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"):
ppt = convertPPT(test_file, trans=ttype)
assert ppt.savetype == expected
with patch('pptopic.lib.wcc.Dispatch'): @patch("pptopic.lib.sys.platform", "linux")
ppt = convertPPT(test_file, trans="jpg") def test_init_non_windows_raises_error(self):
assert ppt.savetype == "JPG"
ppt = convertPPT(test_file, trans="Pdf")
assert ppt.savetype == "PDF"
class TestConvertPTTNonWindows:
"""Test convertPPT behavior on non-Windows systems."""
@patch('pptopic.lib.sys.platform', 'linux')
def test_init_on_non_windows_raises_error(self):
"""Test that initialization on non-Windows raises SystemError."""
with pytest.raises(SystemError, match="Only support Windows system"): 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
@@ -97,54 +69,34 @@ class TestConvertPPTContextManager:
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)

View File

@@ -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() @pytest.mark.parametrize(
mock_convert_ppt.return_value.__enter__.return_value = mock_ppt "width,expected_size",
[
(None, (100, 100)), # no scaling → uses img.size
(50, (50, 50)), # pixel width → (w, h*w//W)
("50%", (50, 50)), # percentage → half
],
ids=["none", "pixel", "percentage"],
)
@patch("pptopic.lib.convertPPT")
def test_width_variations(self, mock_convert_ppt, width, expected_size, tmp_path):
"""PPT_longPic handles None / pixel / percentage width correctly."""
img_paths, mock_img, mock_canvas = _make_img_mocks(tmp_path)
with patch('pptopic.lib.Path.glob') as mock_glob: mock_convert_ppt.return_value.__enter__.return_value = MagicMock()
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: with patch("pptopic.lib.Path.glob", return_value=img_paths):
mock_img = MagicMock() with patch("pptopic.lib.Image.open") as mock_open:
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", width=width, saveto=tmp_path)
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() mock_canvas.save.assert_called_once()
@patch('pptopic.lib.convertPPT') # -- saveName / saveto variations -------------------------------------
def test_ppt_longpic_without_save_name(self, mock_convert_ppt, tmp_path):
"""Test PPT_longPic without saveName uses default JPG."""
ppt_file = tmp_path / "test.pptx"
ppt_file.touch()
mock_ppt = MagicMock() @pytest.mark.parametrize(
mock_convert_ppt.return_value.__enter__.return_value = mock_ppt "save_name,saveto",
[
(None, None), # defaults: JPG, ppt parent dir
("output.jpg", None), # named, ppt parent dir
(None, "."), # default name, custom dir
],
ids=["defaults", "named", "custom_dir"],
)
@patch("pptopic.lib.convertPPT")
def test_save_params(self, mock_convert_ppt, save_name, saveto, tmp_path):
"""PPT_longPic handles saveName / saveto combinations."""
img_paths, mock_img, mock_canvas = _make_img_mocks(tmp_path)
with patch('pptopic.lib.Path.glob') as mock_glob: mock_convert_ppt.return_value.__enter__.return_value = MagicMock()
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: with patch("pptopic.lib.Path.glob", return_value=img_paths):
mock_img = MagicMock() with patch("pptopic.lib.Image.open") as mock_open:
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(
mock_canvas = MagicMock() tmp_path / "test.pptx",
mock_new.return_value = mock_canvas saveName=save_name,
saveto=saveto if saveto else tmp_path,
PPT_longPic(ppt_file, saveto=tmp_path) )
mock_canvas.save.assert_called_once() mock_canvas.save.assert_called_once()
def test_ppt_longpic_invalid_image_type(self, tmp_path): # -- error paths -------------------------------------------------------
"""Test PPT_longPic with invalid image type raises ValueError."""
def test_invalid_image_type(self, tmp_path):
ppt_file = tmp_path / "test.pptx" ppt_file = 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() with patch("pptopic.lib.Path.glob", return_value=[]):
mock_convert_ppt.return_value.__enter__.return_value = mock_ppt
with patch('pptopic.lib.Path.glob', return_value=[]):
with pytest.raises(ValueError, match="No images generated"): 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() @patch("pptopic.lib.convertPPT")
mock_convert_ppt.return_value.__enter__.return_value = mock_ppt def test_multiple_images(self, mock_convert_ppt, tmp_path):
"""PPT_longPic pastes every slide into the canvas."""
img_paths, mock_img, mock_canvas = _make_img_mocks(tmp_path, num_images=3)
with patch('pptopic.lib.Path.glob') as mock_glob: mock_convert_ppt.return_value.__enter__.return_value = MagicMock()
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: with patch("pptopic.lib.Path.glob", return_value=img_paths):
mock_img = MagicMock() with patch("pptopic.lib.Image.open") as mock_open:
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):
PPT_longPic(tmp_path / "test.pptx", saveto=tmp_path)
with patch('pptopic.lib.Image.new') as mock_new: assert mock_canvas.paste.call_count == 3
mock_canvas = MagicMock()
mock_new.return_value = mock_canvas
PPT_longPic(ppt_file, width="50%", saveto=tmp_path)
mock_new.assert_called_once()
@patch('pptopic.lib.convertPPT')
def test_ppt_longpic_with_pixel_width(self, mock_convert_ppt, tmp_path):
"""Test PPT_longPic with pixel width."""
ppt_file = tmp_path / "test.pptx"
ppt_file.touch()
mock_ppt = MagicMock()
mock_convert_ppt.return_value.__enter__.return_value = mock_ppt
with patch('pptopic.lib.Path.glob') as mock_glob:
mock_img_path = MagicMock()
mock_img_path.suffix = '.jpg'
mock_glob.return_value = [mock_img_path]
with patch('pptopic.lib.Image.open') as mock_open:
mock_img = MagicMock()
mock_img.mode = 'RGB'
mock_img.width = 100
mock_img.height = 100
mock_img.resize.return_value = mock_img
mock_open.return_value.__enter__.return_value = mock_img
with patch('pptopic.lib.Image.new') as mock_new:
mock_canvas = MagicMock()
mock_new.return_value = mock_canvas
PPT_longPic(ppt_file, width=50, saveto=tmp_path)
mock_new.assert_called_once()
@patch('pptopic.lib.convertPPT')
def test_ppt_longpic_with_none_width(self, mock_convert_ppt, tmp_path):
"""Test PPT_longPic with None width (no scaling)."""
ppt_file = tmp_path / "test.pptx"
ppt_file.touch()
mock_ppt = MagicMock()
mock_convert_ppt.return_value.__enter__.return_value = mock_ppt
with patch('pptopic.lib.Path.glob') as mock_glob:
mock_img_path = MagicMock()
mock_img_path.suffix = '.jpg'
mock_glob.return_value = [mock_img_path]
with patch('pptopic.lib.Image.open') as mock_open:
mock_img = MagicMock()
mock_img.mode = 'RGB'
mock_img.width = 100
mock_img.height = 100
mock_img.resize.return_value = mock_img
mock_open.return_value.__enter__.return_value = mock_img
with patch('pptopic.lib.Image.new') as mock_new:
mock_canvas = MagicMock()
mock_new.return_value = mock_canvas
PPT_longPic(ppt_file, width=None, saveto=tmp_path)
mock_new.assert_called_once()
@patch('pptopic.lib.convertPPT')
def test_ppt_longpic_save_to_current_dir(self, mock_convert_ppt, tmp_path):
"""Test PPT_longPic saving to current directory."""
ppt_file = tmp_path / "test.pptx"
ppt_file.touch()
mock_ppt = MagicMock()
mock_convert_ppt.return_value.__enter__.return_value = mock_ppt
with patch('pptopic.lib.Path.glob') as mock_glob:
mock_img_path = MagicMock()
mock_img_path.suffix = '.jpg'
mock_glob.return_value = [mock_img_path]
with patch('pptopic.lib.Image.open') as mock_open:
mock_img = MagicMock()
mock_img.mode = 'RGB'
mock_img.width = 100
mock_img.height = 100
mock_img.resize.return_value = mock_img
mock_open.return_value.__enter__.return_value = mock_img
with patch('pptopic.lib.Image.new') as mock_new:
mock_canvas = MagicMock()
mock_new.return_value = mock_canvas
PPT_longPic(ppt_file, saveto=".")
mock_canvas.save.assert_called_once() mock_canvas.save.assert_called_once()
@patch('pptopic.lib.convertPPT')
def test_ppt_longpic_multiple_images(self, mock_convert_ppt, tmp_path):
"""Test PPT_longPic with multiple images."""
ppt_file = tmp_path / "test.pptx"
ppt_file.touch()
mock_ppt = MagicMock()
mock_convert_ppt.return_value.__enter__.return_value = mock_ppt
with patch('pptopic.lib.Path.glob') as mock_glob:
mock_img_path1 = MagicMock()
mock_img_path1.suffix = '.jpg'
mock_img_path2 = MagicMock()
mock_img_path2.suffix = '.jpg'
mock_glob.return_value = [mock_img_path1, mock_img_path2]
with patch('pptopic.lib.Image.open') as mock_open:
mock_img = MagicMock()
mock_img.mode = 'RGB'
mock_img.width = 100
mock_img.height = 100
mock_img.resize.return_value = mock_img
mock_open.return_value.__enter__.return_value = mock_img
with patch('pptopic.lib.Image.new') as mock_new:
mock_canvas = MagicMock()
mock_new.return_value = mock_canvas
PPT_longPic(ppt_file, saveto=tmp_path)
mock_canvas.paste.assert_called()

View File

@@ -1,227 +1,325 @@
"""Tests for imageOptimization function.""" """Tests for image optimization functions."""
import pytest import subprocess
from pathlib import Path from 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] 优化图片中"

View File

@@ -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
View File

@@ -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" },
]