P0-1/P0-2/P1-1: dimensions form + E2E tests + PDF export

P0-1 Backend: dimensions column on template entity + validation
P0-1 Frontend: dimensions edit UI in TemplateManager
P0-2: routeAfterGrading unit tests (10 cases), service spec fix + certificate tests, jest-e2e.json
P1-1: proper PDF generation with embedded CJK font via pdf-lib low-level API
This commit is contained in:
Developer
2026-05-19 08:42:03 +08:00
parent 0b0a060967
commit 68371922ca
18 changed files with 663 additions and 72 deletions
@@ -0,0 +1,146 @@
import * as fs from 'fs';
import * as path from 'path';
import { PDFDocument, rgb } from 'pdf-lib';
const FONT_SEARCH_PATHS = [
path.join(__dirname, '..', '..', '..', 'assets', 'fonts', 'NotoSansSC-VF.ttf'),
'C:/Windows/Fonts/NotoSansSC-VF.ttf',
'C:/Windows/Fonts/msyh.ttc',
'C:/Windows/Fonts/simsun.ttc',
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
'/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc',
];
let cachedFontBytes: Buffer | null = null;
function findFont(): Buffer {
if (cachedFontBytes) return cachedFontBytes;
for (const p of FONT_SEARCH_PATHS) {
try {
if (fs.existsSync(p)) {
cachedFontBytes = fs.readFileSync(p);
return cachedFontBytes;
}
} catch { }
}
return Buffer.alloc(0);
}
interface PdfReportOptions {
title: string;
subtitle?: string;
sections: PdfSection[];
}
interface PdfSection {
title: string;
lines: string[];
}
function textToHex(str: string): string {
let hex = '';
for (let i = 0; i < str.length; i++) {
hex += str.charCodeAt(i).toString(16).toUpperCase().padStart(4, '0');
}
return hex;
}
export async function generateAssessmentPdf(options: PdfReportOptions): Promise<Buffer> {
const doc = await PDFDocument.create();
const page = doc.addPage([595.28, 841.89]);
const ctx = doc.context;
const fontBytes = findFont();
if (fontBytes.length === 0) {
throw new Error('No CJK font found. Install Noto Sans SC or similar.');
}
const fontProgRef = ctx.nextRef();
ctx.assign(fontProgRef, ctx.flateStream(fontBytes));
const fontDescRef = ctx.nextRef();
ctx.assign(fontDescRef, ctx.obj({
Type: 'FontDescriptor',
FontName: 'CJKFont',
Flags: 4,
FontBBox: [0, -300, 1000, 1000],
ItalicAngle: 0,
Ascent: 900,
Descent: -200,
CapHeight: 800,
StemV: 80,
FontFile2: fontProgRef,
}));
const cidFontRef = ctx.nextRef();
ctx.assign(cidFontRef, ctx.obj({
Type: 'Font',
Subtype: 'CIDFontType2',
BaseFont: 'CJKFont',
CIDSystemInfo: ctx.obj({
Registry: 'Adobe',
Ordering: 'Identity',
Supplement: 0,
}),
FontDescriptor: fontDescRef,
W: [0, [500]],
}));
const fontRef = ctx.nextRef();
ctx.assign(fontRef, ctx.obj({
Type: 'Font',
Subtype: 'Type0',
BaseFont: 'CJKFont',
Encoding: 'Identity-H',
DescendantFonts: [cidFontRef],
}));
const fontKey = page.node.newFontDictionaryKey('F1');
page.node.setFontDictionary(fontKey, fontRef);
let contentOps = '';
let y = 800;
const margin = 50;
const pageWidth = 595.28;
function addLine(text: string, size: number, bold: boolean = false) {
const hex = textToHex(text);
contentOps += `BT\n/F1 ${size} Tf\n1 0 0 1 ${margin} ${y} Tm\n<${hex}> Tj\nET\n`;
y -= size * 1.5;
}
function addSeparator() {
let hex = '';
for (let i = 0; i < 55; i++) hex += '002D';
contentOps += `BT\n/F1 8 Tf\n1 0 0 1 ${margin} ${y} Tm\n<${hex}> Tj\nET\n`;
y -= 12;
}
addLine(options.title, 22);
y -= 4;
if (options.subtitle) {
addLine(options.subtitle, 10);
y -= 4;
}
addSeparator();
for (const section of options.sections) {
if (y < 60) break;
addLine(section.title, 12);
for (const line of section.lines) {
if (y < 40) break;
addLine(line, 10);
}
y -= 6;
}
addSeparator();
addLine('--- End of Report ---', 9);
const contentObj = ctx.flateStream(contentOps);
const contentRef = ctx.nextRef();
ctx.assign(contentRef, contentObj);
page.node.addContentStream(contentRef);
return Buffer.from(await doc.save());
}