print-designer-vue

Vue 3 print-template designer whose output is a pdfme template.
1,882
Install
npm install print-designer-vue
Latest Version:0.12.0
Vue:3
License:MIT
Last Updated:Oct 8, 2026
Links: npm
Maintainer: hussein_alaa

print-designer-vue

A Vue 3 print-template designer. You draw on a Konva canvas; what you get out is a pdfme template — the exact object @pdfme/generator consumes, with no adapter or conversion step in between.

npm i print-designer-vue

New here? QUICKSTART.md is the short version — one component, a font, and the model read off a real API response.

vue is a peer dependency. Nothing global is registered — no app.use().

Server-side or in a plain Node script, import the UI-free entry so nothing pulls in Vue or Konva:

import { generatePdf, createTemplate } from 'print-designer-vue/api';

Use

<script setup>
import { ref } from 'vue';
import { PrintDesigner, createTemplate, PAGE_SIZES } from 'print-designer-vue';
import 'print-designer-vue/style.css';

const template = ref(createTemplate(PAGE_SIZES.A4));
</script>

<template>
  <PrintDesigner v-model="template" />
</template>

template.value is plain JSON — store it, reload it, pass it back in.

Generate a PDF yourself

import { generatePdf } from 'print-designer-vue';

const bytes = await generatePdf(template, [{ customer: 'Acme Ltd', total: '250' }]);

Keys in inputs are field names. Omit inputs and each field renders its own content (useful for previews). The generator is dynamically imported, so apps that only design templates never load its ~1.5MB chunk.

Arabic and other RTL text

pdfme's built-in font is Roboto, which has no Arabic glyphs, so register a font with Arabic coverage first. The same bytes are used by the generator and by the canvas preview, so what you design is what you print:

import { registerFont } from 'print-designer-vue';

await registerFont('Cairo', '/fonts/Cairo-Regular.ttf'); // URL or Uint8Array

It must be a .ttf or .otf — .woff2, which is what Google Fonts serves by default, cannot be embedded in a PDF and is rejected with a message saying so.

Prefer a static TTF over a variable one. Google Fonts ships Cairo and Noto Naskh Arabic as variable fonts; pdf-lib embeds the default instance, so weight axes are silently lost. Amiri is a static OFL naskh face (~430KB) and is what the demo in dev/ uses — copy dev/fonts/Amiri-Regular.ttf into your app if you want a known-good starting point.

No font is bundled in the published package: hosts register their own, and shipping one would push a typeface choice onto every app that installs this.

Without a covering font, Arabic prints as empty boxes and generation still succeeds, so the designer shows a warning naming the characters that cannot be drawn, and fontWarnings(template) returns the same list for a server-side pre-flight check.

Then set a field's fontName to 'Cairo' (the designer shows a Font picker once a font is registered). The first font registered becomes the fallback for every field.

Bidirectional text is handled for you. The PDF glyph layer shapes Arabic correctly but reverses the whole string, which turns 1,250 into 052,1 and Acme Ltd into dtL emcA. generatePdf corrects this per field type — prose and table cells are fixed, while barcode payloads and image data URIs are left byte-exact:

Input Without the fix As shipped
الإجمالي 1,250.75 دينار الإجمالي 57.052,1 دينار الإجمالي 1,250.75 دينار
الهاتف: +964 770 123 4567 الهاتف: 7654 321 077 469+ الهاتف: +964 770 123 4567
العميل: Acme Ltd العميل: dtL emcA العميل: Acme Ltd
٢٥ (on its own) ٥٢ ٢٥

This is a heuristic covering LTR runs inside an RTL paragraph — the case print templates actually hit — not the full UAX #9 algorithm. An LTR paragraph with embedded RTL runs is not handled; see the note in src/bidi.js.

Template shape

