初始化專案:租屋契約 PDF 產生器

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 12:04:54 +08:00
commit e1fcf3eb77
13 changed files with 1768 additions and 0 deletions

260
src/contractService.js Normal file
View 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,
};