Files
2026-07-12 10:17:17 -04:00

3.5 KiB

PDF Layout Refinement Patterns (jsPDF)

Patterns learned from iterative PDF layout fixes in quote-tab-manager.js (ShopProQuote).

Fill-Before-Text Ordering

When drawing a background box with text on top, always draw the fill FIRST, then the text, then the border last:

// 1. Draw fill-only background BEFORE any text
doc.setFillColor(248, 248, 248);
doc.roundedRect(x, y, w, initialHeight, r, r, 'F');

// 2. Draw all text content on top of the fill
doc.text(...);

// 3. Compute actual height, draw border-only at correct size
const actualHeight = finalY - startY;
doc.setDrawColor(220, 220, 220);
doc.setLineWidth(0.5);
doc.roundedRect(x, y, w, actualHeight, r, r, 'D');

NEVER use 'FD' (fill+draw) after text — it paints over the text. The two-pass approach (fill first with generous initial height, border after at computed height) ensures text is always visible and borders are tight.

Dynamic Box Height

Never hardcode box height. Compute it from content:

const headerStartY = yPos;
// ... draw all content, advancing yPos ...
const actualHeight = yPos - headerStartY;

This prevents boxes from being too short (clipping content) or too tall (wasted space with large gaps).

Overlap Prevention

When a footer note appears below a box, position it relative to the box's visual bottom, not the last text line:

// WRONG — note may overlap the box
yPos += 15;
doc.text(note, x, yPos);

// RIGHT — note starts after box's visual boundary
yPos = boxBottomY + 10;
doc.text(note, x, yPos);

Color Visibility Thresholds

On white paper (#ffffff), very light fills and borders are invisible:

Color Visible? Minimum for visibility
Fill #fafafa (250,250,250) No — indistinguishable from white #f8f8f8 (248,248,248)
Fill #f8f8f8 (248,248,248) Yes — subtle but visible
Border #e6e6e6 at 0.5px No — too light and thin #dcdcdc at 0.5px
Border #dcdcdc at 0.5px Yes — subtle but defined

Rule: fill at #f8f8f8, border at #dcdcdc, line width 0.5px. These match across header boxes, totals boxes, and service separators for consistent style.

Label Alignment (Widest-First)

When rendering label-value pairs in a PDF, align all values at a consistent offset after the widest label:

// 1. Measure the widest label
let maxWidth = 0;
labels.forEach(label => {
    const w = doc.getTextWidth(label);
    if (w > maxWidth) maxWidth = w;
});

// 2. Position all values at widest label + gap
const valueX = labelStartX + maxWidth + 12;
labels.forEach((label, value) => {
    doc.text(label, labelStartX, y);
    doc.text(value, valueX, y);
});

This produces a clean table-like alignment without wasted whitespace between label and value.

Multi-Pass Page Numbering

Add page numbers AFTER all content is laid out, iterating through all pages:

const totalPages = doc.internal.getNumberOfPages();
for (let i = 1; i <= totalPages; i++) {
    doc.setPage(i);
    doc.text(`Page ${i} of ${totalPages}`, pageWidth - margin, pageHeight - 10, { align: 'right' });
}

This ensures page numbers reflect the final page count, not an intermediate count.

Service-Specific Pitfalls

  • technicianNotes should be excluded from customer-facing PDFs (internal context only)
  • Approved/declined badges should use colored backgrounds (green #dcfce7 for approved, red #fef2f2 for declined)
  • Declined services should be grayed out with strikethrough
  • Services should be grouped: approved first, then pending/declined