Skip to main content

Production recipes

Copyable Vue patterns for invoices, statements, reports, repeating matter, and payment QR codes.

These are application recipes, not extra framework primitives. They compose the same small PdfView/PdfText surface used by the tested playground documents.

A fixed node repeats when one authored page wraps into multiple output pages. Keep the page padding large enough that flowing content never overlaps it.

vue
<PdfPage :style="{ paddingBottom: 52, paddingHorizontal: 40, paddingTop: 64 }">
  <PdfView fixed :style="{ left: 40, position: 'absolute', right: 40, top: 24 }">
    <PdfText>Quarterly statement</PdfText>
  </PdfView>
  <PdfText
    fixed
    :render="({ pageNumber, totalPages }) => `Page ${pageNumber} / ${totalPages}`"
    :style="{ bottom: 24, position: 'absolute', right: 40 }"
  />
  <slot />
</PdfPage>

Address or party block

Use a typed child component so the invoice template remains readable.

pdfs/components/PartyBlock.vue
<script setup lang="ts">
defineProps<{ label: string, lines: string[] }>()
</script>

<template>
  <PdfView :style="{ width: 220 }">
    <PdfText :style="{ color: '#68736B', fontSize: 8, marginBottom: 6 }">
      {{ label }}
    </PdfText>
    <PdfText v-for="line in lines" :key="line" :style="{ fontSize: 10 }">
      {{ line }}
    </PdfText>
  </PdfView>
</template>

Key/value details

Keep widths explicit; PDF rows should not depend on browser table layout.

vue
<PdfView
  v-for="entry in details"
  :key="entry.label"
  :style="{ flexDirection: 'row', marginBottom: 6 }"
>
  <PdfText :style="{ color: '#68736B', width: 110 }">{{ entry.label }}</PdfText>
  <PdfText :style="{ flex: 1 }">{{ entry.value }}</PdfText>
</PdfView>

Invoice lines and totals

Make each line indivisible with wrap="false"; keyed rows then move as a unit instead of losing a description or amount at a page break.

vue
<PdfView
  v-for="line in invoice.lines"
  :key="line.id"
  :wrap="false"
  :style="{ borderBottomColor: '#DDE3DE', borderBottomWidth: 1, flexDirection: 'row', paddingVertical: 10 }"
>
  <PdfText :style="{ flex: 1 }">{{ line.description }}</PdfText>
  <PdfText :style="{ textAlign: 'right', width: 90 }">{{ money(line.total) }}</PdfText>
</PdfView>
<PdfView :wrap="false" :style="{ marginLeft: 'auto', marginTop: 16, width: 220 }">
  <PdfText :style="{ fontWeight: 700, textAlign: 'right' }">Total {{ money(total) }}</PdfText>
</PdfView>

Payment details and QR

Generate the payment payload in setup code, turn consecutive dark QR modules into one SVG path, and keep the payment panel together with wrap="false".

vue
<PdfView :wrap="false" :style="{ flexDirection: 'row', marginTop: 20 }">
  <PdfView :style="{ flex: 1 }">
    <PdfText>IBAN {{ payment.iban }}</PdfText>
    <PdfText>BIC {{ payment.bic }}</PdfText>
    <PdfText>Reference {{ invoice.number }}</PdfText>
  </PdfView>
  <PdfSvg :viewBox="`0 0 ${qrSize} ${qrSize}`" :style="{ height: 72, width: 72 }">
    <PdfPath :d="qrPath" fill="#000000" />
  </PdfSvg>
</PdfView>

Keep a heading with what follows

minPresenceAhead asks pagination to move the heading when too little usable space remains. Use wrap="false" only when the whole section is genuinely small enough to fit on a page.

vue
<PdfView :style="{ marginTop: 24 }">
  <PdfText :min-presence-ahead="54" :style="{ fontSize: 15, marginBottom: 10 }">
    Payment terms
  </PdfText>
  <PdfText>{{ terms }}</PdfText>
</PdfView>

Long rows with continuation headers

Put the header and rows in one wrapping page. The fixed header repeats on every continuation page; page padding reserves its space.

vue
<PdfPage :style="{ paddingHorizontal: 40, paddingTop: 72 }">
  <PdfView fixed :style="{ flexDirection: 'row', left: 40, position: 'absolute', right: 40, top: 36 }">
    <PdfText :style="{ flex: 1 }">Description</PdfText>
    <PdfText :style="{ textAlign: 'right', width: 90 }">Amount</PdfText>
  </PdfView>
  <InvoiceLine v-for="line in lines" :key="line.id" :line="line" />
</PdfPage>

Report sections and a table of contents

Read the page map only where the document needs it. The first pass returns no number, so keep an empty fallback; Nuxt PDF rerenders until the map stabilizes.

vue
<script setup lang="ts">
const pageNumbers = usePdfPageNumbers()
</script>

<template>
  <PdfPage>
    <PdfLink v-for="section in sections" :key="section.id" :src="`#${section.id}`">
      {{ section.title }} ..... {{ pageNumbers[section.id] ?? '' }}
    </PdfLink>
  </PdfPage>
  <PdfPage v-for="section in sections" :key="section.id">
    <PdfView :id="section.id" :bookmark="section.title">
      <PdfText :style="{ fontSize: 22 }">{{ section.title }}</PdfText>
      <PdfText>{{ section.body }}</PdfText>
    </PdfView>
  </PdfPage>
</template>

The canonical invoice, long-line scenario, annual report, and e-book tests cover these patterns with semantic, pagination, geometry, and reviewed raster checks.