新增 .docx 範本支援:jszip 跨 run 佔位符替換
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
112
package-lock.json
generated
112
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = '(?:</w:t></w:r>(?:<w:bookmarkStart[^/]*/?>|<w:bookmarkEnd[^/]*/?>)*<w:r>(?:<w:rPr>[\\s\\S]*?</w:rPr>)?<w:t[^>]*>)?';
|
||||
|
||||
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();
|
||||
|
||||
BIN
templates/租屋契約-內容_逢甲 A.docx
Normal file
BIN
templates/租屋契約-內容_逢甲 A.docx
Normal file
Binary file not shown.
Reference in New Issue
Block a user