Files
hermes-config/skills/self-hosting/shop-pro-quote/references/pdf-generation.md
T
2026-07-12 10:17:17 -04:00

3.1 KiB

PDF Generation (jspdf 4.x)

The v2 app uses jspdf (v4.2.1) to generate quote PDFs. The generator lives at src/lib/pdf.ts.

Critical Pitfalls

1. NEVER pass string arrays to doc.text()

In jspdf 4.x, doc.text(stringArray, x, y) uses internal matrix-based line spacing that differs from manual array.length * lineHeight calculations. This causes yPos desync — every subsequent element renders at the wrong position, causing cascading text overlap.

WRONG:

const lines = doc.splitTextToSize(text, maxWidth);
doc.text(lines, x, y);           // jspdf 4.x spacing != manual calc
yPos += lines.length * 6 + 15;   // DOESN'T MATCH where jspdf rendered

RIGHT:

const lines = doc.splitTextToSize(text, maxWidth);
lines.forEach((line) => {        // render each line individually
  doc.text(line, x, yPos);
  yPos += 6;                     // explicit tracking per line
});
yPos += 15;

Calculate max y as PAGE_HEIGHT - MARGIN - FOOTER_HEIGHT (not just page edge). Footer is 35pt: separator line + message + page number.

Before rendering a service, compute its total height and check:

if (yPos + serviceHeight > PAGE_HEIGHT - MARGIN - FOOTER_HEIGHT) {
  doc.addPage();
  yPos = MARGIN;
  // Redraw section header (APPROVED / POSTPONED) on new page
}

3. Section headers must be redrawn after page breaks

Track currentGroupLabel and currentGroupColor. When a page break occurs mid-section, redraw the section header at the top of the new page.

4. Footers on EVERY page, not just the last

Draw footers (separator + message + page number) in a post-render loop over all pages. Do NOT draw them inline — they'll only appear on the last page.

5. Height calculation must match actual yPos increments

The pre-render height estimate must use the same values as actual yPos advances:

  • Customer decision badge: 5pt
  • Name+price: 6pt
  • Priority (label+reason): 6+10=16pt
  • Recommendation: 8pt
  • Explanation lines: 5pt each + 2pt end padding
  • Parts messages: 8pt each
  • Service separator+spacing: 6pt

7. Service counting for totals: !== 'declined', not === 'approved'

The PDF's servicesTotal accumulator and shopChargeableSubtotal use customerDecision !== 'declined'. This means both approved AND pending services contribute to the pricing summary.

The UI summary (computeQuoteTotals) uses customerDecision === 'approved' — a deliberate difference. The PDF is customer-facing, so pending services must appear in the total so the customer sees the full cost. Only services the customer has explicitly declined are excluded.

Do NOT change the PDF filter to === 'approved' — pending services would produce $0 totals.

Use let boxBottomY (not const) so it can be updated when a page break changes the box position. After rendering all summary rows, set yPos = boxBottomY + 10 for the next content.

File Locations

  • Source: src/lib/pdf.ts
  • Built: dist/assets/QuoteGenerator-*.js
  • Test script pattern: write a .mjs file in the project root, import jspdf, generate, save with fs.writeFileSync