{
  basePdf: { width: 210, height: 297, padding: [0, 0, 0, 0] }, // millimetres
  schemas: [[                                                   // one array per page
    { name: 'customer', type: 'text', content: 'Acme Ltd',
      position: { x: 20, y: 50 }, width: 45, height: 10,        // millimetres
      fontSize: 12, fontColor: '#000000', alignment: 'left' }   // fontSize in points
  ]]
}

name is the data-binding key and is kept unique automatically.

Printing

One call takes the template plus the data and puts the printer dialog in front of the user — no download-then-open step.

import { printDocument, downloadDocument, renderDocument } from 'print-designer-vue';

await printDocument(template, invoice);              // straight to the printer
await downloadDocument(template, invoice, { filename: 'INV-2024-0837.pdf' });
const bytes = await renderDocument(template, invoice); // just the bytes

From the designer, this is all you need:

<PrintDesigner v-model="template" :fields="fields" :record="record" />

The toolbar's Print and Export PDF buttons resolve the template against that record, so what the canvas previews is what the printer produces.

A batch is one document, not one file per record. Hand it an array and you get a single PDF to send to the printer once:

await printDocument(template, [inv1, inv2, inv3]); // 3 records -> 3-page run

All three resolve {{ ... }} against each record first, so the same call works whether the template is static or fully data-driven. They all return the bytes, which is also what makes them useful on a server:

res.type('pdf').send(Buffer.from(await renderDocument(template, record)));

Printing loads the PDF into an offscreen iframe and prints that. Where a browser refuses to print a PDF in a frame it falls back to opening the document in a new tab — pass onFallback to handle that yourself, or deliver to replace the delivery step (Safari, a kiosk printer, a print server).

Saving

Listen for save and the toolbar grows Save and Save & close. Without a listener neither appears — a Save button that does nothing is worse than none.

<PrintDesigner v-model="template" :saving="saving" @save="onSave" />
async function onSave(json, { close }) {
  saving.value = true;                       // disables both buttons
  try {
    await fetch('/api/templates/invoice', {
      method: 'PUT',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(json),
    });
    if (close) router.push('/templates');
  } finally {
    saving.value = false;
  }
}

One handler serves both buttons; close tells you which was pressed. Ctrl/Cmd S saves, Ctrl/Cmd Shift S saves and closes.

The payload is plain JSON, already detached — safe to structuredClone, post, or keep, and later edits in the designer will not mutate what you were handed.

Binding templates to your data

A template is designed once and printed against many records. A field's content is its binding: literal text prints as-is, {{path}} pulls from the record.

import { fetchFields, bindRecord, generatePdf } from 'print-designer-vue';

// 1. Tell the designer what the model offers, so users can point and click.
const fields = await fetchFields('/api/models/invoice');
<PrintDesigner v-model="template" :fields="fields" :record="sampleRecord" />

Clicking a model field binds it to the selection — appending {{number}} to a text field, or filling a table's rows wholesale. Pass a record and a Preview data toggle appears, so the canvas can show real values instead of placeholders while you check the layout.

// 2. At print time, resolve the template against the real record.
const record = await (await fetch(`/api/invoices/${id}`)).json();
const bytes = await generatePdf(template, [bindRecord(template, record)]);

Dotted paths walk relations ({{customer.name}}), and a missing value prints as empty rather than leaking the placeholder onto the page.

Tables bind to an array. Set the field's content to a single placeholder and name the columns:

{ type: 'table', content: '{{items}}', columns: ['product', 'qty', 'price'],
  head: ['البند', 'الكمية', 'السعر'] }

Rows that are already arrays are used as-is; row objects are read through columns, falling back to their own key order.

A column key can also be an expression over the row, so columns format and branch like fields — the designer's Format menu on each column writes these:

columns: ['#', 'product', "date(createdAt, 'dd/MM/yyyy')", "money(price, 'IQD')",
  "switch(status, 'P', 'Paid', 'Due')", "qty >= 20 ? 'Bulk' : 'Unit'"]

