fix: rewrite PDF generator using pdf-lib native embedFont for CJK support
This commit is contained in:
@@ -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<Buffer> {
|
||||
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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user