This commit is contained in:
larssand
2026-03-14 11:47:09 +01:00
parent 3b0af41466
commit 7af303463a
3 changed files with 439 additions and 55 deletions

207
server.js
View File

@@ -302,6 +302,7 @@ function normalizePdfExportPayload(input = {}) {
const brandName = String(input.brandName || "JMK RB").trim();
const brandTagline = String(input.brandTagline || "Live Event").trim();
const footer = String(input.footer || "").trim();
const logoDataUrl = String(input.logoDataUrl || "").trim();
const theme = ["classic", "minimal", "motorsport"].includes(String(input.theme || "").toLowerCase())
? String(input.theme).toLowerCase()
: "classic";
@@ -317,7 +318,7 @@ function normalizePdfExportPayload(input = {}) {
}))
: [];
return { title, subtitle, brandName, brandTagline, footer, theme, filename, sections };
return { title, subtitle, brandName, brandTagline, footer, theme, logoDataUrl, filename, sections };
}
function buildSimplePdf(payload) {
@@ -325,10 +326,13 @@ function buildSimplePdf(payload) {
const pageHeight = 842;
const marginLeft = 42;
const marginTop = 56;
const marginBottom = 56;
const lineHeight = 15;
const maxChars = 86;
const logo = parsePdfLogo(payload.logoDataUrl);
const headerOffset = logo ? logo.displayHeight + 18 : 0;
const lines = buildPdfLines(payload, maxChars);
const linesPerPage = Math.max(10, Math.floor((pageHeight - marginTop * 2) / lineHeight));
const linesPerPage = Math.max(10, Math.floor((pageHeight - marginTop - marginBottom - headerOffset) / lineHeight));
const pages = [];
for (let index = 0; index < lines.length; index += linesPerPage) {
pages.push(lines.slice(index, index + linesPerPage));
@@ -338,37 +342,168 @@ function buildSimplePdf(payload) {
}
const objects = [];
objects.push("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
const kids = [];
const fontObjectId = 3 + pages.length * 2;
const pageObjectIds = [];
const contentObjectIds = [];
let nextObjectId = 3;
for (let index = 0; index < pages.length; index += 1) {
const pageObjectId = 3 + index * 2;
const contentObjectId = 4 + index * 2;
kids.push(`${pageObjectId} 0 R`);
const contentStream = buildPdfContentStream(pages[index], marginLeft, pageHeight - marginTop, lineHeight);
objects.push(
`${pageObjectId} 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageWidth} ${pageHeight}] /Resources << /Font << /F1 ${fontObjectId} 0 R >> >> /Contents ${contentObjectId} 0 R >>\nendobj\n`
);
objects.push(`${contentObjectId} 0 obj\n<< /Length ${Buffer.byteLength(contentStream, "utf8")} >>\nstream\n${contentStream}\nendstream\nendobj\n`);
pageObjectIds.push(nextObjectId++);
contentObjectIds.push(nextObjectId++);
}
objects.splice(1, 0, `2 0 obj\n<< /Type /Pages /Count ${pages.length} /Kids [${kids.join(" ")}] >>\nendobj\n`);
objects.push(`${fontObjectId} 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>\nendobj\n`);
const fontObjectId = nextObjectId++;
const imageObjectId = logo ? nextObjectId++ : null;
let pdf = "%PDF-1.4\n";
objects.push(createPdfTextObject(1, "<< /Type /Catalog /Pages 2 0 R >>"));
objects.push(createPdfTextObject(2, `<< /Type /Pages /Count ${pages.length} /Kids [${pageObjectIds.map((id) => `${id} 0 R`).join(" ")}] >>`));
objects.push(createPdfTextObject(fontObjectId, "<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>"));
if (logo && imageObjectId) {
objects.push(
createPdfStreamObject(
imageObjectId,
`<< /Type /XObject /Subtype /Image /Width ${logo.width} /Height ${logo.height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode`,
logo.data
)
);
}
for (let index = 0; index < pages.length; index += 1) {
const resources = imageObjectId
? `<< /Font << /F1 ${fontObjectId} 0 R >> /XObject << /Im1 ${imageObjectId} 0 R >> >>`
: `<< /Font << /F1 ${fontObjectId} 0 R >> >>`;
const contentStream = Buffer.from(
buildPdfContentStream(
pages[index],
marginLeft,
pageHeight - marginTop - headerOffset,
lineHeight,
logo
? {
x: marginLeft,
y: pageHeight - marginTop - logo.displayHeight,
width: logo.displayWidth,
height: logo.displayHeight,
}
: null
),
"utf8"
);
objects.push(
createPdfTextObject(
pageObjectIds[index],
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageWidth} ${pageHeight}] /Resources ${resources} /Contents ${contentObjectIds[index]} 0 R >>`
)
);
objects.push(createPdfStreamObject(contentObjectIds[index], "<<", contentStream));
}
return assemblePdf(objects);
}
function createPdfTextObject(id, body) {
return {
id,
buffer: Buffer.from(`${id} 0 obj\n${body}\nendobj\n`, "binary"),
};
}
function createPdfStreamObject(id, dictionaryPrefix, streamBuffer) {
return {
id,
buffer: Buffer.concat([
Buffer.from(`${id} 0 obj\n${dictionaryPrefix} /Length ${streamBuffer.length} >>\nstream\n`, "binary"),
streamBuffer,
Buffer.from("\nendstream\nendobj\n", "binary"),
]),
};
}
function assemblePdf(objects) {
const ordered = [...objects].sort((left, right) => left.id - right.id);
const chunks = [Buffer.from("%PDF-1.4\n", "binary")];
const offsets = [0];
objects.forEach((object) => {
offsets.push(Buffer.byteLength(pdf, "utf8"));
pdf += object;
let position = chunks[0].length;
ordered.forEach((object) => {
offsets[object.id] = position;
chunks.push(object.buffer);
position += object.buffer.length;
});
const xrefOffset = Buffer.byteLength(pdf, "utf8");
pdf += `xref\n0 ${objects.length + 1}\n`;
pdf += "0000000000 65535 f \n";
offsets.slice(1).forEach((offset) => {
pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
});
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;
return Buffer.from(pdf, "utf8");
const xrefOffset = position;
const size = ordered.length + 1;
let xref = `xref\n0 ${size}\n0000000000 65535 f \n`;
for (let id = 1; id < size; id += 1) {
xref += `${String(offsets[id] || 0).padStart(10, "0")} 00000 n \n`;
}
chunks.push(Buffer.from(`${xref}trailer\n<< /Size ${size} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`, "binary"));
return Buffer.concat(chunks);
}
function parsePdfLogo(dataUrl) {
const match = /^data:image\/jpeg;base64,([A-Za-z0-9+/=\s]+)$/i.exec(String(dataUrl || "").trim());
if (!match) {
return null;
}
const data = Buffer.from(match[1].replace(/\s+/g, ""), "base64");
const dimensions = getJpegDimensions(data);
if (!dimensions) {
return null;
}
const scale = Math.min(150 / dimensions.width, 52 / dimensions.height, 1);
return {
data,
width: dimensions.width,
height: dimensions.height,
displayWidth: Math.max(1, Math.round(dimensions.width * scale)),
displayHeight: Math.max(1, Math.round(dimensions.height * scale)),
};
}
function getJpegDimensions(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) {
return null;
}
let offset = 2;
while (offset + 1 < buffer.length) {
while (offset < buffer.length && buffer[offset] !== 0xff) {
offset += 1;
}
while (offset < buffer.length && buffer[offset] === 0xff) {
offset += 1;
}
if (offset >= buffer.length) {
break;
}
const marker = buffer[offset];
offset += 1;
if (marker === 0xd9 || marker === 0xda) {
break;
}
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
continue;
}
if (offset + 1 >= buffer.length) {
break;
}
const length = buffer.readUInt16BE(offset);
if (length < 2 || offset + length > buffer.length) {
break;
}
if (
(marker >= 0xc0 && marker <= 0xc3) ||
(marker >= 0xc5 && marker <= 0xc7) ||
(marker >= 0xc9 && marker <= 0xcb) ||
(marker >= 0xcd && marker <= 0xcf)
) {
if (length >= 7) {
return {
height: buffer.readUInt16BE(offset + 3),
width: buffer.readUInt16BE(offset + 5),
};
}
break;
}
offset += length;
}
return null;
}
function buildPdfLines(payload, maxChars) {
@@ -473,16 +608,24 @@ function wrapPdfText(text, maxChars) {
return lines;
}
function buildPdfContentStream(lines, x, y, lineHeight) {
function buildPdfContentStream(lines, x, y, lineHeight, logoPlacement = null) {
const escaped = lines.map((line) => escapePdfText(line));
return [
const commands = [];
if (logoPlacement) {
commands.push("q");
commands.push(`${logoPlacement.width} 0 0 ${logoPlacement.height} ${logoPlacement.x} ${logoPlacement.y} cm`);
commands.push("/Im1 Do");
commands.push("Q");
}
commands.push(
"BT",
"/F1 11 Tf",
`${lineHeight} TL`,
`${x} ${y} Td`,
...escaped.map((line) => `(${line}) Tj\nT*`),
"ET",
].join("\n");
"ET"
);
return commands.join("\n");
}
function escapePdfText(text) {