Every function a field can use works in a column. An expression reads the row first and then the record, so money(price, currency) can take the invoice's currency; a plain key reads the row only. Braces are optional, so an example copied from a field works as it is.

placeholders(template) lists every path a template needs — useful for validating a template against a model before anyone prints it.

Nested fields and array columns

Objects and arrays keep their shape instead of being flattened into a long list of dotted paths. In the panel an object is a group you expand, and an array shows the columns it can fill:

Invoice no.        string
Total              number
> Customer         relation      <- expands to Name, City, Phone
  Lines            3 columns     <- expands to Product, Quantity, Price

Clicking an array creates a table with its columns already set up — heading, data key and width per column. Nobody types product, qty, price by hand. Click it again with a table selected and it refills that table instead.

Each column then has a Shows menu listing that array's fields, so choosing what a cell displays is picking from a list rather than knowing the key. A table not bound to an array still takes a typed key.

There is also a filter box, because a real model has more fields than fit on screen.

No schema endpoint? Use a real record

Most backends have no schema route, but every backend can return one document. inferFields reads the model off it — nesting, arrays, types and all:

import { inferFields } from 'print-designer-vue';

const invoice = await (await fetch('/api/invoices/latest')).json();
const fields = inferFields(invoice);   // pass straight to :fields

It handles what real documents contain: objects inside array rows (lines[].item.category.name), null values (still bindable, type unknown), empty arrays (a table with no columns yet), ISO dates, and keys turned into readable labels — docTotal becomes “Doc total”. maxDepth (default 6) stops it following a very deep or self-referential shape.

A column can read any depth within a row, so item.category.name is a valid column key and appears in the picker grouped under its parent.

The model schema endpoint

fetchFields accepts the shapes backends actually return — a bare array of names, { fields: [...] }, { data: [...] }, { columns: [...] } — and reads name / field / key / column / id for the name. Nested relations are flattened to dotted paths:

{ "fields": [
  { "name": "number", "type": "string" },
  { "name": "customer", "type": "relation", "fields": [{ "name": "name" }] }
]}

becomes a customer group containing customer.name. Arrays keep their row shape — declare an array's fields and the designer can build a table from it. A failed request throws with its status rather than returning an empty list.

flattenFields(fields) gives the flat list of bindable paths where one is easier to work with; rowFieldsOf(fields, 'items') returns an array's columns.

Generating on the server

print-designer-vue/api has no Vue or Konva in it, so the same code runs in a Node route:

import { generatePdf, bindRecord, registerFont } from 'print-designer-vue/api';
import { readFile } from 'node:fs/promises';

await registerFont('Cairo', await readFile('./fonts/Cairo-Regular.ttf'));

app.get('/print/:id', async (req, res) => {
  const [template, record] = await Promise.all([loadTemplate(req.query.template), loadRecord(req.params.id)]);
  res.type('pdf').send(Buffer.from(await generatePdf(template, [bindRecord(template, record)])));
});

Logic in templates

Any field's content can carry an expression, not just a placeholder:

Total: {{ fixed(total, 2) }} IQD
{{ total >= 1000 ? 'VIP CUSTOMER' : 'Standard' }}
{{ default(customer.phone, 'no phone on file') }}
{{ switch(status, 'A', 'Active', 'B', 'Blocked', 'Unknown') }}
{{ sum(items, 'price') }}

Operators: + - * / %, < <= > >=, == != === !==, && ||, !, and ? :. Functions: upper lower trim len round fixed abs min max sum count default concat switch plus the formatters below.

Dates and amounts

A record carries 2026-09-01T11:52:41.2433614Z and 25000.000000; a document shows neither. Select a bound field and pick from Format in the inspector — or write it yourself:

Expression Result
{{ date(docDate) }} 2026-09-01
{{ date(docDate, 'dd/MM/yyyy') }} 01/09/2026
{{ datetime(docDate) }} 2026-09-01 14:52
{{ time(docDate) }} 14:52
{{ number(docTotal) }} 25,000
{{ number(docTotal, 2) }} 25,000.00
{{ money(docTotal, docCurrency.symbole, 2) }} 25,000.00 د.ع

