diff --git a/Dockerfile b/Dockerfile index c4703e4..898e8ab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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.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 原則) # ============================================================================== @@ -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-UNIFIED:Python 工具 + 模型統一安裝" && \ @@ -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 + endesive(PDF 簽章)..." && \ + pip3 install --no-cache-dir --break-system-packages huggingface_hub endesive && \ \ # ======================================== # [2/8] 安裝 markitdown(文件轉換工具) diff --git a/LICENSE-AUTHOR b/LICENSE-AUTHOR new file mode 100644 index 0000000..277b114 --- /dev/null +++ b/LICENSE-AUTHOR @@ -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. diff --git a/LICENSE-OVERVIEW.md b/LICENSE-OVERVIEW.md new file mode 100644 index 0000000..8ee32d0 --- /dev/null +++ b/LICENSE-OVERVIEW.md @@ -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) diff --git a/README.md b/README.md index deebf54..4119e41 100644 --- a/README.md +++ b/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)**. -- **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) diff --git a/docs/功能說明/PDF-Packager.md b/docs/功能說明/PDF-Packager.md new file mode 100644 index 0000000..7d3ccb0 --- /dev/null +++ b/docs/功能說明/PDF-Packager.md @@ -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 命名規則 + +### 基本語法 + +``` +<類型>-[-<保護>][-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-.tar` +- `jpg-.tar` +- `jpeg-.tar` +- `pdf-.pdf` +- `pdf--p.pdf` +- `pdf--np.pdf` +- `pdfa1b-i-.pdf` +- `pdfa1b-o-.pdf` +- `pdfa2b-i-.pdf` +- `pdfa2b-o-.pdf` + +--- + +## 處理管線(Pipeline) + +### 圖片輸出 Pipeline + +``` +input.pdf + ↓ pdftoppm (-r -) +work/imgs/page-0001. +work/imgs/page-0002. + ... + ↓ tar (-cf) +out/pack_.tar + ↓ cleanup +刪除 work/imgs/* +``` + +### 圖片型 PDF Pipeline + +``` +input.pdf + ↓ pdftoppm (-r -png) +work/imgs/page-*.png + ↓ img2pdf +work/pdf/image_based.pdf + ↓ [可選] qpdf (權限保護) +work/pdf/protected.pdf + ↓ [可選] sign (簽章) +work/pdf/signed.pdf + ↓ copy +out/pack_.pdf + ↓ cleanup +刪除 work/imgs/* 和 work/pdf/* +``` + +### PDF/A Pipeline(來源 i) + +``` +input.pdf + ↓ pdftoppm (-r -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_.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_.pdf + ↓ cleanup +刪除 work/pdf/* +``` + +### all-\* Pipeline + +``` +input.pdf + ↓ 依序執行各子 pipeline +out/all/pack_png-.tar +out/all/pack_jpg-.tar +out/all/pack_jpeg-.tar +out/all/pack_pdf-.pdf +out/all/pack_pdf--p.pdf +out/all/pack_pdf--np.pdf +out/all/pack_pdfa1b-i-.pdf +out/all/pack_pdfa1b-o-.pdf +out/all/pack_pdfa2b-i-.pdf +out/all/pack_pdfa2b-o-.pdf + ↓ tar (-cf) +out/pack_all-.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 個不同格式的輸出檔案。 diff --git a/docs/環境變數總覽.md b/docs/環境變數總覽.md index adbca12..7c4d8f1 100644 --- a/docs/環境變數總覽.md +++ b/docs/環境變數總覽.md @@ -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) diff --git a/public/site.webmanifest b/public/site.webmanifest index 09691b5..fed067f 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -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", diff --git a/scripts/pdf_sign.py b/scripts/pdf_sign.py new file mode 100644 index 0000000..5cb7cad --- /dev/null +++ b/scripts/pdf_sign.py @@ -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 + +錯誤代碼: + 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 ", 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() diff --git a/src/converters/main.ts b/src/converters/main.ts index 4dff1c8..930c71c 100644 --- a/src/converters/main.ts +++ b/src/converters/main.ts @@ -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, diff --git a/src/converters/pdfpackager.ts b/src/converters/pdfpackager.ts new file mode 100644 index 0000000..659ca63 --- /dev/null +++ b/src/converters/pdfpackager.ts @@ -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- + 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, + }; + } + + // - (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-[-][-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--[-][-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 { + console.log(`[PDFPackager] 📸 執行 pdftoppm (DPI: ${dpi}, 格式: ${format})`); + + const outputPrefix = join(outputDir, "page"); + + // pdftoppm -r - + 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 { + console.log(`[PDFPackager] 📄 執行 img2pdf (${imageFiles.length} 張圖片)`); + + // img2pdf -o + 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 { + console.log(`[PDFPackager] 📋 執行 Ghostscript (PDF/A-${level})`); + + // 根據 level 選擇 PDF/A 標準 + const pdfaProfile = level === "1b" ? "1" : "2"; + + // gs -dPDFA= -dBATCH -dNOPAUSE -sColorConversionStrategy=UseDeviceIndependentColor + // -sDEVICE=pdfwrite -dPDFACompatibilityPolicy=1 -sOutputFile= + 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 { + console.log(`[PDFPackager] 🔒 執行 qpdf (允許列印: ${allowPrint})`); + + // qpdf --encrypt "" "" 256 --modify=none [--print=none] -- + 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 { + 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 { + console.log(`[PDFPackager] 📦 建立 tar 打包`); + + // tar -cf -C . + 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 { + 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 { + 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 { + 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- 包含的子 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 { + 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 { + 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}`); + } + } +} diff --git a/tests/converters/pdfpackager.test.ts b/tests/converters/pdfpackager.test.ts new file mode 100644 index 0000000..02cff96 --- /dev/null +++ b/tests/converters/pdfpackager.test.ts @@ -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 + 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 + }); +});