forked from hangshuo652/aurak
82a9e75842
(C1) Add dimensionScores/radarData/passed columns to AssessmentSession (C2) Mock DataSource in service.spec.ts + app.e2e-spec.ts (C3) Mock AuditLogService in controller.spec.ts (C4) Rewrite deleteSession tests for dataSource.transaction (I1) batchDeleteSessions uses transaction with certificate cleanup (I2) extractDimensionScores reads from session property (I3/I5) PDF generator supports multi-page + newline splitting (I4) findOne inside transaction uses deleteCondition
182 lines
4.6 KiB
TypeScript
182 lines
4.6 KiB
TypeScript
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 fontBytes = findFont();
|
|
if (fontBytes.length === 0) {
|
|
throw new Error('No CJK font found. Install Noto Sans SC or similar.');
|
|
}
|
|
|
|
const FONT_DESC_REF = { ref: null as any };
|
|
const FONT_REF = { ref: null as any };
|
|
|
|
function prepareFont(page: any) {
|
|
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,
|
|
}));
|
|
FONT_DESC_REF.ref = fontDescRef;
|
|
|
|
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],
|
|
}));
|
|
FONT_REF.ref = fontRef;
|
|
|
|
const fontKey = page.node.newFontDictionaryKey('F1');
|
|
page.node.setFontDictionary(fontKey, fontRef);
|
|
}
|
|
|
|
const margin = 50;
|
|
const pageWidth = 595.28;
|
|
const pageHeight = 841.89;
|
|
|
|
const ctx = doc.context as any;
|
|
let currentPage = doc.addPage([pageWidth, pageHeight]);
|
|
let contentOps = '';
|
|
let y = pageHeight - 50;
|
|
|
|
prepareFont(currentPage);
|
|
|
|
function addFontToPage(page: any) {
|
|
const fontKey = page.node.newFontDictionaryKey('F1');
|
|
page.node.setFontDictionary(fontKey, FONT_REF.ref);
|
|
}
|
|
|
|
function ensureSpace(needed: number) {
|
|
const bottomMargin = 60;
|
|
if (y - needed < bottomMargin) {
|
|
const contentObj = ctx.flateStream(contentOps);
|
|
const contentRef = ctx.nextRef();
|
|
ctx.assign(contentRef, contentObj);
|
|
currentPage.node.addContentStream(contentRef);
|
|
|
|
currentPage = doc.addPage([pageWidth, pageHeight]);
|
|
addFontToPage(currentPage);
|
|
contentOps = '';
|
|
y = pageHeight - 50;
|
|
}
|
|
}
|
|
|
|
function addLine(text: string, size: number, bold: boolean = false) {
|
|
const lines = text.split('\n');
|
|
for (const line of lines) {
|
|
ensureSpace(size * 1.5);
|
|
const hex = textToHex(line);
|
|
contentOps += `BT\n/F1 ${size} Tf\n1 0 0 1 ${margin} ${y} Tm\n<${hex}> Tj\nET\n`;
|
|
y -= size * 1.5;
|
|
}
|
|
}
|
|
|
|
function addSeparator() {
|
|
ensureSpace(12);
|
|
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) {
|
|
ensureSpace(30);
|
|
addLine(section.title, 12);
|
|
for (const line of section.lines) {
|
|
addLine(line, 10);
|
|
}
|
|
y -= 6;
|
|
}
|
|
|
|
addSeparator();
|
|
addLine('--- End of Report ---', 9);
|
|
|
|
const contentObj = ctx.flateStream(contentOps);
|
|
const contentRef = ctx.nextRef();
|
|
ctx.assign(contentRef, contentObj);
|
|
currentPage.node.addContentStream(contentRef);
|
|
|
|
return Buffer.from(await doc.save());
|
|
}
|