Patterns use yyyy yy MM dd HH mm ss; anything else passes through.

Two behaviours worth knowing. A date-only value like 2026-09-01 is formatted from the string itself, never through a Date — parsing it as midnight UTC and printing it locally shows 31 August west of Greenwich, which is how due dates end up a day early. And a timestamp is read in the machine's local time, so a server in UTC and a browser in Baghdad print different clock times for the same instant; pass { utc: true } to formatDateTime if you need them to agree.

Grouping is explicit rather than locale-derived: an Arabic locale would render ٢٥٬٠٠٠, and an invoice whose other amounts use Western digits should not change script for one field. Paths walk relations and arrays — customer.name, items[0].price.

The inspector evaluates the expression against your sample record as you type, shows the result, and flags syntax errors before you print.

This is not eval. Templates get stored in a database and edited by whoever can reach the designer, so they are parsed by a closed grammar with no assignment, no method calls, and no reachable constructors or prototypes — an expression cannot escape into the host page. validateTemplate(template) reports every broken expression in one pass.

Pages, headers, footers and watermarks

schemas is an array of pages; basePdf.staticSchema is the master layer drawn behind every page.

import { addPage, addHeader, addFooter, addWatermark } from 'print-designer-vue';

addPage(template);
addHeader(template, 'شركة الأمل للتجارة');
addFooter(template, '{{currentPage}} / {{totalPages}}');
addWatermark(template, 'DRAFT');

{{currentPage}} and {{totalPages}} are resolved per page at generation. They are the only placeholders the record does not supply — binding leaves them alone and the generator fills them in, so a footer can mix them with your data: Page {{currentPage}} of {{totalPages}} — {{customer.name}}.

In the designer, the Master tab — the leftmost one, before the page numbers — edits that layer with the same tools — and addHeader / addFooter / addWatermark are only convenience presets. The master layer takes any element: text, images, shapes, tables, barcodes.

Master elements print on every page by default. To narrow that, set pages (or use Show on in the inspector):

addHeader(template, 'Continued…').pages = 'except-first';
addWatermark(template, 'COPY').pages = [1, 3];

'all' (the default), 'first', 'last', 'except-first', or an explicit list of page numbers.

The designer opens on page 1, with Master sitting first in the strip so it is the first thing seen. That default is deliberate: an element added before the user registers which surface they are on lands on one page rather than on every page. Use startOn to change it — 'master', or a 1-based page number:

<PrintDesigner v-model="template" start-on="master" />

Keyboard

Delete / Backspace Delete selection
Ctrl/Cmd C X V Copy, cut, paste
Ctrl/Cmd D Duplicate
Ctrl/Cmd Z / Shift Z Undo / redo
Arrow keys Nudge 1mm (Shift for 10mm)
Ctrl/Cmd + - 0 9 Zoom in, out, 100%, fit
Ctrl/Cmd + wheel Zoom about the pointer
Ctrl/Cmd S Save (Shift also closes)
Escape Deselect
Shift + click Add to / remove from the selection
Alt while dragging Ignore snapping

Wheel and shift-wheel scroll the canvas; zoom is on Ctrl/Cmd, which is also what a trackpad pinch sends.

Selecting, aligning and snapping

Shift-click on the canvas or in the Layers panel to select several fields. Delete, duplicate, copy/paste and the arrow keys all act on the whole selection, and dragging one field carries the rest with it.

The align buttons appear in the toolbar as soon as something is selected:

  • One field aligns against the page — centre it, push it to a margin.
  • Several align against their own bounding box, which is what you want when lining two labels up with each other rather than with the paper.
  • Three or more can be distributed, spacing the gaps evenly and leaving the outermost two where the user put them.

