diff --git a/package-lock.json b/package-lock.json index 0762d2c..64fb85f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,9 @@ "name": "rental-contract-pdf", "version": "1.0.0", "dependencies": { - "express": "^4.21.2" + "adm-zip": "^0.5.17", + "express": "^4.21.2", + "jszip": "^3.10.1" }, "engines": { "node": ">=20" @@ -27,6 +29,15 @@ "node": ">= 0.6" } }, + "node_modules/adm-zip": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", + "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -131,6 +142,12 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -429,6 +446,12 @@ "node": ">=0.10.0" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -444,6 +467,33 @@ "node": ">= 0.10" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -552,6 +602,12 @@ "node": ">= 0.8" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -567,6 +623,12 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -619,6 +681,27 @@ "node": ">= 0.8" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -690,6 +773,12 @@ "node": ">= 0.8.0" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -777,6 +866,21 @@ "node": ">= 0.8" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -808,6 +912,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/package.json b/package.json index 429a362..9845e9b 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "dev": "node --watch server.js" }, "dependencies": { - "express": "^4.21.2" + "adm-zip": "^0.5.17", + "express": "^4.21.2", + "jszip": "^3.10.1" }, "engines": { "node": ">=20" diff --git a/src/contractService.js b/src/contractService.js index 09633d1..bbe86d1 100644 --- a/src/contractService.js +++ b/src/contractService.js @@ -4,11 +4,16 @@ const os = require('node:os'); const path = require('node:path'); const { spawn } = require('node:child_process'); const { pathToFileURL } = require('node:url'); +const JSZip = require('jszip'); const TEMPLATE_DIR = path.resolve(process.env.TEMPLATE_DIR || path.join(process.cwd(), 'templates')); const TEMP_DIR = path.resolve(process.env.TEMP_DIR || path.join(os.tmpdir(), 'rental-contracts')); -const SOFFICE_BIN = process.env.SOFFICE_BIN || 'soffice'; -const SUPPORTED_TEMPLATE_EXTENSIONS = new Set(['.doc']); +const SOFFICE_BIN = process.env.SOFFICE_BIN || ( + process.platform === 'win32' + ? 'C:\\Program Files\\LibreOffice\\program\\soffice.exe' + : 'soffice' +); +const SUPPORTED_TEMPLATE_EXTENSIONS = new Set(['.doc', '.docx']); const PLACEHOLDERS = [ ['{{每月租金}}', 'monthlyRent', '每月租金'], @@ -37,7 +42,10 @@ async function createContractPdf(input) { const copiedDocPath = path.join(workDir, `contract-${crypto.randomUUID()}${extension}`); await fs.copyFile(templatePath, copiedDocPath); - const replacementCounts = await applyPlaceholdersToDoc(copiedDocPath, values); + const ext = path.extname(copiedDocPath).toLowerCase(); + const replacementCounts = ext === '.docx' + ? await applyPlaceholdersToDocx(copiedDocPath, values) + : await applyPlaceholdersToDoc(copiedDocPath, values); assertAllPlaceholdersWereFound(replacementCounts); const pdfPath = await convertToPdf(copiedDocPath, workDir); @@ -93,7 +101,7 @@ async function resolveTemplatePath(templateName) { } if (!SUPPORTED_TEMPLATE_EXTENSIONS.has(path.extname(templatePath).toLowerCase())) { - throwClientError('目前僅支援 .doc 範本。'); + throwClientError('目前僅支援 .doc / .docx 範本。'); } try { @@ -121,6 +129,40 @@ async function removeWorkDir(workDir) { await fs.rm(workDir, { recursive: true, force: true }); } +// Word 會把佔位符拆成多個 run,用這個 pattern 允許相鄰字元之間出現 run 邊界 XML +const RUN_BOUNDARY = '(?:(?:|)*(?:[\\s\\S]*?)?]*>)?'; + +function buildPlaceholderPattern(placeholder) { + const chars = [...placeholder].map((c) => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + return new RegExp(chars.join(RUN_BOUNDARY), 'g'); +} + +async function applyPlaceholdersToDocx(docPath, values) { + const data = await fs.readFile(docPath); + const zip = await JSZip.loadAsync(data); + const xmlFiles = ['word/document.xml', 'word/header1.xml', 'word/footer1.xml']; + const counts = new Map(PLACEHOLDERS.map(([p]) => [p, 0])); + + for (const xmlFile of xmlFiles) { + const entry = zip.file(xmlFile); + if (!entry) continue; + + let xml = await entry.async('string'); + for (const [placeholder, key] of PLACEHOLDERS) { + const pattern = buildPlaceholderPattern(placeholder); + xml = xml.replace(pattern, () => { + counts.set(placeholder, counts.get(placeholder) + 1); + return values[key]; + }); + } + zip.file(xmlFile, xml); + } + + const out = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }); + await fs.writeFile(docPath, out); + return counts; +} + async function applyPlaceholdersToDoc(docPath, values) { const buffer = await fs.readFile(docPath); const counts = new Map(); diff --git a/templates/租屋契約-內容_逢甲 A.docx b/templates/租屋契約-內容_逢甲 A.docx new file mode 100644 index 0000000..8326acc Binary files /dev/null and b/templates/租屋契約-內容_逢甲 A.docx differ