初始化專案:租屋契約 PDF 產生器
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
260
src/contractService.js
Normal file
260
src/contractService.js
Normal file
@@ -0,0 +1,260 @@
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
|
||||
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 PLACEHOLDERS = [
|
||||
['{{每月租金}}', 'monthlyRent', '每月租金'],
|
||||
['{{繳款日期}}', 'paymentDay', '繳款日期'],
|
||||
['{{保證金}}', 'deposit', '保證金'],
|
||||
];
|
||||
|
||||
async function listTemplates() {
|
||||
await fs.mkdir(TEMPLATE_DIR, { recursive: true });
|
||||
const entries = await fs.readdir(TEMPLATE_DIR, { withFileTypes: true });
|
||||
|
||||
return entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.filter((entry) => SUPPORTED_TEMPLATE_EXTENSIONS.has(path.extname(entry.name).toLowerCase()))
|
||||
.map((entry) => entry.name)
|
||||
.sort((a, b) => a.localeCompare(b, 'zh-Hant'));
|
||||
}
|
||||
|
||||
async function createContractPdf(input) {
|
||||
const values = normalizeContractInput(input);
|
||||
const templatePath = await resolveTemplatePath(input.template);
|
||||
const workDir = await createWorkDir();
|
||||
|
||||
try {
|
||||
const extension = path.extname(templatePath);
|
||||
const copiedDocPath = path.join(workDir, `contract-${crypto.randomUUID()}${extension}`);
|
||||
|
||||
await fs.copyFile(templatePath, copiedDocPath);
|
||||
const replacementCounts = await applyPlaceholdersToDoc(copiedDocPath, values);
|
||||
assertAllPlaceholdersWereFound(replacementCounts);
|
||||
|
||||
const pdfPath = await convertToPdf(copiedDocPath, workDir);
|
||||
const buffer = await fs.readFile(pdfPath);
|
||||
|
||||
return {
|
||||
buffer,
|
||||
fileName: buildPdfFileName(templatePath),
|
||||
};
|
||||
} finally {
|
||||
await removeWorkDir(workDir);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeContractInput(input) {
|
||||
return {
|
||||
monthlyRent: normalizePositiveInteger(input.monthlyRent, '每月租金'),
|
||||
paymentDay: normalizePaymentDay(input.paymentDay),
|
||||
deposit: normalizePositiveInteger(input.deposit, '保證金'),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value, label) {
|
||||
const text = String(value ?? '').trim();
|
||||
if (!/^[1-9]\d*$/.test(text)) {
|
||||
throwClientError(`${label}只能輸入正整數。`);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function normalizePaymentDay(value) {
|
||||
const text = String(value ?? '').trim();
|
||||
const day = Number(text);
|
||||
if (!/^\d+$/.test(text) || day < 1 || day > 31) {
|
||||
throwClientError('繳款日期只能輸入 1 到 31。');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
async function resolveTemplatePath(templateName) {
|
||||
const name = String(templateName ?? '').trim();
|
||||
if (!name) {
|
||||
throwClientError('請選擇 Word 範本。');
|
||||
}
|
||||
|
||||
if (name !== path.basename(name)) {
|
||||
throwClientError('範本檔名不正確。');
|
||||
}
|
||||
|
||||
const templatePath = path.resolve(TEMPLATE_DIR, name);
|
||||
if (!isInsideDirectory(TEMPLATE_DIR, templatePath)) {
|
||||
throwClientError('範本路徑不正確。');
|
||||
}
|
||||
|
||||
if (!SUPPORTED_TEMPLATE_EXTENSIONS.has(path.extname(templatePath).toLowerCase())) {
|
||||
throwClientError('目前僅支援 .doc 範本。');
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(templatePath);
|
||||
} catch {
|
||||
throwClientError('找不到指定的 Word 範本。');
|
||||
}
|
||||
|
||||
return templatePath;
|
||||
}
|
||||
|
||||
async function createWorkDir() {
|
||||
const workDir = path.resolve(TEMP_DIR, crypto.randomUUID());
|
||||
if (!isInsideDirectory(TEMP_DIR, workDir)) {
|
||||
throw new Error('Temporary directory resolved outside of TEMP_DIR.');
|
||||
}
|
||||
await fs.mkdir(workDir, { recursive: true });
|
||||
return workDir;
|
||||
}
|
||||
|
||||
async function removeWorkDir(workDir) {
|
||||
if (!workDir || !isInsideDirectory(TEMP_DIR, workDir)) {
|
||||
return;
|
||||
}
|
||||
await fs.rm(workDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function applyPlaceholdersToDoc(docPath, values) {
|
||||
const buffer = await fs.readFile(docPath);
|
||||
const counts = new Map();
|
||||
|
||||
for (const [placeholder, key, label] of PLACEHOLDERS) {
|
||||
const value = values[key];
|
||||
if ([...value].length > [...placeholder].length) {
|
||||
throwClientError(`${label}的字數不可超過 ${[...placeholder].length} 個字。`);
|
||||
}
|
||||
|
||||
counts.set(placeholder, replaceUtf16Le(buffer, placeholder, value));
|
||||
}
|
||||
|
||||
await fs.writeFile(docPath, buffer);
|
||||
return counts;
|
||||
}
|
||||
|
||||
function replaceUtf16Le(buffer, placeholder, value) {
|
||||
const search = Buffer.from(placeholder, 'utf16le');
|
||||
const replacement = Buffer.from(value.padEnd([...placeholder].length, ' '), 'utf16le');
|
||||
let count = 0;
|
||||
let index = buffer.indexOf(search);
|
||||
|
||||
while (index !== -1) {
|
||||
replacement.copy(buffer, index);
|
||||
count += 1;
|
||||
index = buffer.indexOf(search, index + replacement.length);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
function assertAllPlaceholdersWereFound(replacementCounts) {
|
||||
const missing = [...replacementCounts.entries()]
|
||||
.filter(([, count]) => count === 0)
|
||||
.map(([placeholder]) => placeholder);
|
||||
|
||||
if (missing.length > 0) {
|
||||
throwClientError(`範本缺少佔位符:${missing.join('、')}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function convertToPdf(inputPath, outputDir) {
|
||||
const libreOfficeProfileDir = path.join(outputDir, 'lo-profile');
|
||||
await fs.mkdir(libreOfficeProfileDir, { recursive: true });
|
||||
|
||||
const args = [
|
||||
`-env:UserInstallation=${pathToFileURL(libreOfficeProfileDir).href}`,
|
||||
'--headless',
|
||||
'--nologo',
|
||||
'--nofirststartwizard',
|
||||
'--nolockcheck',
|
||||
'--convert-to',
|
||||
'pdf',
|
||||
'--outdir',
|
||||
outputDir,
|
||||
inputPath,
|
||||
];
|
||||
|
||||
await runProcess(SOFFICE_BIN, args);
|
||||
|
||||
const expectedPdfPath = path.join(
|
||||
outputDir,
|
||||
`${path.basename(inputPath, path.extname(inputPath))}.pdf`,
|
||||
);
|
||||
|
||||
try {
|
||||
await fs.access(expectedPdfPath);
|
||||
return expectedPdfPath;
|
||||
} catch {
|
||||
const files = await fs.readdir(outputDir);
|
||||
const pdf = files.find((file) => path.extname(file).toLowerCase() === '.pdf');
|
||||
if (pdf) {
|
||||
return path.join(outputDir, pdf);
|
||||
}
|
||||
throw new Error('LibreOffice did not create a PDF file.');
|
||||
}
|
||||
}
|
||||
|
||||
function runProcess(command, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
windowsHide: true,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
reject(new Error(`無法執行 LibreOffice (${command}):${error.message}`));
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`LibreOffice 轉檔失敗:${stderr || stdout || `exit code ${code}`}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildPdfFileName(templatePath) {
|
||||
const baseName = path.basename(templatePath, path.extname(templatePath));
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, '')
|
||||
.replace(/\.\d{3}Z$/, '');
|
||||
|
||||
return `${baseName}-${timestamp}.pdf`;
|
||||
}
|
||||
|
||||
function isInsideDirectory(parentDir, targetPath) {
|
||||
const relative = path.relative(path.resolve(parentDir), path.resolve(targetPath));
|
||||
return Boolean(relative) && !relative.startsWith('..') && !path.isAbsolute(relative);
|
||||
}
|
||||
|
||||
function throwClientError(message) {
|
||||
const error = new Error(message);
|
||||
error.exposeToClient = true;
|
||||
throw error;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createContractPdf,
|
||||
listTemplates,
|
||||
};
|
||||
Reference in New Issue
Block a user