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:
parent
87bd318303
commit
944fe18b75
11 changed files with 2233 additions and 8 deletions
46
Dockerfile
46
Dockerfile
|
|
@ -142,6 +142,7 @@ RUN echo "" && \
|
||||||
mupdf-tools \
|
mupdf-tools \
|
||||||
poppler-utils \
|
poppler-utils \
|
||||||
potrace \
|
potrace \
|
||||||
|
qpdf \
|
||||||
&& rm -rf /var/lib/apt/lists/* && \
|
&& rm -rf /var/lib/apt/lists/* && \
|
||||||
echo "✅ 階段 2/11 完成:核心轉換工具已安裝"
|
echo "✅ 階段 2/11 完成:核心轉換工具已安裝"
|
||||||
|
|
||||||
|
|
@ -319,6 +320,7 @@ RUN echo "" && \
|
||||||
python3-numpy \
|
python3-numpy \
|
||||||
python3-tinycss2 \
|
python3-tinycss2 \
|
||||||
python3-opencv \
|
python3-opencv \
|
||||||
|
python3-img2pdf \
|
||||||
pipx \
|
pipx \
|
||||||
&& rm -rf /var/lib/apt/lists/* && \
|
&& rm -rf /var/lib/apt/lists/* && \
|
||||||
echo "✅ 階段 11/11 完成:Python 依賴已安裝" && \
|
echo "✅ 階段 11/11 完成:Python 依賴已安裝" && \
|
||||||
|
|
@ -327,6 +329,35 @@ RUN echo "" && \
|
||||||
echo "✅ 所有 APT 套件安裝完成!" && \
|
echo "✅ 所有 APT 套件安裝完成!" && \
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# 🔐 階段 11.1:PDF 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-UNIFIED:Python 工具安裝 + 模型下載(單一 RUN 原則)
|
# 🔥 階段 12-UNIFIED:Python 工具安裝 + 模型下載(單一 RUN 原則)
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|
@ -352,6 +383,15 @@ ENV PIP_NO_CACHE_DIR=1
|
||||||
# HuggingFace 環境變數(安裝時允許下載,安裝完成後設為離線)
|
# HuggingFace 環境變數(安裝時允許下載,安裝完成後設為離線)
|
||||||
ENV HF_HOME="/root/.cache/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 && \
|
RUN set -eux && \
|
||||||
echo "===========================================================" && \
|
echo "===========================================================" && \
|
||||||
echo "🚀 階段 12-UNIFIED:Python 工具 + 模型統一安裝" && \
|
echo "🚀 階段 12-UNIFIED:Python 工具 + 模型統一安裝" && \
|
||||||
|
|
@ -361,11 +401,11 @@ RUN set -eux && \
|
||||||
echo "===========================================================" && \
|
echo "===========================================================" && \
|
||||||
\
|
\
|
||||||
# ========================================
|
# ========================================
|
||||||
# [1/8] 安裝 huggingface_hub(用於顯式模型下載)
|
# [1/8] 安裝 huggingface_hub + endesive(用於顯式模型下載和 PDF 簽章)
|
||||||
# ========================================
|
# ========================================
|
||||||
echo "" && \
|
echo "" && \
|
||||||
echo "📦 [1/8] 安裝 huggingface_hub..." && \
|
echo "📦 [1/8] 安裝 huggingface_hub + endesive(PDF 簽章)..." && \
|
||||||
pip3 install --no-cache-dir --break-system-packages huggingface_hub && \
|
pip3 install --no-cache-dir --break-system-packages huggingface_hub endesive && \
|
||||||
\
|
\
|
||||||
# ========================================
|
# ========================================
|
||||||
# [2/8] 安裝 markitdown(文件轉換工具)
|
# [2/8] 安裝 markitdown(文件轉換工具)
|
||||||
|
|
|
||||||
65
LICENSE-AUTHOR
Normal file
65
LICENSE-AUTHOR
Normal 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
125
LICENSE-OVERVIEW.md
Normal 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)
|
||||||
17
README.md
17
README.md
|
|
@ -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)**.
|
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.
|
> ⚠️ **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)
|
||||||
|
|
|
||||||
439
docs/功能說明/PDF-Packager.md
Normal file
439
docs/功能說明/PDF-Packager.md
Normal 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 個不同格式的輸出檔案。
|
||||||
|
|
@ -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)
|
- [Docker 部署指南](部署指南/Docker部署.md)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "ConvertX | Self Hosted File Converter",
|
"name": "ConvertX-CN | Self Hosted File Converter",
|
||||||
"short_name": "ConvertX",
|
"short_name": "ConvertX-CN",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/android-chrome-192x192.png",
|
"src": "/android-chrome-192x192.png",
|
||||||
|
|
|
||||||
129
scripts/pdf_sign.py
Normal file
129
scripts/pdf_sign.py
Normal 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()
|
||||||
|
|
@ -33,6 +33,7 @@ import {
|
||||||
} from "./pdfmathtranslate";
|
} from "./pdfmathtranslate";
|
||||||
import { convert as convertBabelDoc, properties as propertiesBabelDoc } from "./babeldoc";
|
import { convert as convertBabelDoc, properties as propertiesBabelDoc } from "./babeldoc";
|
||||||
import { convert as convertOcrMyPdf, properties as propertiesOcrMyPdf } from "./ocrmypdf";
|
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
|
// 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,
|
properties: propertiesOcrMyPdf,
|
||||||
converter: convertOcrMyPdf,
|
converter: convertOcrMyPdf,
|
||||||
},
|
},
|
||||||
|
"PDF Packager": {
|
||||||
|
properties: propertiesPdfPackager,
|
||||||
|
converter: convertPdfPackager,
|
||||||
|
},
|
||||||
deark: {
|
deark: {
|
||||||
properties: propertiesDeark,
|
properties: propertiesDeark,
|
||||||
converter: convertDeark,
|
converter: convertDeark,
|
||||||
|
|
|
||||||
876
src/converters/pdfpackager.ts
Normal file
876
src/converters/pdfpackager.ts
Normal 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 輸出 .tar(images, 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.py(Docker 環境)或相對於專案根目錄
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
504
tests/converters/pdfpackager.test.ts
Normal file
504
tests/converters/pdfpackager.test.ts
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue