Documentation

Components

Every component maps to one engine element, and its props are typed - <Text :size :color bold> autocompletes and type-checks, and a wrong value (say a fit that does not exist) goes red in your editor. Boolean props have a shorthand: <Text bold> is <Text :bold="true">.

The full set:

Document · Page · Column · Row · Box · Padding · Expanded · Spacer · Divider · Image · Svg · Canvas · Text · Paragraph · Span · Table / TableRow / TableCell · Positioned · DefaultTextStyle · Link · Anchor · Bookmark · Rotated · RotatedBox · PageBreak · KeepTogether · PageNumber · PageCount · TextField · Checkbox · RadioGroup · Dropdown · ListBox · PushButton · SignatureField.

That is 36 components, and the Nuxt module auto-imports exactly the same 36.

Structure

Document

The root of every PDF component. Its text props (font, size, color, lineHeight, align, bold, italic) set the document-wide defaults that everything inside inherits.

PropType
size, color, font, bold, italicdefault text style
align, lineHeightdefault paragraph style
meta{ title?, author? }
fontscustom fonts, see Data & assets

Page

One physical page. Lays its children out as a column, so gap, justify and align work here too.

PropType
size"A4", "Letter", ... or a custom size
orientation"portrait" | "landscape"
margina number, or per-side insets
gapspace between children
justify"start" | "center" | "end" | "between" | "around"
align"start" | "center" | "end" | "stretch"

<Page> also takes #header and #footer slots. They are drawn on every physical page the content flows onto, and rebuilt per page - which is how <PageNumber> knows where it is:

<template>
  <Document>
    <Page :size="'A4'" :margin="48">
      <template #header>
        <Text :size="9" color="#94a3b8">ACME GmbH - Invoice</Text>
      </template>

      <Text>... long content that paginates across pages ...</Text>

      <template #footer>
        <Row :justify="'between'">
          <Text :size="9" color="#94a3b8">acme.example</Text>
          <Text :size="9" color="#94a3b8">Page footer</Text>
        </Row>
      </template>
    </Page>
  </Document>
</template>

Layout

Column and Row

Stack children vertically (Column) or horizontally (Row). Both share gap, justify and align.

<template>
  <Row :gap="12" :justify="'between'" :align="'center'">
    <Text bold>Item</Text>
    <Text>Value</Text>
  </Row>
</template>

Box

A sized, decorated container: background, border, padding, rounded corners.

PropType
bg, bordera color
borderTop / borderRight / borderBottom / borderLeft, borderWidthper-side border control
paddinga number, or per-side insets
width, height, radiusnumbers (points)
relativemake it a positioning frame
overflow"hidden" clips children to the box

Padding, Spacer, Expanded, Divider

  • <Padding :insets="16"> - wraps a child in space (a number or per-side insets).
  • <Spacer :flex="1"> - flexible empty space that pushes siblings apart.
  • <Expanded :flex="1"> - a flex child that fills the remaining main-axis space.
  • <Divider color="#e2e8f0" :thickness="1" /> - a rule line.

Text

Text, Paragraph and Span

Text and Paragraph render text; Span styles a run inside them.

Every text style the engine has is a prop here - the set is held to the engine by a test, so a style cannot ship in the core without arriving in Vue.

PropType
size, font, colorthe basics; font also takes a fallback stack
bold, italicboolean shorthands
align"start" | "left" | "center" | "right" | "justify"
lineHeightline spacing multiplier
letterSpacing, wordSpacingin points; negative tightens
textIndentfirst-line indent, in points
textTransform"uppercase" | "lowercase" | "capitalize" | "none"
underline, strikethrough, skipInkdecoration; skipInk steps around descenders
direction"ltr" | "rtl" - Hebrew and Arabic order correctly
ligaturesfalse turns off fi/fl/ffi, which are on by default
breakWord, hyphenatewhat to do with a word too long for its box
orphans, widowsminimum lines kept together at a page break
maxLines, overflowoverflow is "clip" | "ellipsis"
role"h1".."h6" | "p" for a tagged, accessible PDF
hreflink this run to an external URL
tolink this run to an <Anchor>

<Span> takes all of those plus verticalAlign ("super" | "sub"), which shifts one run's baseline without resizing it.

<template>
  <Paragraph>
    Plain text, then a <Span bold color="#1450aa">styled run</Span> in the middle.
  </Paragraph>
</template>

bold and italic are left unset by default, so a <Text> inside a bold <DefaultTextStyle> stays bold unless you override it. <DefaultTextStyle> re-defaults the text style for a whole subtree, the per-section counterpart to the <Document> defaults.

Putting href (or to) on a <Span> links just that run and lets the sentence flow around it:

<template>
  <Paragraph>
    Built with <Span href="https://jasy.dev" bold color="#1450aa">jasy</Span>, and this part is not
    a link.
  </Paragraph>
</template>

Image

<template>
  <Image :src="bytes" :width="120" :height="120" :fit="'contain'" :radius="8" />
</template>

fit is "none", "contain", "cover" or "fill". src takes image bytes (a Uint8Array); see Data & assets for loading them in the browser.

An SVG source is recognised by itself and stays a vector - no fit, no radius, since it is always drawn contained, which is SVG's own default.

Svg

<template>
  <Svg :src="logoMarkup" :width="120" alt="Acme" />
</template>

src takes SVG markup, the file's bytes, or - on the server - a path. With no size it draws at its intrinsic size; pin one axis and the other follows the viewBox. alt makes it a Figure in a tagged PDF. See SVG and Canvas for what the subset covers.

Canvas

For a chart, a sparkline or a seal, <Canvas> hands your function a typed pen and the size the layout resolved. paint is an ordinary function prop:

<script setup lang="ts">
import type { CanvasPainter } from "@jasy/pdf";

const months = [12, 19, 14, 27, 22, 31];

function sparkline(c: CanvasPainter, { width, height }: { width: number; height: number }) {
  const max = Math.max(...months);
  c.move(0, height - (months[0] / max) * height);
  months
    .slice(1)
    .forEach((v, i) =>
      c.line(((i + 1) / (months.length - 1)) * width, height - (v / max) * height),
    );
  c.stroke("#1450aa", { width: 1.5, cap: "round" });
}
</script>

<template>
  <Canvas :height="46" :paint="sparkline" />
</template>

The same function works at any width, so one definition serves a wide card and a narrow sidebar.

Table

<Table :columns> holds <TableRow>s of <TableCell>s. Mark a row header to repeat it on every page the table paginates onto. Columns are widths: a number is fixed points, a string like "1fr" is a flexible share.

<template>
  <Table :columns="['1fr', 120]" cell-border="#e2e8f0" :cell-padding="9">
    <TableRow header>
      <TableCell><Text bold>Description</Text></TableCell>
      <TableCell><Text bold>Amount</Text></TableCell>
    </TableRow>
    <TableRow v-for="line in lines" :key="line.id">
      <TableCell
        ><Text>{{ line.name }}</Text></TableCell
      >
      <TableCell
        ><Text>{{ line.total }} EUR</Text></TableCell
      >
    </TableRow>
  </Table>
</template>

Table also takes gap / rowGap / colGap, cellPadding, cellBorder and a rule color.

Positioned

Out-of-flow placement, anchored to the nearest <Box relative> (or the page). Give it edges (top / right / bottom / left) or an alignment (h / v) plus an x / y offset.

<template>
  <Box relative :height="200">
    <Positioned :top="8" :right="8">
      <Text :size="9" color="#94a3b8">draft</Text>
    </Positioned>
  </Box>
</template>

<Link> makes its child clickable. href opens a URL, to jumps to an <Anchor> in the same document. Exactly one of the two.

<template>
  <Link href="https://jasy.dev">
    <Box bg="#f3dc29" :padding="12"><Text>The whole box is the hit area</Text></Box>
  </Link>
</template>

<Anchor name> marks a jump target, and <Bookmark title> adds an entry to the outline tree your viewer shows in its sidebar. level nests it under the nearest preceding smaller level.

ComponentProps
<Link>href | to
<Anchor>name (required)
<Bookmark>title (required), level

A chapter heading usually wants both: a sidebar entry and a jump target. Nest them.

<template>
  <Bookmark title="2. Installation" :level="1">
    <Anchor name="install">
      <Text :size="22" bold>2. Installation</Text>
    </Anchor>
  </Bookmark>
</template>

See Navigation for how links, anchors and bookmarks work under the hood.

Rotated and RotatedBox

<Rotated :angle> spins its child at any angle around its center, at paint time: the layout box stays unrotated, so siblings do not reflow. That is what you want for a stamp or a watermark.

<RotatedBox :turns> does layout-aware quarter-turns instead. A 90 or 270 degree turn swaps width and height, so a vertical label reserves exactly the strip it occupies and its neighbours flow around it. turns counts clockwise 90-degree steps.

<template>
  <Row :gap="12">
    <RotatedBox :turns="3"><Text bold>SECTION A</Text></RotatedBox>
    <Column><Text>Line one</Text><Text>Line two</Text></Column>
  </Row>

  <Rotated :angle="-18">
    <Box bg="#ffe9e9" border="#d33" :padding="10"
      ><Text :size="26" bold color="#d33">PAID</Text></Box
    >
  </Rotated>
</template>

Page breaks

<PageBreak /> cuts the flow: everything after it starts on a fresh page. <KeepTogether> does the opposite - it refuses to let its children be split, and moves the whole group to the next page if it would not fit on this one. Both also exist as props (break-before, break-after, keep-together) on <Box>, <Column> and <Row>, which is usually the tidier form.

<template>
  <Page>
    <Text :size="18" bold>Terms</Text>
    <Text>...</Text>

    <PageBreak />

    <Text :size="18" bold>Appendix</Text>

    <KeepTogether>
      <Box border="#d0d4da" :radius="8" :padding="14">
        <Text bold>Signed</Text>
        <Text :size="9" color="gray">This box is never torn across two pages.</Text>
      </Box>
    </KeepTogether>
  </Page>
</template>

A break that cannot do anything - inside a <Row>, or anywhere that does not paginate - is ignored rather than obeyed halfway, and says so once on the console. A group taller than a whole page is split after all, because the alternative is a document that never terminates.

Form fields

A form field is a layout element like any other: it reserves a box, flows with its neighbours, and emits a widget the reader can actually type into. Seven kinds:

ComponentFor
TextFieldone line or, with multiline, several; password, max-length
Checkboxa single tick; on-value is what gets stored
RadioGroupone of several, laid out for you; options + value
Dropdowna closed list, or editable to allow free text
ListBoxa visible list; multi-select for several at once
PushButtonan action - "reset", { open: url } or { submit: url }
SignatureFieldan empty signature box for the reader to sign

All of them share name (the key the value is stored under), width, height, tooltip, read-only, required, hidden, print, and the appearance props border, background, border-width and color.

<template>
  <Column :gap="10">
    <TextField name="fullName" :width="220" :height="22" border="#aab3c2" background="#fbfcfe" />

    <RadioGroup
      name="plan"
      :gap="6"
      :options="[
        { value: 'basic', label: 'Basic' },
        { value: 'pro', label: 'Pro' },
      ]"
    />

    <Dropdown name="country" :width="110" :height="22" :options="['DE', 'AT', 'CH']" />

    <Checkbox name="agree" label="I have read the statutes" />

    <PushButton name="clear" label="Reset form" action="reset" :width="92" :height="26" />
  </Column>
</template>

Give a field a border if you want one - a text field draws nothing by default, exactly as a browser input with border: none would.

Reading a form back and filling it is the other half, and lives in the core: Forms.

Page numbers

<PageNumber> and <PageCount> print the current page and the document total. Put them anywhere - a #footer is the usual home, but the body works too. They take the <Text> styling props plus offset, which is added to the number (use -1 when a cover page should not count).

<template>
  <Document>
    <Page>
      <template #footer>
        <Row justify="center" :gap="3">
          <Text :size="9">Page</Text>
          <PageNumber :size="9" bold />
          <Text :size="9">of</Text>
          <PageCount :size="9" bold />
        </Row>
      </template>
      <Column><!-- body --></Column>
    </Page>
  </Document>
</template>

Getting the total right is why jasy paginates the whole document before it draws anything.

The core also ships a PageBuilder primitive, which hands you pageNumber, pageCount and pageSize and draws whatever you return. It takes a closure, so it is not exposed as a component - a template cannot express one. In a Nuxt server/ route, where you write plain JavaScript, you can use it directly.

A component of your own

The set above is not closed. registerElement(type, factory) teaches the renderer a new node type, and from then on it is a component like any other - in a Vue template, in a Nuxt page, or in a plain descriptor tree:

import { registerElement, Box, Text } from "@jasy/pdf";

registerElement("callout", (props, children) =>
  Box({ bg: "#eef1f5", border: "#1450aa", radius: 6, padding: 12 }, [
    Text(String(children[0] ?? ""), { color: "#1450aa" }),
  ]),
);

The seam underneath is buildDocument(descriptor): a { type, props, children } tree resolved through the same factories the components use. That is exactly how @jasy/vue is built - its host nodes ARE those descriptors - which is why a custom type works in every binding at once, and why a browser can hand a Node service a document as plain JSON.

react-pdf's primitive set is closed; this one is not.

jasypdf

Declarative PDFs in pure TypeScript. ZUGFeRD & XRechnung compliant, with no headless browser and no Java.

Resources

© 2026 Florian Heuberger · MIT License

Built with Nuxt · self-hosted fonts · no trackers