Dragging snaps to the page edges, the page centre, and the edges and centres of other fields, drawing a guide where it caught. Hold Alt to drag freely — snapping is right nearly always and maddening the one time you need 30.9mm. Locked fields are never moved by any of this.

Panel sizes

Every divider between panels can be dragged — elements, model fields, the inspector, and the layers list. Some model paths are long and some templates need the canvas wide, so the split is the user's to make. Each divider is also a focusable separator: arrow keys resize it, Shift for larger steps.

Theme

theme is 'auto' (follows the OS), 'light' or 'dark'. Tokens are scoped to the component, so nothing leaks into or out of the host page, and no webfont is downloaded — the UI uses the system font stack.

<PrintDesigner v-model="template" theme="dark" />

Field types

Icons come from Lucide — the same set the editor's own interface uses, so a placed icon matches the toolbar around it. Add an icon field and search all ~2,000 of them; colour and stroke weight stay editable after placing, because the field remembers which icon it is rather than freezing the markup.

Search understands the words people actually reach for, not just Lucide's names: invoice finds receipt, customer finds user, delivery finds truck. Arabic works too — فاتورة, هاتف, تاريخ, مخزن. A real Lucide word is never displaced by a suggestion.

The icon set is ~90KB gzipped and loads only when the picker is opened — a template with no icons never pays for it.

SVG artwork with gradients is flattened when printing: a PDF has no paint servers, so fill="url(#SVGID_2)" becomes the gradient's first stop colour. Without that, generation throws and Print and Export both fail. The canvas preview flattens too, so the editor shows what the printer produces, and the inspector says so on the field.

Group Types
Text text, date, dateTime, time, select
Data table
Media image, icon, svg
Codes qrcode, code128, ean13, ean8, upca, code39, itf14, pdf417
Shapes line, rectangle, ellipse, checkbox, radioGroup

Lines, rectangles and ellipses take a line style — solid, dashed, dotted, dash-dot or long dash — chosen in the inspector. The pattern scales with the stroke, so a hairline and a thick rule both read as the style they claim.

pdfme's shape plugins draw solid strokes only, and pdf-lib's SVG parser ignores stroke-dasharray, so the pattern is emitted as geometry: the outline is walked and only the "on" runs are drawn. That is also why a dash carries around a corner instead of restarting on each side.

addField(template, { type }) fills in a working default for each, and every type is covered by a test that generates a real PDF.

Barcodes and QR codes render for real on the canvas, through the same bwip-js that pdfme draws them with — so the preview and the printed page come from one engine rather than two that can disagree. It loads only when a template actually contains a code.

multiVariableText is deliberately not exposed: its content must be a JSON map of variable values, which contradicts this package's contract that content is the binding. A text field with {{ expressions }} does strictly more.

API

Export What it does
PrintDesigner The editor. v-model = template; fields, record, zoom props; @export emits PDF bytes
createTemplate(size?) Empty single-page template
addField(template, partial?) Appends a text field, returns it
printDocument(template, records?) Resolve, build, and open the printer dialog
downloadDocument(template, records?, { filename }) Resolve, build, and save a file
renderDocument(template, records?) Resolve and build; returns the bytes
generatePdf(template, inputs?) Lower level: inputs keyed by field name
registerFont(name, source, { weight?, italic? }) Embeds a TTF/OTF for the PDF and the canvas; register bold/semibold/italic files under the same name
fontWarnings(template) Fields whose characters no registered font can draw
describeFont(bytes) Font format, and whether a PDF can embed it
fontNames() Registered font families
FONT_CATALOG / loadFont(family, face?) Ready-made fonts (Cairo, Roboto, …), fetched on first use; generatePdf loads them for you
fixBidi(value) Reverse LTR runs in an RTL string (applied automatically)
fetchFields(url, init?) Model schema from your backend, normalised
normaliseFields(payload) The normalisation alone, for data you already have
bindRecord(template, record) Resolve {{path}} placeholders into pdfme inputs
buildPrompt(template, fields?, fonts?, brief?) A prompt any AI can turn into a full template JSON (also in the JSON dialog: Copy AI prompt, then Upload or paste the answer)
placeholders(template) Every model path the template reads
FIELD_TYPES / FIELD_DEFAULTS Available field types and their starting schemas
PAGE_SIZES A4, LETTER
mmToPt ptToMm mmToPx pxToMm Unit helpers

