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;
// 1) Already public-relative.
if (urlOrPath.startsWith('/')) return urlOrPath;
if (urlOrPath.startsWith('/')) {
return urlOrPath;
}
// 2) Some datasets store "media/..." without leading slash.
if (/^media\//i.test(urlOrPath)) return `/${urlOrPath}`;
@@ -3120,23 +3122,49 @@ async function loadEmbeddablePng(
if (!resolved) return null;
try {
// Prefer local files for stability and speed.
// 1) Try standard local path first
if (resolved.startsWith('/')) {
try {
const bytes = await readBytesFromPublic(resolved);
return { pngBytes: await toPngBytes(bytes, resolved), debugLabel: resolved };
} 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)
const host = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
// 2) Remote (network fetch fallback)
// 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://...`
const fetchUrl = resolved.startsWith('/') ? `${host.replace(/\/$/, '')}${resolved}` : resolved;
const bytes = await fetchBytes(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;
}
}
@@ -3264,7 +3292,8 @@ function drawHeader(ctx: SectionDrawContext, yStart: number): number {
// Cable-industry look: calm, engineered header with right-aligned meta.
// Keep header compact to free vertical space for technical tables.
const headerH = 52;
const dividerY = yStart - headerH;
const headerTopPadding = 24;
const dividerY = ctx.height - headerTopPadding - headerH;
ctx.headerDividerY = dividerY;
page.drawRectangle({
@@ -3989,10 +4018,15 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
const qrPng = await loadQrPng(productUrl);
const qrImage = qrPng ? await pdfDoc.embedPng(qrPng.pngBytes) : null;
// Engineered page frame (A4): slightly narrower margins but consistent rhythm.
const margin = 54;
const footerY = 54;
const contentMinY = footerY + 42; // keep clear of footer + page numbers
// Engineered page frame (A4): Push margins slightly closer to the edge.
const margin = 48; // Left/right margin
const footerY = 28; // Absolute distance from the bottom for the footer line
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 ctx: SectionDrawContext = {
@@ -4038,9 +4072,11 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
const name = stripHtml(product.name);
const maxW = ctx.contentWidth;
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, {
x: margin,
y: yStart,
y: height - headerTopPadding - 32,
size: 12,
font: fontBold,
color: navy,
@@ -4056,9 +4092,10 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
syncCtxForPage(page);
drawPageBackground(page);
drawFooter(ctx);
let yStart = drawHeader(ctx, ctx.height - ctx.margin);
if (opts?.includeProductName) yStart = drawProductNameOnPage(page, yStart);
return yStart;
y = drawHeader(ctx, height); // pass height, we override dividerY internally inside drawHeader
// stampProductName(); // Assuming this function is defined elsewhere or will be added.
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;