diff --git a/server/src/assessment/services/pdf-generator.ts b/server/src/assessment/services/pdf-generator.ts index cd86666..03b98c7 100644 --- a/server/src/assessment/services/pdf-generator.ts +++ b/server/src/assessment/services/pdf-generator.ts @@ -1,11 +1,11 @@ import * as fs from 'fs'; import * as path from 'path'; -import { PDFDocument, rgb } from 'pdf-lib'; +import { PDFDocument, rgb, StandardFonts, PageSizes } 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/msyhbd.ttc', 'C:/Windows/Fonts/simsun.ttc', '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc', '/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc', @@ -37,145 +37,83 @@ interface PdfSection { 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 { const doc = await PDFDocument.create(); + let font: any; 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); + if (fontBytes.length > 0) { + try { + font = await doc.embedFont(fontBytes, { subset: true }); + } catch { + font = undefined; + } + } + if (!font) { + font = await doc.embedFont(StandardFonts.Helvetica); } + const pageWidth = PageSizes.A4[0]; + const pageHeight = PageSizes.A4[1]; const margin = 50; - const pageWidth = 595.28; - const pageHeight = 841.89; + const fontSize = 10; + const titleSize = 20; + const sectionSize = 13; + const lineHeight = fontSize * 1.6; - const ctx = doc.context as any; - let currentPage = doc.addPage([pageWidth, pageHeight]); - let contentOps = ''; - let y = pageHeight - 50; + function drawPage(title: string, titleSize: number, sections: PdfSection[]): void { + const page = doc.addPage([pageWidth, pageHeight]); + let y = pageHeight - margin; - prepareFont(currentPage); + page.drawText(title, { x: margin, y, size: titleSize, font, color: rgb(0, 0, 0) }); + y -= titleSize * 1.8; - 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; + if (options.subtitle) { + page.drawText(options.subtitle, { x: margin, y, size: 9, font, color: rgb(0.4, 0.4, 0.4) }); + y -= 16; } - } - 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; + for (const section of sections) { + y -= 8; + if (y < margin + sectionSize * 2) { + y = pageHeight - margin; + } + page.drawText(section.title, { x: margin, y, size: sectionSize, font, color: rgb(0.1, 0.1, 0.1) }); + y -= sectionSize * 1.8; + + for (const line of section.lines) { + const chunks = wrapLine(line, font, fontSize, pageWidth - margin * 2); + for (const chunk of chunks) { + if (y < margin + lineHeight) { + y = pageHeight - margin; + } + page.drawText(chunk, { x: margin, y, size: fontSize, font, color: rgb(0.2, 0.2, 0.2) }); + y -= lineHeight; + } + } } + + page.drawText('--- End of Report ---', { x: margin, y: margin, size: 8, font, color: rgb(0.6, 0.6, 0.6) }); } - 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); + drawPage(options.title, titleSize, options.sections); return Buffer.from(await doc.save()); } + +function wrapLine(text: string, _font: any, fontSize: number, maxWidth: number): string[] { + if (!text || text.length === 0) return ['']; + const chunks: string[] = []; + const lines = text.split('\n'); + for (const line of lines) { + if (line.length === 0) { + chunks.push(''); + continue; + } + const estimatedCharsPerLine = Math.max(1, Math.floor(maxWidth / (fontSize * 0.6))); + for (let i = 0; i < line.length; i += estimatedCharsPerLine) { + chunks.push(line.substring(i, i + estimatedCharsPerLine)); + } + } + return chunks.length > 0 ? chunks : [text]; +}