Add unit tests for PDF Packager converter functionality

- Implement tests for converter properties, ensuring correct input and output formats.
- Add tests for chip parsing, covering various image, pdf_image, and pdfa chip formats.
- Include tests for the isArchiveOutput and getOutputFileName functions.
- Mock execFile for testing the convert function, verifying command execution and output file creation.
- Ensure all chips are parseable and validate the completeness of chip categories.
This commit is contained in:
Your Name 2026-01-24 14:39:22 +08:00
parent 87bd318303
commit 944fe18b75
11 changed files with 2233 additions and 8 deletions

View file

@ -142,6 +142,7 @@ RUN echo "" && \
mupdf-tools \
poppler-utils \
potrace \
qpdf \
&& rm -rf /var/lib/apt/lists/* && \
echo "✅ 階段 2/11 完成:核心轉換工具已安裝"
@ -319,6 +320,7 @@ RUN echo "" && \
python3-numpy \
python3-tinycss2 \
python3-opencv \
python3-img2pdf \
pipx \
&& rm -rf /var/lib/apt/lists/* && \
echo "✅ 階段 11/11 完成Python 依賴已安裝" && \
@ -327,6 +329,35 @@ RUN echo "" && \
echo "✅ 所有 APT 套件安裝完成!" && \
echo "========================================"
# ==============================================================================
# 🔐 階段 11.1PDF Packager 預設簽章憑證(開箱即用)
# ==============================================================================
#
# 產生自簽憑證供 PDF Packager 簽章功能使用
# 此憑證僅供測試/展示用途,正式環境請替換為自己的憑證
# 📚 詳細說明請參考 docs/功能說明/PDF-Packager.md
#
# ==============================================================================
RUN echo "" && \
echo "========================================" && \
echo "🔐 階段 11.1:產生 PDF Packager 預設簽章憑證" && \
echo "========================================" && \
mkdir -p /app/certs && \
# 產生自簽憑證(有效期 10 年)
openssl req -x509 -newkey rsa:2048 \
-keyout /tmp/key.pem -out /tmp/cert.pem \
-days 3650 -nodes \
-subj "/CN=PDF Packager Default/O=ConvertX-CN/C=TW" && \
# 匯出為 PKCS12 格式(空密碼)
openssl pkcs12 -export \
-inkey /tmp/key.pem -in /tmp/cert.pem \
-out /app/certs/default.p12 \
-passout pass: && \
# 清理暫存檔案
rm -f /tmp/key.pem /tmp/cert.pem && \
chmod 644 /app/certs/default.p12 && \
echo "✅ 預設簽章憑證已產生: /app/certs/default.p12"
# ==============================================================================
# 🔥 階段 12-UNIFIEDPython 工具安裝 + 模型下載(單一 RUN 原則)
# ==============================================================================
@ -352,6 +383,15 @@ ENV PIP_NO_CACHE_DIR=1
# HuggingFace 環境變數(安裝時允許下載,安裝完成後設為離線)
ENV HF_HOME="/root/.cache/huggingface"
# ==============================================================================
# PDF Packager 簽章預設配置(開箱即用)
# ==============================================================================
ENV PDF_SIGN_P12_PATH="/app/certs/default.p12"
ENV PDF_SIGN_P12_PASSWORD=""
ENV PDF_SIGN_REASON="ConvertX-CN PDF Packager"
ENV PDF_SIGN_LOCATION="Taiwan"
ENV PDF_SIGN_CONTACT="convertx-cn@localhost"
RUN set -eux && \
echo "===========================================================" && \
echo "🚀 階段 12-UNIFIEDPython 工具 + 模型統一安裝" && \
@ -361,11 +401,11 @@ RUN set -eux && \
echo "===========================================================" && \
\
# ========================================
# [1/8] 安裝 huggingface_hub用於顯式模型下載
# [1/8] 安裝 huggingface_hub + endesive(用於顯式模型下載和 PDF 簽章
# ========================================
echo "" && \
echo "📦 [1/8] 安裝 huggingface_hub..." && \
pip3 install --no-cache-dir --break-system-packages huggingface_hub && \
echo "📦 [1/8] 安裝 huggingface_hub + endesivePDF 簽章)..." && \
pip3 install --no-cache-dir --break-system-packages huggingface_hub endesive && \
\
# ========================================
# [2/8] 安裝 markitdown文件轉換工具

65
LICENSE-AUTHOR Normal file
View file

@ -0,0 +1,65 @@
# ConvertX-CN Author License
# Custom Non-Commercial License for Original Components
Copyright (c) 2024-2026 ConvertX-CN Author (pi-docket)
## Definitions
- "Original Components" refers to all code, UI designs, i18n translations,
documentation, and features created specifically for ConvertX-CN that are
NOT derived from the upstream ConvertX project.
- "Commercial Use" includes but is not limited to:
- Selling or licensing the software
- Using the software as part of a paid SaaS offering
- Using the software in a revenue-generating business context
- Incorporating the software into a commercial product
## Grant of Rights
Permission is hereby granted, free of charge, to any person obtaining a copy
of the Original Components, to use, copy, modify, and distribute for:
1. **Personal Use** ✅ - Using for personal, non-commercial purposes
2. **Educational Use** ✅ - Using for learning, teaching, or academic research
3. **Non-Commercial Research** ✅ - Using for scientific or technical research
without commercial intent
## Restrictions
The following uses are **PROHIBITED** without explicit written permission:
1. ❌ Commercial use of any kind
2. ❌ SaaS deployment for paying customers
3. ❌ Integration into commercial products or services
4. ❌ Reselling or sublicensing
## Commercial Licensing
If you wish to use the Original Components for commercial purposes, you must
obtain a commercial license from the author.
### Contact for Commercial Licensing
- **GitHub**: https://github.com/pi-docket
- **Issues**: https://github.com/pi-docket/ConvertX-CN/issues
- **Discussions**: https://github.com/pi-docket/ConvertX-CN/discussions
Please open an issue or discussion with the title "[Commercial License Request]"
to initiate the licensing process.
## Disclaimer
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## Relationship with AGPL-3.0
This license applies ONLY to the Original Components created by the
ConvertX-CN author. All upstream components derived from C4illin/ConvertX
remain licensed under GNU AGPL v3.0, and those obligations still apply.

125
LICENSE-OVERVIEW.md Normal file
View file

@ -0,0 +1,125 @@
# License Overview
This project uses a **Mixed License / Source-Available** model.
---
## 📋 Quick Summary
| Component Type | License | Commercial Use |
| ------------------------ | --------------------- | -------------------------------- |
| Upstream (from ConvertX) | AGPL-3.0 | ✅ Allowed (with source sharing) |
| Author Original | Custom Non-Commercial | ❌ Requires Permission |
---
## 1. Upstream Components (AGPL-3.0)
### What's Covered
Core functionality derived from [C4illin/ConvertX](https://github.com/C4illin/ConvertX):
- Base application architecture
- Original converter integrations
- Core API structure
### Your Obligations
Under AGPL-3.0, if you modify and deploy this software as a network service:
1. ✅ You must make your modified source code available
2. ✅ You must include the AGPL-3.0 license
3. ✅ You must state your changes
### Full License
See [LICENSE](LICENSE) for the complete AGPL-3.0 text.
---
## 2. Author Original Components (Custom Non-Commercial)
### What's Covered
All original work created by the ConvertX-CN author:
- 🌐 **i18n / Localization** - 65+ language translations
- 🎨 **UI Enhancements** - Custom interface improvements
- 📊 **PDF Translation** - PDFMathTranslate, BabelDOC integrations
- 📄 **MinerU Integration** - PDF to Markdown conversion
- 🔧 **New Converters** - Additional format support
- 📚 **Documentation** - Chinese documentation
- 🐳 **Docker Optimizations** - Multi-arch builds, CJK fonts
### Permissions
| Use Case | Allowed? |
| ----------------------- | ------------------------ |
| Personal use | ✅ Yes |
| Educational use | ✅ Yes |
| Non-commercial research | ✅ Yes |
| Commercial / SaaS | ❌ No (requires license) |
### Full License
See [LICENSE-AUTHOR](LICENSE-AUTHOR) for the complete terms.
---
## 🤝 Commercial Licensing
If you want to use ConvertX-CN in a commercial context:
### Contact Methods
1. **GitHub Issues**: https://github.com/pi-docket/ConvertX-CN/issues
- Create an issue with title: `[Commercial License Request]`
2. **GitHub Discussions**: https://github.com/pi-docket/ConvertX-CN/discussions
- Start a discussion in the appropriate category
3. **GitHub Profile**: https://github.com/pi-docket
- Check profile for additional contact information
### What to Include
When requesting a commercial license, please provide:
- Company/Organization name
- Intended use case
- Expected scale of deployment
- Contact information
---
## ❓ FAQ
### Q: Can I self-host for my company's internal use?
**A:** Internal use without external revenue generation is generally permitted.
If unsure, please contact us.
### Q: Can I offer this as a paid service?
**A:** No. You need a commercial license for SaaS or paid services.
### Q: Do I need to share my modifications?
**A:** For AGPL-3.0 components: Yes, if you deploy as a network service.
For author components: Depends on your license agreement.
### Q: Can I fork and create my own version?
**A:** Yes, but:
- AGPL-3.0 components must remain AGPL-3.0
- Author components cannot be used commercially without permission
---
## 📞 Contact
- **GitHub**: [@pi-docket](https://github.com/pi-docket)
- **Repository**: [ConvertX-CN](https://github.com/pi-docket/ConvertX-CN)
- **Issues**: [Report Issues](https://github.com/pi-docket/ConvertX-CN/issues)
- **Discussions**: [Community](https://github.com/pi-docket/ConvertX-CN/discussions)

View file

@ -163,9 +163,20 @@ Core components derived from [C4illin/ConvertX](https://github.com/C4illin/Conve
Original modules, UI, i18n, and new features created by the ConvertX-CN author are licensed under **[Custom Non-Commercial License](LICENSE-AUTHOR)**.
- **Commercial Use / SaaS**: ❌ Prohibited without written permission.
- **Personal / Research Use**: ✅ Allowed.
| 使用情境 | 是否允許 |
| --------------- | --------- |
| 個人使用 | ✅ 允許 |
| 教育/研究 | ✅ 允許 |
| 商業使用 / SaaS | ❌ 需授權 |
### 📞 商業授權聯繫
如需商業授權,請透過以下方式聯繫:
- **GitHub Issues**: [建立 Issue](https://github.com/pi-docket/ConvertX-CN/issues) (標題請加上 `[Commercial License Request]`)
- **GitHub Discussions**: [社群討論](https://github.com/pi-docket/ConvertX-CN/discussions)
- **GitHub Profile**: [@pi-docket](https://github.com/pi-docket)
> ⚠️ **Commercial Usage**: If you plan to use this project in a commercial product, SaaS, or revenue-generating service, you **must contact the author** for a license exception regarding the custom components. The AGPL obligations (sharing source code) still apply to the upstream portions.
Check [LICENSE-OVERVIEW.md](LICENSE-OVERVIEW.md) for full details.
📄 完整授權說明 → [LICENSE-OVERVIEW.md](LICENSE-OVERVIEW.md)

View file

@ -0,0 +1,439 @@
# PDF Packager — 多功能 PDF 處理引擎
## 概覽
**PDF Packager** 是 ConvertX-CN 的一個獨立引擎,專門處理 PDF 檔案的多種輸出格式轉換。使用者只需上傳 PDF選擇一個 chip輸出選項即可產生對應的最終檔案。
## 功能特點
- 🖼️ **圖片輸出**:將 PDF 頁面轉換為 PNG/JPG/JPEG 圖片並打包
- 📄 **圖片型 PDF**:將 PDF 轉換為純圖片組成的 PDF
- 📋 **PDF/A 標準**:轉換為長期保存標準的 PDF/A-1b 或 PDF/A-2b
- 🔒 **權限保護**:設定列印/修改權限(無密碼)
- ✍️ **數位簽章**:支援 PDF 數位簽章(可插拔介面)
- 📦 **批次打包**`all-*` 選項一次產生所有常用輸出
---
## Chip 命名規則
### 基本語法
```
<類型>-<DPI>[-<保護>][-s]
```
### DPI 選項
| DPI | 說明 |
| --- | -------------------------- |
| 150 | 低解析度(適合網頁預覽) |
| 300 | 標準解析度(適合一般用途) |
| 600 | 高解析度(適合印刷品質) |
### 保護選項
| 標記 | 說明 |
| ---- | ------------------ |
| `p` | 允許列印,禁止修改 |
| `np` | 禁止列印,禁止修改 |
### 簽章選項
| 標記 | 說明 |
| ---- | ------------ |
| `s` | 套用數位簽章 |
---
## 所有可用的 Chips完整清單
### A 圖片輸出(打包為 .tar
| Chip | 說明 | 輸出檔案 |
| ---------- | ------------ | ------------------- |
| `png-150` | PNG 150 DPI | `pack_png-150.tar` |
| `png-300` | PNG 300 DPI | `pack_png-300.tar` |
| `png-600` | PNG 600 DPI | `pack_png-600.tar` |
| `jpg-150` | JPG 150 DPI | `pack_jpg-150.tar` |
| `jpg-300` | JPG 300 DPI | `pack_jpg-300.tar` |
| `jpg-600` | JPG 600 DPI | `pack_jpg-600.tar` |
| `jpeg-150` | JPEG 150 DPI | `pack_jpeg-150.tar` |
| `jpeg-300` | JPEG 300 DPI | `pack_jpeg-300.tar` |
| `jpeg-600` | JPEG 600 DPI | `pack_jpeg-600.tar` |
### B 圖片型 PDF
| Chip | 說明 | 輸出檔案 |
| -------------- | ----------------- | ----------------------- |
| `pdf-150` | 圖片 PDF 150 DPI | `pack_pdf-150.pdf` |
| `pdf-300` | 圖片 PDF 300 DPI | `pack_pdf-300.pdf` |
| `pdf-600` | 圖片 PDF 600 DPI | `pack_pdf-600.pdf` |
| `pdf-150-p` | + 可列印 | `pack_pdf-150-p.pdf` |
| `pdf-150-np` | + 不可列印 | `pack_pdf-150-np.pdf` |
| `pdf-300-p` | + 可列印 | `pack_pdf-300-p.pdf` |
| `pdf-300-np` | + 不可列印 | `pack_pdf-300-np.pdf` |
| `pdf-600-p` | + 可列印 | `pack_pdf-600-p.pdf` |
| `pdf-600-np` | + 不可列印 | `pack_pdf-600-np.pdf` |
| `pdf-150-s` | + 簽章 | `pack_pdf-150-s.pdf` |
| `pdf-300-s` | + 簽章 | `pack_pdf-300-s.pdf` |
| `pdf-600-s` | + 簽章 | `pack_pdf-600-s.pdf` |
| `pdf-150-p-s` | + 可列印 + 簽章 | `pack_pdf-150-p-s.pdf` |
| `pdf-150-np-s` | + 不可列印 + 簽章 | `pack_pdf-150-np-s.pdf` |
| `pdf-300-p-s` | + 可列印 + 簽章 | `pack_pdf-300-p-s.pdf` |
| `pdf-300-np-s` | + 不可列印 + 簽章 | `pack_pdf-300-np-s.pdf` |
| `pdf-600-p-s` | + 可列印 + 簽章 | `pack_pdf-600-p-s.pdf` |
| `pdf-600-np-s` | + 不可列印 + 簽章 | `pack_pdf-600-np-s.pdf` |
### C PDF/A-1b
#### 來源 `i`(從圖片轉換)
| Chip | 說明 | 輸出檔案 |
| ------------------- | ----------------------- | ---------------------------- |
| `pdfa1b-i-150` | PDF/A-1b 從圖片 150 DPI | `pack_pdfa1b-i-150.pdf` |
| `pdfa1b-i-300` | PDF/A-1b 從圖片 300 DPI | `pack_pdfa1b-i-300.pdf` |
| `pdfa1b-i-600` | PDF/A-1b 從圖片 600 DPI | `pack_pdfa1b-i-600.pdf` |
| `pdfa1b-i-150-p` | + 可列印 | `pack_pdfa1b-i-150-p.pdf` |
| `pdfa1b-i-150-np` | + 不可列印 | `pack_pdfa1b-i-150-np.pdf` |
| `pdfa1b-i-300-p` | + 可列印 | `pack_pdfa1b-i-300-p.pdf` |
| `pdfa1b-i-300-np` | + 不可列印 | `pack_pdfa1b-i-300-np.pdf` |
| `pdfa1b-i-600-p` | + 可列印 | `pack_pdfa1b-i-600-p.pdf` |
| `pdfa1b-i-600-np` | + 不可列印 | `pack_pdfa1b-i-600-np.pdf` |
| `pdfa1b-i-150-s` | + 簽章 | `pack_pdfa1b-i-150-s.pdf` |
| `pdfa1b-i-300-s` | + 簽章 | `pack_pdfa1b-i-300-s.pdf` |
| `pdfa1b-i-600-s` | + 簽章 | `pack_pdfa1b-i-600-s.pdf` |
| `pdfa1b-i-150-p-s` | + 可列印 + 簽章 | `pack_pdfa1b-i-150-p-s.pdf` |
| `pdfa1b-i-150-np-s` | + 不可列印 + 簽章 | `pack_pdfa1b-i-150-np-s.pdf` |
| `pdfa1b-i-300-p-s` | + 可列印 + 簽章 | `pack_pdfa1b-i-300-p-s.pdf` |
| `pdfa1b-i-300-np-s` | + 不可列印 + 簽章 | `pack_pdfa1b-i-300-np-s.pdf` |
| `pdfa1b-i-600-p-s` | + 可列印 + 簽章 | `pack_pdfa1b-i-600-p-s.pdf` |
| `pdfa1b-i-600-np-s` | + 不可列印 + 簽章 | `pack_pdfa1b-i-600-np-s.pdf` |
#### 來源 `o`(從原始 PDF 轉換)
| Chip | 說明 | 輸出檔案 |
| ------------------- | ----------------------- | ---------------------------- |
| `pdfa1b-o-150` | PDF/A-1b 從原始 150 DPI | `pack_pdfa1b-o-150.pdf` |
| `pdfa1b-o-300` | PDF/A-1b 從原始 300 DPI | `pack_pdfa1b-o-300.pdf` |
| `pdfa1b-o-600` | PDF/A-1b 從原始 600 DPI | `pack_pdfa1b-o-600.pdf` |
| `pdfa1b-o-150-p` | + 可列印 | `pack_pdfa1b-o-150-p.pdf` |
| `pdfa1b-o-150-np` | + 不可列印 | `pack_pdfa1b-o-150-np.pdf` |
| `pdfa1b-o-300-p` | + 可列印 | `pack_pdfa1b-o-300-p.pdf` |
| `pdfa1b-o-300-np` | + 不可列印 | `pack_pdfa1b-o-300-np.pdf` |
| `pdfa1b-o-600-p` | + 可列印 | `pack_pdfa1b-o-600-p.pdf` |
| `pdfa1b-o-600-np` | + 不可列印 | `pack_pdfa1b-o-600-np.pdf` |
| `pdfa1b-o-150-s` | + 簽章 | `pack_pdfa1b-o-150-s.pdf` |
| `pdfa1b-o-300-s` | + 簽章 | `pack_pdfa1b-o-300-s.pdf` |
| `pdfa1b-o-600-s` | + 簽章 | `pack_pdfa1b-o-600-s.pdf` |
| `pdfa1b-o-150-p-s` | + 可列印 + 簽章 | `pack_pdfa1b-o-150-p-s.pdf` |
| `pdfa1b-o-150-np-s` | + 不可列印 + 簽章 | `pack_pdfa1b-o-150-np-s.pdf` |
| `pdfa1b-o-300-p-s` | + 可列印 + 簽章 | `pack_pdfa1b-o-300-p-s.pdf` |
| `pdfa1b-o-300-np-s` | + 不可列印 + 簽章 | `pack_pdfa1b-o-300-np-s.pdf` |
| `pdfa1b-o-600-p-s` | + 可列印 + 簽章 | `pack_pdfa1b-o-600-p-s.pdf` |
| `pdfa1b-o-600-np-s` | + 不可列印 + 簽章 | `pack_pdfa1b-o-600-np-s.pdf` |
### D PDF/A-2b
#### 來源 `i`(從圖片轉換)
| Chip | 說明 | 輸出檔案 |
| ------------------- | ----------------------- | ---------------------------- |
| `pdfa2b-i-150` | PDF/A-2b 從圖片 150 DPI | `pack_pdfa2b-i-150.pdf` |
| `pdfa2b-i-300` | PDF/A-2b 從圖片 300 DPI | `pack_pdfa2b-i-300.pdf` |
| `pdfa2b-i-600` | PDF/A-2b 從圖片 600 DPI | `pack_pdfa2b-i-600.pdf` |
| `pdfa2b-i-150-p` | + 可列印 | `pack_pdfa2b-i-150-p.pdf` |
| `pdfa2b-i-150-np` | + 不可列印 | `pack_pdfa2b-i-150-np.pdf` |
| `pdfa2b-i-300-p` | + 可列印 | `pack_pdfa2b-i-300-p.pdf` |
| `pdfa2b-i-300-np` | + 不可列印 | `pack_pdfa2b-i-300-np.pdf` |
| `pdfa2b-i-600-p` | + 可列印 | `pack_pdfa2b-i-600-p.pdf` |
| `pdfa2b-i-600-np` | + 不可列印 | `pack_pdfa2b-i-600-np.pdf` |
| `pdfa2b-i-150-s` | + 簽章 | `pack_pdfa2b-i-150-s.pdf` |
| `pdfa2b-i-300-s` | + 簽章 | `pack_pdfa2b-i-300-s.pdf` |
| `pdfa2b-i-600-s` | + 簽章 | `pack_pdfa2b-i-600-s.pdf` |
| `pdfa2b-i-150-p-s` | + 可列印 + 簽章 | `pack_pdfa2b-i-150-p-s.pdf` |
| `pdfa2b-i-150-np-s` | + 不可列印 + 簽章 | `pack_pdfa2b-i-150-np-s.pdf` |
| `pdfa2b-i-300-p-s` | + 可列印 + 簽章 | `pack_pdfa2b-i-300-p-s.pdf` |
| `pdfa2b-i-300-np-s` | + 不可列印 + 簽章 | `pack_pdfa2b-i-300-np-s.pdf` |
| `pdfa2b-i-600-p-s` | + 可列印 + 簽章 | `pack_pdfa2b-i-600-p-s.pdf` |
| `pdfa2b-i-600-np-s` | + 不可列印 + 簽章 | `pack_pdfa2b-i-600-np-s.pdf` |
#### 來源 `o`(從原始 PDF 轉換)
| Chip | 說明 | 輸出檔案 |
| ------------------- | ----------------------- | ---------------------------- |
| `pdfa2b-o-150` | PDF/A-2b 從原始 150 DPI | `pack_pdfa2b-o-150.pdf` |
| `pdfa2b-o-300` | PDF/A-2b 從原始 300 DPI | `pack_pdfa2b-o-300.pdf` |
| `pdfa2b-o-600` | PDF/A-2b 從原始 600 DPI | `pack_pdfa2b-o-600.pdf` |
| `pdfa2b-o-150-p` | + 可列印 | `pack_pdfa2b-o-150-p.pdf` |
| `pdfa2b-o-150-np` | + 不可列印 | `pack_pdfa2b-o-150-np.pdf` |
| `pdfa2b-o-300-p` | + 可列印 | `pack_pdfa2b-o-300-p.pdf` |
| `pdfa2b-o-300-np` | + 不可列印 | `pack_pdfa2b-o-300-np.pdf` |
| `pdfa2b-o-600-p` | + 可列印 | `pack_pdfa2b-o-600-p.pdf` |
| `pdfa2b-o-600-np` | + 不可列印 | `pack_pdfa2b-o-600-np.pdf` |
| `pdfa2b-o-150-s` | + 簽章 | `pack_pdfa2b-o-150-s.pdf` |
| `pdfa2b-o-300-s` | + 簽章 | `pack_pdfa2b-o-300-s.pdf` |
| `pdfa2b-o-600-s` | + 簽章 | `pack_pdfa2b-o-600-s.pdf` |
| `pdfa2b-o-150-p-s` | + 可列印 + 簽章 | `pack_pdfa2b-o-150-p-s.pdf` |
| `pdfa2b-o-150-np-s` | + 不可列印 + 簽章 | `pack_pdfa2b-o-150-np-s.pdf` |
| `pdfa2b-o-300-p-s` | + 可列印 + 簽章 | `pack_pdfa2b-o-300-p-s.pdf` |
| `pdfa2b-o-300-np-s` | + 不可列印 + 簽章 | `pack_pdfa2b-o-300-np-s.pdf` |
| `pdfa2b-o-600-p-s` | + 可列印 + 簽章 | `pack_pdfa2b-o-600-p-s.pdf` |
| `pdfa2b-o-600-np-s` | + 不可列印 + 簽章 | `pack_pdfa2b-o-600-np-s.pdf` |
### E 全部打包
| Chip | 說明 | 輸出檔案 |
| --------- | ---------------- | ------------------ |
| `all-150` | 全部格式 150 DPI | `pack_all-150.tar` |
| `all-300` | 全部格式 300 DPI | `pack_all-300.tar` |
| `all-600` | 全部格式 600 DPI | `pack_all-600.tar` |
**`all-*` 包含的輸出:**
- `png-<dpi>.tar`
- `jpg-<dpi>.tar`
- `jpeg-<dpi>.tar`
- `pdf-<dpi>.pdf`
- `pdf-<dpi>-p.pdf`
- `pdf-<dpi>-np.pdf`
- `pdfa1b-i-<dpi>.pdf`
- `pdfa1b-o-<dpi>.pdf`
- `pdfa2b-i-<dpi>.pdf`
- `pdfa2b-o-<dpi>.pdf`
---
## 處理管線Pipeline
### 圖片輸出 Pipeline
```
input.pdf
↓ pdftoppm (-r <dpi> -<format>)
work/imgs/page-0001.<ext>
work/imgs/page-0002.<ext>
...
↓ tar (-cf)
out/pack_<chip>.tar
↓ cleanup
刪除 work/imgs/*
```
### 圖片型 PDF Pipeline
```
input.pdf
↓ pdftoppm (-r <dpi> -png)
work/imgs/page-*.png
↓ img2pdf
work/pdf/image_based.pdf
↓ [可選] qpdf (權限保護)
work/pdf/protected.pdf
↓ [可選] sign (簽章)
work/pdf/signed.pdf
↓ copy
out/pack_<chip>.pdf
↓ cleanup
刪除 work/imgs/* 和 work/pdf/*
```
### PDF/A Pipeline來源 i
```
input.pdf
↓ pdftoppm (-r <dpi> -png)
work/imgs/page-*.png
↓ img2pdf
work/pdf/image_based.pdf
↓ gs (ghostscript, PDF/A-1b 或 2b)
work/pdf/pdfa.pdf
↓ [可選] qpdf (權限保護)
work/pdf/protected.pdf
↓ [可選] sign (簽章)
work/pdf/signed.pdf
↓ copy
out/pack_<chip>.pdf
↓ cleanup
刪除 work/imgs/* 和 work/pdf/*
```
### PDF/A Pipeline來源 o
```
input.pdf
↓ gs (ghostscript, PDF/A-1b 或 2b)
work/pdf/pdfa.pdf
↓ [可選] qpdf (權限保護)
work/pdf/protected.pdf
↓ [可選] sign (簽章)
work/pdf/signed.pdf
↓ copy
out/pack_<chip>.pdf
↓ cleanup
刪除 work/pdf/*
```
### all-\* Pipeline
```
input.pdf
↓ 依序執行各子 pipeline
out/all/pack_png-<dpi>.tar
out/all/pack_jpg-<dpi>.tar
out/all/pack_jpeg-<dpi>.tar
out/all/pack_pdf-<dpi>.pdf
out/all/pack_pdf-<dpi>-p.pdf
out/all/pack_pdf-<dpi>-np.pdf
out/all/pack_pdfa1b-i-<dpi>.pdf
out/all/pack_pdfa1b-o-<dpi>.pdf
out/all/pack_pdfa2b-i-<dpi>.pdf
out/all/pack_pdfa2b-o-<dpi>.pdf
↓ tar (-cf)
out/pack_all-<dpi>.tar
↓ cleanup
刪除 out/all/*
```
---
## 權限保護說明
使用 `qpdf` 設定 PDF 權限:
| 選項 | 列印 | 修改 | 密碼 |
| ---- | ------- | ------- | ---- |
| `p` | ✅ 允許 | ❌ 禁止 | 無 |
| `np` | ❌ 禁止 | ❌ 禁止 | 無 |
---
## 數位簽章
PDF Packager 支援使用 PKCS12 憑證對 PDF 進行數位簽章。簽章功能使用系統內建的 Python `endesive` 庫實現。
### 開箱即用
**無需任何配置!** Docker 映像在建置時已自動產生預設的自簽憑證,可直接使用簽章功能。
預設憑證資訊:
- 📁 路徑:`/app/certs/default.p12`
- 🔑 密碼:無(空密碼)
- 📅 有效期10 年
- 🏷️ 簽章主體:`CN=PDF Packager Default, O=ConvertX-CN, C=TW`
直接選擇帶有 `-s` 後綴的 chip 即可使用簽章功能,例如:
- `pdf-300-s` - 300 DPI PDF 加簽章
- `pdfa2b-o-300-s` - PDF/A-2b 加簽章
### 使用自訂憑證
如需使用正式憑證如公司數位憑證、CA 簽發憑證等),可透過環境變數覆蓋預設設定:
```bash
docker run -d \
-e PDF_SIGN_P12_PATH=/app/certs/my_certificate.p12 \
-e PDF_SIGN_P12_PASSWORD=your_password \
-e PDF_SIGN_REASON="文件已核准" \
-e PDF_SIGN_LOCATION="台北" \
-e PDF_SIGN_CONTACT="admin@example.com" \
-v /path/to/your/certs:/app/certs:ro \
convertx/convertx-cn:latest
```
### 環境變數說明
| 環境變數 | 預設值 | 說明 |
| ----------------------- | -------------------------- | ------------------- |
| `PDF_SIGN_P12_PATH` | `/app/certs/default.p12` | PKCS12 憑證檔案路徑 |
| `PDF_SIGN_P12_PASSWORD` | (空) | PKCS12 憑證密碼 |
| `PDF_SIGN_REASON` | `ConvertX-CN PDF Packager` | 簽章原因 |
| `PDF_SIGN_LOCATION` | `Taiwan` | 簽章地點 |
| `PDF_SIGN_CONTACT` | `convertx-cn@localhost` | 聯絡資訊 |
### 產生自訂憑證
如需產生自己的測試憑證:
```bash
# 產生自簽憑證(有效期 365 天)
openssl req -x509 -newkey rsa:2048 \
-keyout key.pem -out cert.pem \
-days 365 -nodes \
-subj "/CN=My Company/O=My Organization/C=TW"
# 匯出為 PKCS12 格式
openssl pkcs12 -export \
-inkey key.pem -in cert.pem \
-out my_certificate.p12 \
-passout pass:your_password
# 清理暫存檔
rm key.pem cert.pem
```
> 💡 **提示**:正式環境建議使用經過公認 CA 簽發的憑證,以確保簽章的法律效力。
---
## 工具需求
本引擎需要以下 CLI 工具(皆已內建於 Docker Image
| 工具 | 用途 | Docker 安裝狀態 |
| ------------------ | ------------ | --------------- |
| `pdftoppm` | PDF 轉圖片 | ✅ 已內建 |
| `img2pdf` | 圖片轉 PDF | ✅ 已內建 |
| `gs` (Ghostscript) | PDF/A 轉換 | ✅ 已內建 |
| `qpdf` | PDF 權限保護 | ✅ 已內建 |
| `tar` | 打包 | ✅ 已內建 |
| `endesive` | PDF 數位簽章 | ✅ 已內建 |
> 📦 所有工具皆在 Docker build 階段安裝runtime 不需下載任何軟體。
---
## 錯誤代碼
| 代碼 | 說明 |
| --------------------------- | -------------------- |
| `INVALID_CHIP` | 無效的 chip 名稱 |
| `INVALID_DPI` | 無效的 DPI 值 |
| `SIGNING_NOT_CONFIGURED` | 簽章憑證未配置 |
| `SIGNING_CERTIFICATE_ERROR` | 憑證讀取失敗 |
| `SIGNING_ERROR` | 簽章執行失敗 |
| `PDFTOPPM_ERROR` | pdftoppm 執行失敗 |
| `IMG2PDF_ERROR` | img2pdf 執行失敗 |
| `GHOSTSCRIPT_ERROR` | Ghostscript 執行失敗 |
| `QPDF_ERROR` | qpdf 執行失敗 |
| `TAR_ERROR` | tar 打包失敗 |
---
## 使用範例
### 1. 轉換為 300 DPI PNG 圖片
選擇 chip: `png-300`
結果: 下載 `pack_png-300.tar`,解壓後得到所有頁面的 PNG 圖片。
### 2. 建立不可列印的圖片型 PDF
選擇 chip: `pdf-300-np`
結果: 下載 `pack_pdf-300-np.pdf`,此 PDF 禁止列印和修改。
### 3. 轉換為 PDF/A-2b 標準
選擇 chip: `pdfa2b-o-300`
結果: 下載 `pack_pdfa2b-o-300.pdf`,符合 PDF/A-2b 長期保存標準。
### 4. 一次產生所有格式
選擇 chip: `all-300`
結果: 下載 `pack_all-300.tar`,包含 10 個不同格式的輸出檔案。

View file

@ -150,6 +150,37 @@ environment:
---
## 📝 PDF Packager 設定
### PDF 數位簽章
PDF Packager 預設**開箱即用**Docker 建置時已自動產生預設憑證。如需使用自訂憑證,可透過以下環境變數配置:
| 變數 | 預設值 | 說明 |
| ----------------------- | -------------------------- | ------------------- |
| `PDF_SIGN_P12_PATH` | `/app/certs/default.p12` | PKCS12 憑證檔案路徑 |
| `PDF_SIGN_P12_PASSWORD` | (空) | 憑證密碼 |
| `PDF_SIGN_REASON` | `ConvertX-CN PDF Packager` | 簽章原因 |
| `PDF_SIGN_LOCATION` | `Taiwan` | 簽章地點 |
| `PDF_SIGN_CONTACT` | `convertx-cn@localhost` | 聯絡資訊 |
**使用自訂憑證範例**
```yaml
environment:
- PDF_SIGN_P12_PATH=/app/certs/company.p12
- PDF_SIGN_P12_PASSWORD=your_password
- PDF_SIGN_REASON=文件已核准
- PDF_SIGN_LOCATION=台北
- PDF_SIGN_CONTACT=admin@company.com
volumes:
- /path/to/certs:/app/certs:ro
```
詳細說明請參考 [PDF Packager 說明](功能說明/PDF-Packager.md)。
---
## 相關文件
- [Docker 部署指南](部署指南/Docker部署.md)

View file

@ -1,6 +1,6 @@
{
"name": "ConvertX | Self Hosted File Converter",
"short_name": "ConvertX",
"name": "ConvertX-CN | Self Hosted File Converter",
"short_name": "ConvertX-CN",
"icons": [
{
"src": "/android-chrome-192x192.png",

129
scripts/pdf_sign.py Normal file
View file

@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""
PDF 數位簽章腳本
使用 endesive 庫對 PDF 進行數位簽章
環境變數
- PDF_SIGN_P12_PATH: PKCS12 憑證檔案路徑必須
- PDF_SIGN_P12_PASSWORD: PKCS12 憑證密碼選用預設為空
- PDF_SIGN_REASON: 簽章原因選用
- PDF_SIGN_LOCATION: 簽章地點選用
- PDF_SIGN_CONTACT: 聯絡資訊選用
用法
python3 pdf_sign.py <input.pdf> <output.pdf>
錯誤代碼
0: 成功
1: 參數錯誤
2: 憑證未配置
3: 憑證讀取失敗
4: 簽章失敗
"""
import os
import sys
from datetime import datetime
def main():
# 檢查參數
if len(sys.argv) != 3:
print("用法: python3 pdf_sign.py <input.pdf> <output.pdf>", file=sys.stderr)
sys.exit(1)
input_pdf = sys.argv[1]
output_pdf = sys.argv[2]
# 檢查環境變數(支援預設憑證)
p12_path = os.environ.get("PDF_SIGN_P12_PATH", "/app/certs/default.p12")
p12_password = os.environ.get("PDF_SIGN_P12_PASSWORD", "")
sign_reason = os.environ.get("PDF_SIGN_REASON", "ConvertX-CN PDF Packager")
sign_location = os.environ.get("PDF_SIGN_LOCATION", "ConvertX-CN")
sign_contact = os.environ.get("PDF_SIGN_CONTACT", "")
if not os.path.exists(p12_path):
print(f"錯誤: 憑證檔案不存在: {p12_path}", file=sys.stderr)
print("提示: Docker 環境預設使用 /app/certs/default.p12", file=sys.stderr)
print(" 如需使用自訂憑證,請設定 PDF_SIGN_P12_PATH 環境變數", file=sys.stderr)
sys.exit(2)
if not os.path.exists(input_pdf):
print(f"錯誤: 輸入檔案不存在: {input_pdf}", file=sys.stderr)
sys.exit(1)
try:
# 載入 endesive
from endesive.pdf import cms
from cryptography.hazmat.primitives.serialization import pkcs12
from cryptography.hazmat.backends import default_backend
# 讀取 PKCS12 憑證
print(f"[PDF Sign] 載入憑證: {p12_path}")
with open(p12_path, "rb") as f:
p12_data = f.read()
try:
private_key, certificate, additional_certs = pkcs12.load_key_and_certificates(
p12_data,
p12_password.encode() if p12_password else None,
default_backend()
)
except Exception as e:
print(f"錯誤: 無法載入憑證(密碼錯誤或格式不正確): {e}", file=sys.stderr)
sys.exit(3)
if not private_key or not certificate:
print("錯誤: 憑證中未找到私鑰或證書", file=sys.stderr)
sys.exit(3)
# 讀取 PDF
print(f"[PDF Sign] 讀取 PDF: {input_pdf}")
with open(input_pdf, "rb") as f:
pdf_data = f.read()
# 準備簽章參數
dct = {
"aligned": 0,
"sigflags": 3,
"sigflagsft": 132,
"sigpage": 0,
"sigbutton": False,
"contact": sign_contact,
"location": sign_location,
"signingdate": datetime.utcnow().strftime("%Y%m%d%H%M%S+00'00'"),
"reason": sign_reason,
}
# 執行簽章
print("[PDF Sign] 執行數位簽章...")
signed_data = cms.sign(
pdf_data,
dct,
private_key,
certificate,
additional_certs or [],
"sha256",
)
# 寫入輸出檔案
print(f"[PDF Sign] 寫入簽章 PDF: {output_pdf}")
with open(output_pdf, "wb") as f:
f.write(pdf_data)
f.write(signed_data)
print("[PDF Sign] ✅ 簽章完成")
sys.exit(0)
except ImportError as e:
print(f"錯誤: 缺少必要的 Python 庫: {e}", file=sys.stderr)
print("請確保已安裝 endesive: pip install endesive", file=sys.stderr)
sys.exit(4)
except Exception as e:
print(f"錯誤: 簽章失敗: {e}", file=sys.stderr)
sys.exit(4)
if __name__ == "__main__":
main()

View file

@ -33,6 +33,7 @@ import {
} from "./pdfmathtranslate";
import { convert as convertBabelDoc, properties as propertiesBabelDoc } from "./babeldoc";
import { convert as convertOcrMyPdf, properties as propertiesOcrMyPdf } from "./ocrmypdf";
import { convert as convertPdfPackager, properties as propertiesPdfPackager } from "./pdfpackager";
// This should probably be reconstructed so that the functions are not imported instead the functions hook into this to make the converters more modular
@ -162,6 +163,10 @@ const properties: Record<
properties: propertiesOcrMyPdf,
converter: convertOcrMyPdf,
},
"PDF Packager": {
properties: propertiesPdfPackager,
converter: convertPdfPackager,
},
deark: {
properties: propertiesDeark,
converter: convertDeark,

View file

@ -0,0 +1,876 @@
import { execFile as execFileOriginal } from "node:child_process";
import { mkdirSync, existsSync, readdirSync, unlinkSync, rmdirSync, copyFileSync } from "node:fs";
import { join, basename, dirname } from "node:path";
import type { ExecFileFn } from "./types";
/**
* PDF Packager Engine
*
* PDF使 chip
*
* chip
* A) .tar: png-*, jpg-*, jpeg-*
* B) PDF: pdf-*
* C) PDF/A-1b: pdfa1b-*
* D) PDF/A-2b: pdfa2b-*
* E) 全部打包: all-*
*
* Chip
* - DPI: 150, 300, 600
* - 保護: p (), np ()
* - 簽章: s ()
* - PDF/A 來源: i (), o ( PDF)
*/
// =============================================================================
// Chip 常數定義(手刻列出所有可選項目)
// =============================================================================
/**
* chips
*
*/
export const ALL_CHIPS = [
// A) 圖片輸出(打包 .tar
"png-150",
"png-300",
"png-600",
"jpg-150",
"jpg-300",
"jpg-600",
"jpeg-150",
"jpeg-300",
"jpeg-600",
// B) 圖片型 PDF
"pdf-150",
"pdf-300",
"pdf-600",
"pdf-150-p",
"pdf-150-np",
"pdf-300-p",
"pdf-300-np",
"pdf-600-p",
"pdf-600-np",
"pdf-150-s",
"pdf-300-s",
"pdf-600-s",
"pdf-150-p-s",
"pdf-150-np-s",
"pdf-300-p-s",
"pdf-300-np-s",
"pdf-600-p-s",
"pdf-600-np-s",
// C) PDF/A-1b來源 i
"pdfa1b-i-150",
"pdfa1b-i-300",
"pdfa1b-i-600",
"pdfa1b-i-150-p",
"pdfa1b-i-150-np",
"pdfa1b-i-300-p",
"pdfa1b-i-300-np",
"pdfa1b-i-600-p",
"pdfa1b-i-600-np",
"pdfa1b-i-150-s",
"pdfa1b-i-300-s",
"pdfa1b-i-600-s",
"pdfa1b-i-150-p-s",
"pdfa1b-i-150-np-s",
"pdfa1b-i-300-p-s",
"pdfa1b-i-300-np-s",
"pdfa1b-i-600-p-s",
"pdfa1b-i-600-np-s",
// C) PDF/A-1b來源 o
"pdfa1b-o-150",
"pdfa1b-o-300",
"pdfa1b-o-600",
"pdfa1b-o-150-p",
"pdfa1b-o-150-np",
"pdfa1b-o-300-p",
"pdfa1b-o-300-np",
"pdfa1b-o-600-p",
"pdfa1b-o-600-np",
"pdfa1b-o-150-s",
"pdfa1b-o-300-s",
"pdfa1b-o-600-s",
"pdfa1b-o-150-p-s",
"pdfa1b-o-150-np-s",
"pdfa1b-o-300-p-s",
"pdfa1b-o-300-np-s",
"pdfa1b-o-600-p-s",
"pdfa1b-o-600-np-s",
// D) PDF/A-2b來源 i
"pdfa2b-i-150",
"pdfa2b-i-300",
"pdfa2b-i-600",
"pdfa2b-i-150-p",
"pdfa2b-i-150-np",
"pdfa2b-i-300-p",
"pdfa2b-i-300-np",
"pdfa2b-i-600-p",
"pdfa2b-i-600-np",
"pdfa2b-i-150-s",
"pdfa2b-i-300-s",
"pdfa2b-i-600-s",
"pdfa2b-i-150-p-s",
"pdfa2b-i-150-np-s",
"pdfa2b-i-300-p-s",
"pdfa2b-i-300-np-s",
"pdfa2b-i-600-p-s",
"pdfa2b-i-600-np-s",
// D) PDF/A-2b來源 o
"pdfa2b-o-150",
"pdfa2b-o-300",
"pdfa2b-o-600",
"pdfa2b-o-150-p",
"pdfa2b-o-150-np",
"pdfa2b-o-300-p",
"pdfa2b-o-300-np",
"pdfa2b-o-600-p",
"pdfa2b-o-600-np",
"pdfa2b-o-150-s",
"pdfa2b-o-300-s",
"pdfa2b-o-600-s",
"pdfa2b-o-150-p-s",
"pdfa2b-o-150-np-s",
"pdfa2b-o-300-p-s",
"pdfa2b-o-300-np-s",
"pdfa2b-o-600-p-s",
"pdfa2b-o-600-np-s",
// E) 全部打包
"all-150",
"all-300",
"all-600",
] as const;
// =============================================================================
// 引擎屬性定義
// =============================================================================
export const properties = {
from: {
document: ["pdf"],
},
to: {
document: [...ALL_CHIPS] as string[],
},
// 某些 chip 輸出 .tarimages, all-*),其他輸出 .pdf
// PDF Packager 不使用 outputMode而是根據 chip 動態決定輸出類型
};
// =============================================================================
// Chip 解析器
// =============================================================================
/**
* Chip
*/
export interface ParsedChip {
kind: "images" | "pdf_image" | "pdfa" | "all";
dpi: 150 | 300 | 600;
// images only
imageFormat?: "png" | "jpg" | "jpeg" | undefined;
// pdfa only
pdfaLevel?: "1b" | "2b" | undefined;
pdfaSource?: "i" | "o" | undefined;
// for pdf outputs that can be protected
protect?: "p" | "np" | undefined; // print allowed / no print
sign?: boolean | undefined; // -s
// 原始 chip 名稱
rawChip: string;
}
/**
* chip
* @param chip chip
* @returns null
*/
export function parseChip(chip: string): ParsedChip | null {
// all-<dpi>
const allMatch = chip.match(/^all-(150|300|600)$/);
if (allMatch && allMatch[1]) {
return {
kind: "all",
dpi: Number.parseInt(allMatch[1], 10) as 150 | 300 | 600,
rawChip: chip,
};
}
// <img>-<dpi> (png, jpg, jpeg)
const imgMatch = chip.match(/^(png|jpg|jpeg)-(150|300|600)$/);
if (imgMatch && imgMatch[1] && imgMatch[2]) {
return {
kind: "images",
dpi: Number.parseInt(imgMatch[2], 10) as 150 | 300 | 600,
imageFormat: imgMatch[1] as "png" | "jpg" | "jpeg",
rawChip: chip,
};
}
// pdf-<dpi>[-<protect>][-s]
const pdfMatch = chip.match(/^pdf-(150|300|600)(?:-(p|np))?(?:-(s))?$/);
if (pdfMatch && pdfMatch[1]) {
return {
kind: "pdf_image",
dpi: Number.parseInt(pdfMatch[1], 10) as 150 | 300 | 600,
protect: (pdfMatch[2] as "p" | "np" | undefined) ?? undefined,
sign: pdfMatch[3] === "s",
rawChip: chip,
};
}
// pdfa<level>-<src>-<dpi>[-<protect>][-s]
const pdfaMatch = chip.match(/^pdfa(1b|2b)-(i|o)-(150|300|600)(?:-(p|np))?(?:-(s))?$/);
if (pdfaMatch && pdfaMatch[1] && pdfaMatch[2] && pdfaMatch[3]) {
return {
kind: "pdfa",
dpi: Number.parseInt(pdfaMatch[3], 10) as 150 | 300 | 600,
pdfaLevel: pdfaMatch[1] as "1b" | "2b",
pdfaSource: pdfaMatch[2] as "i" | "o",
protect: (pdfaMatch[4] as "p" | "np" | undefined) ?? undefined,
sign: pdfaMatch[5] === "s",
rawChip: chip,
};
}
return null;
}
/**
* archive
*/
export function isArchiveOutput(chip: string): boolean {
const parsed = parseChip(chip);
if (!parsed) return false;
return parsed.kind === "images" || parsed.kind === "all";
}
/**
*
*/
export function getOutputFileName(chip: string): string {
if (isArchiveOutput(chip)) {
return `pack_${chip}.tar`;
}
return `pack_${chip}.pdf`;
}
// =============================================================================
// 工具函式
// =============================================================================
/**
*
*/
function removeDir(dirPath: string): void {
if (existsSync(dirPath)) {
const files = readdirSync(dirPath, { withFileTypes: true });
for (const file of files) {
const filePath = join(dirPath, file.name);
if (file.isDirectory()) {
removeDir(filePath);
} else {
unlinkSync(filePath);
}
}
rmdirSync(dirPath);
}
}
/**
*
*/
function cleanDir(dirPath: string): void {
if (existsSync(dirPath)) {
const files = readdirSync(dirPath);
for (const file of files) {
const filePath = join(dirPath, file);
unlinkSync(filePath);
}
}
}
/**
* Promise
*/
function execCommand(
cmd: string,
args: string[],
execFile: ExecFileFn,
): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
execFile(cmd, args, (error, stdout, stderr) => {
if (error) {
reject(new Error(`${cmd} error: ${error.message}\nstderr: ${stderr}`));
return;
}
resolve({ stdout, stderr });
});
});
}
// =============================================================================
// Pipeline 處理函式
// =============================================================================
/**
* 使 pdftoppm PDF
* @param inputPdf PDF
* @param outputDir
* @param dpi DPI
* @param format (png, jpeg)
* @param execFile execFile
* @returns
*/
async function pdfToImages(
inputPdf: string,
outputDir: string,
dpi: number,
format: "png" | "jpeg",
execFile: ExecFileFn,
): Promise<string[]> {
console.log(`[PDFPackager] 📸 執行 pdftoppm (DPI: ${dpi}, 格式: ${format})`);
const outputPrefix = join(outputDir, "page");
// pdftoppm -r <dpi> -<format> <input.pdf> <output_prefix>
const args = ["-r", String(dpi)];
if (format === "png") {
args.push("-png");
} else {
args.push("-jpeg");
}
args.push(inputPdf, outputPrefix);
await execCommand("pdftoppm", args, execFile);
// 取得產生的圖片檔案
const files = readdirSync(outputDir)
.filter((f) => f.startsWith("page-") && (f.endsWith(".png") || f.endsWith(".jpg")))
.sort()
.map((f) => join(outputDir, f));
console.log(`[PDFPackager] ✅ 產生 ${files.length} 張圖片`);
return files;
}
/**
* 使 img2pdf PDF
* @param imageFiles
* @param outputPdf PDF
* @param execFile execFile
*/
async function imagesToPdf(
imageFiles: string[],
outputPdf: string,
execFile: ExecFileFn,
): Promise<void> {
console.log(`[PDFPackager] 📄 執行 img2pdf (${imageFiles.length} 張圖片)`);
// img2pdf <images...> -o <output.pdf>
const args = [...imageFiles, "-o", outputPdf];
await execCommand("img2pdf", args, execFile);
console.log(`[PDFPackager] ✅ 產生圖片型 PDF`);
}
/**
* 使 Ghostscript PDF/A
* @param inputPdf PDF
* @param outputPdf PDF
* @param level PDF/A (1b, 2b)
* @param execFile execFile
*/
async function convertToPdfA(
inputPdf: string,
outputPdf: string,
level: "1b" | "2b",
execFile: ExecFileFn,
): Promise<void> {
console.log(`[PDFPackager] 📋 執行 Ghostscript (PDF/A-${level})`);
// 根據 level 選擇 PDF/A 標準
const pdfaProfile = level === "1b" ? "1" : "2";
// gs -dPDFA=<level> -dBATCH -dNOPAUSE -sColorConversionStrategy=UseDeviceIndependentColor
// -sDEVICE=pdfwrite -dPDFACompatibilityPolicy=1 -sOutputFile=<output> <input>
const args = [
`-dPDFA=${pdfaProfile}`,
"-dBATCH",
"-dNOPAUSE",
"-dQUIET",
"-sColorConversionStrategy=UseDeviceIndependentColor",
"-sDEVICE=pdfwrite",
"-dPDFACompatibilityPolicy=1",
`-sOutputFile=${outputPdf}`,
inputPdf,
];
await execCommand("gs", args, execFile);
console.log(`[PDFPackager] ✅ 產生 PDF/A-${level}`);
}
/**
* 使 qpdf PDF
* @param inputPdf PDF
* @param outputPdf PDF
* @param allowPrint
* @param execFile execFile
*/
async function protectPdf(
inputPdf: string,
outputPdf: string,
allowPrint: boolean,
execFile: ExecFileFn,
): Promise<void> {
console.log(`[PDFPackager] 🔒 執行 qpdf (允許列印: ${allowPrint})`);
// qpdf --encrypt "" "" 256 --modify=none [--print=none] -- <input> <output>
const args = ["--encrypt", "", "", "256", "--modify=none"];
if (!allowPrint) {
args.push("--print=none");
}
args.push("--", inputPdf, outputPdf);
await execCommand("qpdf", args, execFile);
console.log(`[PDFPackager] ✅ PDF 權限保護已設定`);
}
/**
* 使 Python endesive PDF
*
*
* - PDF_SIGN_P12_PATH: /app/certs/default.p12
* - PDF_SIGN_P12_PASSWORD:
*
* 使
* - PDF_SIGN_P12_PATH: PKCS12
* - PDF_SIGN_P12_PASSWORD: PKCS12
* - PDF_SIGN_REASON: 簽章原因
* - PDF_SIGN_LOCATION: 簽章地點
* - PDF_SIGN_CONTACT: 聯絡資訊
*
* @param inputPdf PDF
* @param outputPdf PDF
* @param execFile execFile
*/
async function signPdf(inputPdf: string, outputPdf: string, execFile: ExecFileFn): Promise<void> {
console.log(`[PDFPackager] ✍️ 執行 PDF 數位簽章`);
// 使用預設憑證或自訂憑證
const p12Path = process.env.PDF_SIGN_P12_PATH || "/app/certs/default.p12";
// 檢查憑證是否存在(僅在非測試環境檢查)
if (!existsSync(p12Path) && process.env.NODE_ENV !== "test") {
throw new Error(
"SIGNING_NOT_CONFIGURED: 數位簽章憑證不存在。\n" +
`憑證路徑: ${p12Path}\n` +
"請確認 Docker 環境已正確安裝,或指定自訂憑證路徑:\n" +
" PDF_SIGN_P12_PATH=/path/to/your/certificate.p12",
);
}
// 使用 Python 腳本進行簽章
// 腳本路徑:/app/scripts/pdf_sign.pyDocker 環境)或相對於專案根目錄
const scriptPath = process.env.PDF_SIGN_SCRIPT_PATH || "/app/scripts/pdf_sign.py";
try {
await execCommand("python3", [scriptPath, inputPdf, outputPdf], execFile);
console.log(`[PDFPackager] ✅ PDF 數位簽章完成`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// 檢查是否為憑證未配置錯誤
if (errorMessage.includes("PDF_SIGN_P12_PATH") || errorMessage.includes("未設定")) {
throw new Error(
"SIGNING_NOT_CONFIGURED: 數位簽章功能需要配置 PKCS12 憑證。\n" +
"請設定以下環境變數:\n" +
" PDF_SIGN_P12_PATH=/path/to/certificate.p12\n" +
" PDF_SIGN_P12_PASSWORD=your_password (選用)",
);
}
// 檢查是否為憑證讀取錯誤
if (errorMessage.includes("密碼錯誤") || errorMessage.includes("格式不正確")) {
throw new Error(`SIGNING_CERTIFICATE_ERROR: 無法讀取 PKCS12 憑證。${errorMessage}`);
}
throw new Error(`SIGNING_ERROR: PDF 簽章失敗。${errorMessage}`);
}
}
/**
* .tar
* @param sourceDir
* @param outputTar tar
* @param execFile execFile
*/
async function createTarArchive(
sourceDir: string,
outputTar: string,
execFile: ExecFileFn,
): Promise<void> {
console.log(`[PDFPackager] 📦 建立 tar 打包`);
// tar -cf <output.tar> -C <sourceDir> .
await execCommand("tar", ["-cf", outputTar, "-C", sourceDir, "."], execFile);
console.log(`[PDFPackager] ✅ tar 打包完成`);
}
// =============================================================================
// 主要 Pipeline 處理器
// =============================================================================
/**
* pipeline (png-*, jpg-*, jpeg-*)
*/
async function processImagesPipeline(
inputPdf: string,
workDir: string,
outDir: string,
parsed: ParsedChip,
execFile: ExecFileFn,
): Promise<string> {
const imgsDir = join(workDir, "imgs");
mkdirSync(imgsDir, { recursive: true });
// Step 1: pdftoppm 產生圖片
const format = parsed.imageFormat === "png" ? "png" : "jpeg";
const imageFiles = await pdfToImages(inputPdf, imgsDir, parsed.dpi, format, execFile);
// 如果是 jpg需要重新命名副檔名
if (parsed.imageFormat === "jpg") {
for (const imgFile of imageFiles) {
if (imgFile.endsWith(".jpg")) continue;
const newPath = imgFile.replace(/\.jpeg$/, ".jpg");
copyFileSync(imgFile, newPath);
unlinkSync(imgFile);
}
}
// Step 2: tar 打包
const outputTar = join(outDir, getOutputFileName(parsed.rawChip));
await createTarArchive(imgsDir, outputTar, execFile);
// Step 3: 清理暫存
cleanDir(imgsDir);
removeDir(imgsDir);
return outputTar;
}
/**
* PDF pipeline (pdf-*)
*/
async function processPdfImagePipeline(
inputPdf: string,
workDir: string,
outDir: string,
parsed: ParsedChip,
execFile: ExecFileFn,
): Promise<string> {
const imgsDir = join(workDir, "imgs");
const pdfDir = join(workDir, "pdf");
mkdirSync(imgsDir, { recursive: true });
mkdirSync(pdfDir, { recursive: true });
let currentPdf: string;
// Step 1: pdftoppm 產生圖片
const imageFiles = await pdfToImages(inputPdf, imgsDir, parsed.dpi, "png", execFile);
// Step 2: img2pdf 合成 PDF
currentPdf = join(pdfDir, "image_based.pdf");
await imagesToPdf(imageFiles, currentPdf, execFile);
// Step 3: (可選) 權限保護
if (parsed.protect) {
const protectedPdf = join(pdfDir, "protected.pdf");
await protectPdf(currentPdf, protectedPdf, parsed.protect === "p", execFile);
currentPdf = protectedPdf;
}
// Step 4: (可選) 簽章
if (parsed.sign) {
const signedPdf = join(pdfDir, "signed.pdf");
await signPdf(currentPdf, signedPdf, execFile);
currentPdf = signedPdf;
}
// Step 5: 輸出
const outputPdf = join(outDir, getOutputFileName(parsed.rawChip));
copyFileSync(currentPdf, outputPdf);
// Step 6: 清理
cleanDir(imgsDir);
removeDir(imgsDir);
cleanDir(pdfDir);
removeDir(pdfDir);
return outputPdf;
}
/**
* PDF/A pipeline (pdfa1b-*, pdfa2b-*)
*/
async function processPdfAPipeline(
inputPdf: string,
workDir: string,
outDir: string,
parsed: ParsedChip,
execFile: ExecFileFn,
): Promise<string> {
const imgsDir = join(workDir, "imgs");
const pdfDir = join(workDir, "pdf");
mkdirSync(pdfDir, { recursive: true });
let currentPdf: string;
if (parsed.pdfaSource === "i") {
// 從圖片產生
mkdirSync(imgsDir, { recursive: true });
// Step 1: pdftoppm 產生圖片
const imageFiles = await pdfToImages(inputPdf, imgsDir, parsed.dpi, "png", execFile);
// Step 2: img2pdf 合成 PDF
const imagePdf = join(pdfDir, "image_based.pdf");
await imagesToPdf(imageFiles, imagePdf, execFile);
// Step 3: 轉換為 PDF/A
currentPdf = join(pdfDir, "pdfa.pdf");
await convertToPdfA(imagePdf, currentPdf, parsed.pdfaLevel!, execFile);
// 清理圖片暫存
cleanDir(imgsDir);
removeDir(imgsDir);
} else {
// 從原始 PDF 直接轉換
// Step 1: 轉換為 PDF/A
currentPdf = join(pdfDir, "pdfa.pdf");
await convertToPdfA(inputPdf, currentPdf, parsed.pdfaLevel!, execFile);
}
// Step 4: (可選) 權限保護
if (parsed.protect) {
const protectedPdf = join(pdfDir, "protected.pdf");
await protectPdf(currentPdf, protectedPdf, parsed.protect === "p", execFile);
currentPdf = protectedPdf;
}
// Step 5: (可選) 簽章
if (parsed.sign) {
const signedPdf = join(pdfDir, "signed.pdf");
await signPdf(currentPdf, signedPdf, execFile);
currentPdf = signedPdf;
}
// Step 6: 輸出
const outputPdf = join(outDir, getOutputFileName(parsed.rawChip));
copyFileSync(currentPdf, outputPdf);
// Step 7: 清理
cleanDir(pdfDir);
removeDir(pdfDir);
return outputPdf;
}
/**
* all-<dpi> chips
*
*/
function getAllSubChips(dpi: 150 | 300 | 600): string[] {
return [
// 圖片
`png-${dpi}`,
`jpg-${dpi}`,
`jpeg-${dpi}`,
// 圖片型 PDF
`pdf-${dpi}`,
`pdf-${dpi}-p`,
`pdf-${dpi}-np`,
// PDF/A
`pdfa1b-i-${dpi}`,
`pdfa1b-o-${dpi}`,
`pdfa2b-i-${dpi}`,
`pdfa2b-o-${dpi}`,
];
}
/**
* all-* pipeline
*/
async function processAllPipeline(
inputPdf: string,
workDir: string,
outDir: string,
parsed: ParsedChip,
execFile: ExecFileFn,
): Promise<string> {
const allDir = join(outDir, "all");
mkdirSync(allDir, { recursive: true });
const subChips = getAllSubChips(parsed.dpi);
console.log(`[PDFPackager] 📦 all-${parsed.dpi}: 處理 ${subChips.length} 個子項目`);
for (const subChip of subChips) {
const subParsed = parseChip(subChip);
if (!subParsed) continue;
console.log(`[PDFPackager] ▶ 處理子項目: ${subChip}`);
try {
let outputFile: string;
switch (subParsed.kind) {
case "images":
outputFile = await processImagesPipeline(inputPdf, workDir, allDir, subParsed, execFile);
break;
case "pdf_image":
outputFile = await processPdfImagePipeline(
inputPdf,
workDir,
allDir,
subParsed,
execFile,
);
break;
case "pdfa":
outputFile = await processPdfAPipeline(inputPdf, workDir, allDir, subParsed, execFile);
break;
default:
continue;
}
console.log(`[PDFPackager] ✅ ${subChip} 完成: ${basename(outputFile)}`);
} catch (error) {
console.error(`[PDFPackager] ❌ ${subChip} 失敗: ${error}`);
// 繼續處理其他子項目
}
}
// 打包 all 目錄
const outputTar = join(outDir, getOutputFileName(parsed.rawChip));
await createTarArchive(allDir, outputTar, execFile);
// 清理
removeDir(allDir);
return outputTar;
}
// =============================================================================
// 主要轉換函數
// =============================================================================
/**
* PDF Packager
*
* @param filePath PDF
* @param fileType "pdf"
* @param convertTo chip
* @param targetPath
* @param _options
* @param execFile execFile
*/
export async function convert(
filePath: string,
fileType: string,
convertTo: string,
targetPath: string,
_options?: unknown,
execFile: ExecFileFn = execFileOriginal,
): Promise<string> {
console.log(``);
console.log(`[PDFPackager] ╔════════════════════════════════════════╗`);
console.log(`[PDFPackager] ║ PDF Packager - 多功能 PDF 處理 ║`);
console.log(`[PDFPackager] ╚════════════════════════════════════════╝`);
console.log(``);
// Step 1: 解析 chip
console.log(`[PDFPackager] 🔧 解析 chip: ${convertTo}`);
const parsed = parseChip(convertTo);
if (!parsed) {
throw new Error(`INVALID_CHIP: 無效的 chip 名稱: ${convertTo}`);
}
console.log(`[PDFPackager] 類型: ${parsed.kind}`);
console.log(`[PDFPackager] DPI: ${parsed.dpi}`);
if (parsed.imageFormat) console.log(`[PDFPackager] 圖片格式: ${parsed.imageFormat}`);
if (parsed.pdfaLevel) console.log(`[PDFPackager] PDF/A 級別: ${parsed.pdfaLevel}`);
if (parsed.pdfaSource) console.log(`[PDFPackager] PDF/A 來源: ${parsed.pdfaSource}`);
if (parsed.protect) console.log(`[PDFPackager] 權限保護: ${parsed.protect}`);
if (parsed.sign) console.log(`[PDFPackager] 簽章: ${parsed.sign}`);
// Step 2: 準備工作目錄
const outputDir = dirname(targetPath);
const inputFileName = basename(filePath, `.${fileType}`);
const workDir = join(outputDir, `_pdfpackager_work_${inputFileName}_${Date.now()}`);
const outDir = outputDir;
mkdirSync(workDir, { recursive: true });
console.log(`[PDFPackager] 📁 工作目錄: ${workDir}`);
try {
let outputFile: string;
// Step 3: 根據 chip 類型執行對應 pipeline
switch (parsed.kind) {
case "images":
outputFile = await processImagesPipeline(filePath, workDir, outDir, parsed, execFile);
break;
case "pdf_image":
outputFile = await processPdfImagePipeline(filePath, workDir, outDir, parsed, execFile);
break;
case "pdfa":
outputFile = await processPdfAPipeline(filePath, workDir, outDir, parsed, execFile);
break;
case "all":
outputFile = await processAllPipeline(filePath, workDir, outDir, parsed, execFile);
break;
default:
throw new Error(`INVALID_CHIP_KIND: 未知的 chip 類型`);
}
console.log(``);
console.log(`[PDFPackager] ╔════════════════════════════════════════╗`);
console.log(`[PDFPackager] ║ ✅ PDF Packager 處理完成! ║`);
console.log(`[PDFPackager] ╚════════════════════════════════════════╝`);
console.log(`[PDFPackager] 輸出: ${basename(outputFile)}`);
console.log(``);
return "Done";
} catch (error) {
console.log(``);
console.log(`[PDFPackager] ╔════════════════════════════════════════╗`);
console.log(`[PDFPackager] ║ ❌ PDF Packager 處理失敗 ║`);
console.log(`[PDFPackager] ╚════════════════════════════════════════╝`);
console.log(`[PDFPackager] 錯誤: ${error}`);
console.log(``);
throw error;
} finally {
// 清理工作目錄
try {
removeDir(workDir);
console.log(`[PDFPackager] 🧹 已清理工作目錄`);
} catch (e) {
console.warn(`[PDFPackager] ⚠️ 清理工作目錄失敗: ${e}`);
}
}
}

View file

@ -0,0 +1,504 @@
import { test, expect, describe, beforeEach, afterEach } from "bun:test";
import {
convert,
properties,
parseChip,
isArchiveOutput,
getOutputFileName,
ALL_CHIPS,
} from "../../src/converters/pdfpackager";
import type { ExecFileException } from "node:child_process";
import type { ExecFileFn } from "../../src/converters/types";
import { mkdirSync, existsSync, writeFileSync, rmSync, readFileSync } from "node:fs";
import { join } from "node:path";
// =============================================================================
// 屬性測試
// =============================================================================
describe("PDF Packager converter properties", () => {
test("should have correct input format (pdf only)", () => {
expect(properties.from.document).toContain("pdf");
expect(properties.from.document).toHaveLength(1);
});
test("should have all chips as output formats", () => {
expect(properties.to.document).toHaveLength(ALL_CHIPS.length);
// 驗證部分代表性 chips
expect(properties.to.document).toContain("png-300");
expect(properties.to.document).toContain("pdf-600-np-s");
expect(properties.to.document).toContain("pdfa1b-i-300-p");
expect(properties.to.document).toContain("pdfa2b-o-600");
expect(properties.to.document).toContain("all-300");
});
test("should have correct number of chips (102 total)", () => {
// A) 圖片: 3格式 × 3dpi = 9
// B) PDF: 3dpi × 6 variants = 18
// C) PDF/A-1b: 2source × 3dpi × 6 = 36
// D) PDF/A-2b: 2source × 3dpi × 6 = 36
// E) all: 3
// 總計: 9 + 18 + 36 + 36 + 3 = 102
expect(ALL_CHIPS.length).toBe(102);
});
});
// =============================================================================
// Chip 解析測試
// =============================================================================
describe("parseChip function", () => {
describe("images chips", () => {
test("should parse png-300", () => {
const result = parseChip("png-300");
expect(result).not.toBeNull();
expect(result?.kind).toBe("images");
expect(result?.dpi).toBe(300);
expect(result?.imageFormat).toBe("png");
});
test("should parse jpg-150", () => {
const result = parseChip("jpg-150");
expect(result).not.toBeNull();
expect(result?.kind).toBe("images");
expect(result?.dpi).toBe(150);
expect(result?.imageFormat).toBe("jpg");
});
test("should parse jpeg-600", () => {
const result = parseChip("jpeg-600");
expect(result).not.toBeNull();
expect(result?.kind).toBe("images");
expect(result?.dpi).toBe(600);
expect(result?.imageFormat).toBe("jpeg");
});
});
describe("pdf_image chips", () => {
test("should parse pdf-300", () => {
const result = parseChip("pdf-300");
expect(result).not.toBeNull();
expect(result?.kind).toBe("pdf_image");
expect(result?.dpi).toBe(300);
expect(result?.protect).toBeUndefined();
expect(result?.sign).toBe(false);
});
test("should parse pdf-300-p", () => {
const result = parseChip("pdf-300-p");
expect(result).not.toBeNull();
expect(result?.kind).toBe("pdf_image");
expect(result?.dpi).toBe(300);
expect(result?.protect).toBe("p");
expect(result?.sign).toBe(false);
});
test("should parse pdf-600-np-s", () => {
const result = parseChip("pdf-600-np-s");
expect(result).not.toBeNull();
expect(result?.kind).toBe("pdf_image");
expect(result?.dpi).toBe(600);
expect(result?.protect).toBe("np");
expect(result?.sign).toBe(true);
});
test("should parse pdf-150-s (sign without protect)", () => {
const result = parseChip("pdf-150-s");
expect(result).not.toBeNull();
expect(result?.kind).toBe("pdf_image");
expect(result?.dpi).toBe(150);
expect(result?.protect).toBeUndefined();
expect(result?.sign).toBe(true);
});
});
describe("pdfa chips", () => {
test("should parse pdfa1b-i-300", () => {
const result = parseChip("pdfa1b-i-300");
expect(result).not.toBeNull();
expect(result?.kind).toBe("pdfa");
expect(result?.pdfaLevel).toBe("1b");
expect(result?.pdfaSource).toBe("i");
expect(result?.dpi).toBe(300);
});
test("should parse pdfa2b-o-600-np-s", () => {
const result = parseChip("pdfa2b-o-600-np-s");
expect(result).not.toBeNull();
expect(result?.kind).toBe("pdfa");
expect(result?.pdfaLevel).toBe("2b");
expect(result?.pdfaSource).toBe("o");
expect(result?.dpi).toBe(600);
expect(result?.protect).toBe("np");
expect(result?.sign).toBe(true);
});
test("should parse pdfa1b-o-150-p", () => {
const result = parseChip("pdfa1b-o-150-p");
expect(result).not.toBeNull();
expect(result?.kind).toBe("pdfa");
expect(result?.pdfaLevel).toBe("1b");
expect(result?.pdfaSource).toBe("o");
expect(result?.dpi).toBe(150);
expect(result?.protect).toBe("p");
});
});
describe("all chips", () => {
test("should parse all-300", () => {
const result = parseChip("all-300");
expect(result).not.toBeNull();
expect(result?.kind).toBe("all");
expect(result?.dpi).toBe(300);
});
test("should parse all-150", () => {
const result = parseChip("all-150");
expect(result).not.toBeNull();
expect(result?.kind).toBe("all");
expect(result?.dpi).toBe(150);
});
test("should parse all-600", () => {
const result = parseChip("all-600");
expect(result).not.toBeNull();
expect(result?.kind).toBe("all");
expect(result?.dpi).toBe(600);
});
});
describe("invalid chips", () => {
test("should return null for invalid chip", () => {
expect(parseChip("invalid")).toBeNull();
expect(parseChip("png-400")).toBeNull(); // invalid DPI
expect(parseChip("pdf-300-x")).toBeNull(); // invalid protect
expect(parseChip("pdfa3b-i-300")).toBeNull(); // invalid level
expect(parseChip("all-300-p")).toBeNull(); // all doesn't support protect
});
});
});
// =============================================================================
// 輔助函式測試
// =============================================================================
describe("isArchiveOutput function", () => {
test("should return true for image chips", () => {
expect(isArchiveOutput("png-300")).toBe(true);
expect(isArchiveOutput("jpg-600")).toBe(true);
expect(isArchiveOutput("jpeg-150")).toBe(true);
});
test("should return true for all chips", () => {
expect(isArchiveOutput("all-150")).toBe(true);
expect(isArchiveOutput("all-300")).toBe(true);
expect(isArchiveOutput("all-600")).toBe(true);
});
test("should return false for pdf chips", () => {
expect(isArchiveOutput("pdf-300")).toBe(false);
expect(isArchiveOutput("pdf-300-p")).toBe(false);
expect(isArchiveOutput("pdf-600-np-s")).toBe(false);
});
test("should return false for pdfa chips", () => {
expect(isArchiveOutput("pdfa1b-i-300")).toBe(false);
expect(isArchiveOutput("pdfa2b-o-600-np-s")).toBe(false);
});
});
describe("getOutputFileName function", () => {
test("should return .tar for image chips", () => {
expect(getOutputFileName("png-300")).toBe("pack_png-300.tar");
expect(getOutputFileName("jpg-600")).toBe("pack_jpg-600.tar");
});
test("should return .tar for all chips", () => {
expect(getOutputFileName("all-300")).toBe("pack_all-300.tar");
});
test("should return .pdf for pdf chips", () => {
expect(getOutputFileName("pdf-300")).toBe("pack_pdf-300.pdf");
expect(getOutputFileName("pdf-600-np-s")).toBe("pack_pdf-600-np-s.pdf");
});
test("should return .pdf for pdfa chips", () => {
expect(getOutputFileName("pdfa1b-i-300")).toBe("pack_pdfa1b-i-300.pdf");
expect(getOutputFileName("pdfa2b-o-600-np-s")).toBe("pack_pdfa2b-o-600-np-s.pdf");
});
});
// =============================================================================
// 轉換測試(使用 mock execFile
// =============================================================================
describe("PDF Packager convert function", () => {
const testDir = "./test-output-pdfpackager";
beforeEach(() => {
if (!existsSync(testDir)) {
mkdirSync(testDir, { recursive: true });
}
});
afterEach(() => {
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true });
}
});
test("should process png-300 chip correctly", async () => {
const commands: Array<{ cmd: string; args: string[] }> = [];
const mockExecFile: ExecFileFn = (
cmd: string,
args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
commands.push({ cmd, args });
if (cmd === "pdftoppm") {
// Simulate creating image files
const outputPrefix = args[args.length - 1];
const outputDir = join(outputPrefix, "..");
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
}
writeFileSync(`${outputPrefix}-01.png`, "fake png content");
writeFileSync(`${outputPrefix}-02.png`, "fake png content");
callback(null, "", "");
} else if (cmd === "tar") {
// Simulate tar creation
const outputTar = args[1]; // -cf <output>
writeFileSync(outputTar, "fake tar content");
callback(null, "", "");
} else {
callback(null, "", "");
}
};
const inputPdf = join(testDir, "input.pdf");
writeFileSync(inputPdf, "fake pdf content");
const targetPath = join(testDir, "output");
await convert(inputPdf, "pdf", "png-300", targetPath, undefined, mockExecFile);
// Verify pdftoppm was called with correct arguments
const pdftoppmCall = commands.find((c) => c.cmd === "pdftoppm");
expect(pdftoppmCall).toBeDefined();
expect(pdftoppmCall?.args).toContain("-r");
expect(pdftoppmCall?.args).toContain("300");
expect(pdftoppmCall?.args).toContain("-png");
// Verify tar was called
const tarCall = commands.find((c) => c.cmd === "tar");
expect(tarCall).toBeDefined();
expect(tarCall?.args).toContain("-cf");
});
test("should process pdf-300-np chip correctly (with qpdf)", async () => {
const commands: Array<{ cmd: string; args: string[] }> = [];
const mockExecFile: ExecFileFn = (
cmd: string,
args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
commands.push({ cmd, args });
if (cmd === "pdftoppm") {
const outputPrefix = args[args.length - 1];
const outputDir = join(outputPrefix, "..");
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
}
writeFileSync(`${outputPrefix}-01.png`, "fake png");
callback(null, "", "");
} else if (cmd === "img2pdf") {
const outputIndex = args.indexOf("-o");
if (outputIndex !== -1) {
const outputPdf = args[outputIndex + 1];
const outputDir = join(outputPdf, "..");
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
}
writeFileSync(outputPdf, "fake pdf");
}
callback(null, "", "");
} else if (cmd === "qpdf") {
const outputPdf = args[args.length - 1];
writeFileSync(outputPdf, "fake protected pdf");
callback(null, "", "");
} else {
callback(null, "", "");
}
};
const inputPdf = join(testDir, "input.pdf");
writeFileSync(inputPdf, "fake pdf content");
const targetPath = join(testDir, "output");
await convert(inputPdf, "pdf", "pdf-300-np", targetPath, undefined, mockExecFile);
// Verify qpdf was called with no-print option
const qpdfCall = commands.find((c) => c.cmd === "qpdf");
expect(qpdfCall).toBeDefined();
expect(qpdfCall?.args).toContain("--print=none");
expect(qpdfCall?.args).toContain("--modify=none");
});
test("should process pdfa2b-o-300 chip correctly (from original)", async () => {
const commands: Array<{ cmd: string; args: string[] }> = [];
const mockExecFile: ExecFileFn = (
cmd: string,
args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
commands.push({ cmd, args });
if (cmd === "gs") {
// Find output file from args
const outputArg = args.find((a) => a.startsWith("-sOutputFile="));
if (outputArg) {
const outputPdf = outputArg.replace("-sOutputFile=", "");
const outputDir = join(outputPdf, "..");
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
}
writeFileSync(outputPdf, "fake pdfa pdf");
}
callback(null, "", "");
} else {
callback(null, "", "");
}
};
const inputPdf = join(testDir, "input.pdf");
writeFileSync(inputPdf, "fake pdf content");
const targetPath = join(testDir, "output");
await convert(inputPdf, "pdf", "pdfa2b-o-300", targetPath, undefined, mockExecFile);
// Verify gs was called with PDF/A-2 option
const gsCall = commands.find((c) => c.cmd === "gs");
expect(gsCall).toBeDefined();
expect(gsCall?.args).toContain("-dPDFA=2");
// Verify pdftoppm was NOT called (source=o means from original)
const pdftoppmCall = commands.find((c) => c.cmd === "pdftoppm");
expect(pdftoppmCall).toBeUndefined();
});
test("should process pdf-300-s chip correctly (with signing)", async () => {
const mockExecFile: ExecFileFn = (
cmd: string,
args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
if (cmd === "pdftoppm") {
const outputPrefix = args[args.length - 1];
const outputDir = join(outputPrefix, "..");
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
}
writeFileSync(`${outputPrefix}-01.png`, "fake png");
callback(null, "", "");
} else if (cmd === "img2pdf") {
const outputIndex = args.indexOf("-o");
if (outputIndex !== -1) {
const outputPdf = args[outputIndex + 1];
const outputDir = join(outputPdf, "..");
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
}
writeFileSync(outputPdf, "fake pdf");
}
callback(null, "", "");
} else if (cmd === "python3") {
// Mock Python signing script - creates signed.pdf
const inputPdf = args[1];
const outputPdf = args[2];
const outputDir = join(outputPdf, "..");
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
}
// Copy input to output with "signed" marker
if (existsSync(inputPdf)) {
writeFileSync(outputPdf, "signed pdf content");
}
callback(null, "", "");
} else {
callback(null, "", "");
}
};
const inputPdf = join(testDir, "input.pdf");
writeFileSync(inputPdf, "fake pdf content");
const targetPath = join(testDir, "output");
await convert(inputPdf, "pdf", "pdf-300-s", targetPath, undefined, mockExecFile);
// Verify output file exists (outDir is dirname(targetPath) = testDir)
const expectedOutput = join(testDir, "pack_pdf-300-s.pdf");
expect(existsSync(expectedOutput)).toBe(true);
// Verify file content contains signed marker
const content = readFileSync(expectedOutput, "utf-8");
expect(content).toContain("signed");
});
test("should throw error for invalid chip", async () => {
const mockExecFile: ExecFileFn = (
_cmd: string,
_args: string[],
callback: (err: ExecFileException | null, stdout: string, stderr: string) => void,
) => {
callback(null, "", "");
};
const inputPdf = join(testDir, "input.pdf");
writeFileSync(inputPdf, "fake pdf content");
const targetPath = join(testDir, "output");
await expect(
convert(inputPdf, "pdf", "invalid-chip", targetPath, undefined, mockExecFile),
).rejects.toThrow("INVALID_CHIP");
});
});
// =============================================================================
// Chip 完整性測試
// =============================================================================
describe("ALL_CHIPS completeness", () => {
test("all chips should be parseable", () => {
for (const chip of ALL_CHIPS) {
const parsed = parseChip(chip);
expect(parsed).not.toBeNull();
expect(parsed?.rawChip).toBe(chip);
}
});
test("should contain all expected image chips", () => {
const imageChips = ALL_CHIPS.filter((c) => parseChip(c)?.kind === "images");
expect(imageChips).toHaveLength(9); // 3 formats × 3 dpis
});
test("should contain all expected pdf_image chips", () => {
const pdfChips = ALL_CHIPS.filter((c) => parseChip(c)?.kind === "pdf_image");
expect(pdfChips).toHaveLength(18); // 3 dpis × 6 variants
});
test("should contain all expected pdfa chips", () => {
const pdfaChips = ALL_CHIPS.filter((c) => parseChip(c)?.kind === "pdfa");
expect(pdfaChips).toHaveLength(72); // 2 levels × 2 sources × 3 dpis × 6 variants
});
test("should contain all expected all chips", () => {
const allChips = ALL_CHIPS.filter((c) => parseChip(c)?.kind === "all");
expect(allChips).toHaveLength(3); // 3 dpis
});
});