Long tables and page overflow

A table bound to an array grows with the data. pdfme flows page content through the band between the master header and footer: rows spill onto new pages, the head repeats on each, and whatever sits below the table (totals, signatures) follows its last row. Two rules make this work:

  • Give the table a small design height (head plus a row) and put totals right under it — they always land after the last row, however many rows there are.
  • Header, footer and page numbers go on the master layer. Their bands are reserved for you: flowing content never covers them, and {{currentPage}} / {{totalPages}} counts the pages actually generated.

npm run dev seeds a 100-line invoice that shows all of this.

Scope

Ships: text fields (add / drag / resize / delete / style), page presets and custom mm sizes, PDF export, JSON round trip.

Ships too: Arabic/RTL text with font embedding and bidi correction; text, image, QR, Code128, EAN-13 and table fields; model binding with a data palette and a server-side generation entry.

Not yet: chart fields, undo-redo, snapping guides, layers, multi-page, template persistence (your app owns that — the template is JSON). See specs/001-print-designer-package/.

Installing across several projects

There is no registry involved. Tag a release in this repo and each project depends on the tag:

npm i "github:<you>/print-designer#v0.1.0"

prepare builds the package on install, so nothing built is committed and every project gets the exact code of that tag. Pin the tag rather than tracking a branch — a shared print engine that silently changes under eighteen apps is how invoices start printing wrong. Upgrade a project by bumping its tag.

If install time becomes annoying (the build runs per project), commit dist/ and drop the prepare script; installs then just copy files.

Requirements in the host project

Vue 3.3+ (peer dependency, never bundled)
Node 18+ (fetch, structuredClone)
Bundler anything that reads ESM — Vite 4+, webpack 5, Nuxt 3/4
React not supported; the generator half (print-designer-vue/api) is framework-free and works anywhere

Nuxt and other SSR hosts

The canvas is client-only internally — Konva needs a real DOM at setup, so the component renders a sized placeholder on the server and swaps in the canvas after mount. That keeps hydration clean and means you do not need to wrap it in <ClientOnly>:

// nuxt.config.ts
export default defineNuxtConfig({ css: ['print-designer-vue/style.css'] });

Server routes should import print-designer-vue/api, which contains no Vue and no Konva.

Versioning

The template JSON is the contract, not just the JavaScript API:

  • major — a change that stops existing saved templates from rendering the same
  • minor — new field types, new options, new exports
  • patch — fixes, including rendering corrections that change output bytes

Develop

npm run dev     # demo app with a live JSON view
npm test        # unit + real-PDF generation tests
npm run build   # library build into dist/

Related Packages

@worm-vue3-print/canvas

Vue 3 可视化打印模板设计器画布(开源核心,无 Element Plus、无宿主业务依赖)

2,036
@worm-vue3-print/core

打印模板表达式引擎、同构渲染管线与框架无关的设计器内核(HTML 生成/分页/数据绑定/...

2,143
@jake-gao/vue3-hiprint

基于 hiprint 的 Vue3 可视化打印设计器

1,959 1
tiptap-print-designer

一个开箱即用的富文本打印设计器,基于 tiptap 和 Vue3 开发

2,981 9
@burnmark-io/designer-vue

Vue 3 composable for the burnmark label design engine

774 1

Version History

Version Released Vue License
0.12.0 ^3.3.0 MIT
0.11.1 ^3.3.0 MIT
0.11.0 ^3.3.0 MIT
0.10.0 ^3.3.0 MIT
0.9.0 ^3.3.0 MIT