新增 .docx 範本支援:jszip 跨 run 佔位符替換
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user