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

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;