Skip to main content

Contents, links & bookmarks

usePdfPageNumbers, the multi-pass layout loop, convergence and maxPasses, internal links, and the bookmark outline.

Nuxt PDF builds a table of contents, internal links, and a bookmark outline from ordinary components. There is no TOC component and no automatic heading collection. You author the list, and the engine resolves the page numbers.

Give any primitive an id to make it a named destination, then link to it with <PdfLink src="#id">:

vue
<PdfText id="terms">Terms &amp; conditions</PdfText>

<PdfLink src="#terms">Jump to terms</PdfLink>

Internal #id links resolve by name in a single pass. They do not trigger the multi-pass loop and add no cost. A #id that does not match any destination in the mounted document fails the render with PDF_TREE_INVALID. The link and destination must both exist in the initial mounted tree and after every page-number update; do not conditionally create a destination only after its page number becomes available.

When an id sits on a node that spans a page boundary, both the printed page number and the jump target resolve to the section's first page. This is a deliberate divergence from React PDF, whose last-writer-wins table points at the last page.

Printing page numbers: usePdfPageNumbers

To print the page a destination lands on, read the auto-imported usePdfPageNumbers() composable. It returns a readonly, reactive map from id to its 1-based page:

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

const entries = sections.map(s => ({ id: s.id, title: s.title }))
</script>

<template>
  <PdfLink
    v-for="entry in entries"
    :key="entry.id"
    :src="`#${entry.id}`"
  >
    {{ entry.title }} {{ pageNumbers[entry.id] ?? '' }}
  </PdfLink>
</template>

Reading the composable is the only signal that turns on the multi-pass layout: the document is laid out repeatedly, feeding each pass's resolved destination-page map back in until it stabilizes.

On the first pass every entry is undefined. Always keep a fallback (pageNumbers[id] ?? '') so the first pass renders a blank rather than throwing.

Calling usePdfPageNumbers() outside a PDF render throws, rather than returning an empty map that could be mistaken for first-pass state.

Convergence and maxPasses

The loop is a fixed point. An ordinary table of contents converges in two passes: pass one fills the numbers in, pass two lays out with them and the map no longer changes.

A document does not converge if its layout depends on the page numbers that it prints. For example, a TOC entry can change height when its page number changes. After maxPasses (a validated positive integer on definePdf, default 5) the render fails with a PDF_LIMIT_EXCEEDED NuxtPdfError, attributed to the template:

ts
definePdf<Props>({
  // Raise the cap only if a genuinely convergent document needs more passes.
  maxPasses: 8,
})

Non-convergence fails closed; the loop does not force a wrong answer. Raise maxPasses only when you know the document actually settles.

Bookmarks (the outline)

Add a bookmark to any of PdfPage, PdfView, PdfText, or PdfImage to build the PDF outline. The value is a title string, or an object:

vue
<PdfView :id="section.id" :bookmark="{ title: section.title, expanded: true }">
  <PdfText :id="sub.id" :bookmark="sub.title">{{ sub.title }}</PdfText>
</PdfView>

Nesting follows the component tree, so a child's bookmark nests under its ancestor's. The outline carries title text, its initially expanded state, and parent/child nesting. Nuxt PDF does not expose reader page-mode preferences or bookmark destination geometry (top/left/zoom/fit).

A full example

playground/pdfs/report.vue in the repository demonstrates the whole feature: a contents page linking to each section, live page numbers, per-section bookmarks, and a fixed footer with a dynamic page counter. The engine resolves all these features through the multi-pass loop.