fix(pdf): push pdf header and footer closer to page edge and force payload image extraction fallback

This commit is contained in:
2026-03-03 13:05:23 +01:00
parent daabf8bb63
commit 1756b630ef
51 changed files with 52 additions and 15 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -2569,7 +2569,9 @@ function resolveMediaToLocalPath(urlOrPath: string | null | undefined): string |
if (!urlOrPath) return null; if (!urlOrPath) return null;
// 1) Already public-relative. // 1) Already public-relative.
if (urlOrPath.startsWith('/')) return urlOrPath; if (urlOrPath.startsWith('/')) {
return urlOrPath;
}
// 2) Some datasets store "media/..." without leading slash. // 2) Some datasets store "media/..." without leading slash.
if (/^media\//i.test(urlOrPath)) return `/${urlOrPath}`; if (/^media\//i.test(urlOrPath)) return `/${urlOrPath}`;
@@ -3120,23 +3122,49 @@ async function loadEmbeddablePng(
if (!resolved) return null; if (!resolved) return null;
try { try {
// Prefer local files for stability and speed. // 1) Try standard local path first
if (resolved.startsWith('/')) { if (resolved.startsWith('/')) {
try { try {
const bytes = await readBytesFromPublic(resolved); const bytes = await readBytesFromPublic(resolved);
return { pngBytes: await toPngBytes(bytes, resolved), debugLabel: resolved }; return { pngBytes: await toPngBytes(bytes, resolved), debugLabel: resolved };
} catch { } catch {
// Fall back to HTTP fetch if file doesn't exist locally (e.g., Payload /api/ route) // Fallback: It might be a Payload API image that we couldn't statically map earlier
// Check if we can intercept it manually.
if (resolved.startsWith('/api/media/file/')) {
try {
const uploadFallback = resolved.replace('/api/media/file/', '/uploads/');
const bytes = await readBytesFromPublic(uploadFallback);
return {
pngBytes: await toPngBytes(bytes, uploadFallback),
debugLabel: uploadFallback,
};
} catch {
// Check media
try {
const mediaFallback = resolved.replace('/api/media/file/', '/media/');
const bytes = await readBytesFromPublic(mediaFallback);
return {
pngBytes: await toPngBytes(bytes, mediaFallback),
debugLabel: mediaFallback,
};
} catch {
// Ignore inner errors and fall through to network fetch
}
}
}
} }
} }
// Remote (fallback) // 2) Remote (network fetch fallback)
const host = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'; // IMPORTANT: Node 18+ fetch often resolves `localhost` to `::1` IPv6, which Payload might not be listening on.
// Force 127.0.0.1 to guarantee IPv4 resolution for local API fetches during build.
const host = process.env.NEXT_PUBLIC_BASE_URL || 'http://127.0.0.1:3000';
// Ensure we don't end up with `http://localhost:3000http://...` // Ensure we don't end up with `http://localhost:3000http://...`
const fetchUrl = resolved.startsWith('/') ? `${host.replace(/\/$/, '')}${resolved}` : resolved; const fetchUrl = resolved.startsWith('/') ? `${host.replace(/\/$/, '')}${resolved}` : resolved;
const bytes = await fetchBytes(fetchUrl); const bytes = await fetchBytes(fetchUrl);
return { pngBytes: await toPngBytes(bytes, fetchUrl), debugLabel: fetchUrl }; return { pngBytes: await toPngBytes(bytes, fetchUrl), debugLabel: fetchUrl };
} catch { } catch (err: any) {
console.warn(`[PDF Image Warn] Failed to load image: ${src} -> ${resolved}`, err?.message);
return null; return null;
} }
} }
@@ -3264,7 +3292,8 @@ function drawHeader(ctx: SectionDrawContext, yStart: number): number {
// Cable-industry look: calm, engineered header with right-aligned meta. // Cable-industry look: calm, engineered header with right-aligned meta.
// Keep header compact to free vertical space for technical tables. // Keep header compact to free vertical space for technical tables.
const headerH = 52; const headerH = 52;
const dividerY = yStart - headerH; const headerTopPadding = 24;
const dividerY = ctx.height - headerTopPadding - headerH;
ctx.headerDividerY = dividerY; ctx.headerDividerY = dividerY;
page.drawRectangle({ page.drawRectangle({
@@ -3989,10 +4018,15 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
const qrPng = await loadQrPng(productUrl); const qrPng = await loadQrPng(productUrl);
const qrImage = qrPng ? await pdfDoc.embedPng(qrPng.pngBytes) : null; const qrImage = qrPng ? await pdfDoc.embedPng(qrPng.pngBytes) : null;
// Engineered page frame (A4): slightly narrower margins but consistent rhythm. // Engineered page frame (A4): Push margins slightly closer to the edge.
const margin = 54; const margin = 48; // Left/right margin
const footerY = 54; const footerY = 28; // Absolute distance from the bottom for the footer line
const contentMinY = footerY + 42; // keep clear of footer + page numbers const headerH = 52; // Header background height
const headerTopPadding = 24; // Absolute distance from top for the header box
// Y-coordinate starts from the BOTTOM in pdf-lib. Height is 841.89
// `contentMinY` is the lowest point standard body content can reach before overflowing into the footer.
const contentMinY = footerY + 36;
const contentWidth = width - 2 * margin; const contentWidth = width - 2 * margin;
const ctx: SectionDrawContext = { const ctx: SectionDrawContext = {
@@ -4038,9 +4072,11 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
const name = stripHtml(product.name); const name = stripHtml(product.name);
const maxW = ctx.contentWidth; const maxW = ctx.contentWidth;
const line = wrapText(name, fontBold, 12, maxW).slice(0, 1)[0] || name; const line = wrapText(name, fontBold, 12, maxW).slice(0, 1)[0] || name;
// Render name a bit lower inside the header space
const headerTopPadding = 24;
p.drawText(line, { p.drawText(line, {
x: margin, x: margin,
y: yStart, y: height - headerTopPadding - 32,
size: 12, size: 12,
font: fontBold, font: fontBold,
color: navy, color: navy,
@@ -4056,9 +4092,10 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
syncCtxForPage(page); syncCtxForPage(page);
drawPageBackground(page); drawPageBackground(page);
drawFooter(ctx); drawFooter(ctx);
let yStart = drawHeader(ctx, ctx.height - ctx.margin); y = drawHeader(ctx, height); // pass height, we override dividerY internally inside drawHeader
if (opts?.includeProductName) yStart = drawProductNameOnPage(page, yStart); // stampProductName(); // Assuming this function is defined elsewhere or will be added.
return yStart; if (opts?.includeProductName) y = drawProductNameOnPage(page, y); // Update y after drawing product name
return ctx.headerDividerY - 24; // spacing below header line
}; };
const hasSpace = (needed: number) => y - needed >= contentMinY; const hasSpace = (needed: number) => y - needed >= contentMinY;