新增 .docx 範本支援:jszip 跨 run 佔位符替換

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 14:37:33 +08:00
parent e1fcf3eb77
commit ef770070a7
4 changed files with 160 additions and 6 deletions

View File

@@ -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();