Documentation

SVG and Canvas

PDF is a vector format. A logo pasted in as a PNG throws that away: it goes soft when someone zooms, fat when someone prints, and it carries pixels into a document that is meant to be archived. jasy draws vectors instead - either from an SVG file you already have, or from a pen you drive yourself.

Image recognises an SVG source by itself, so a logo needs no special handling:

import { Document, Page, Image, Svg, renderToBytes } from "@jasy/pdf";

Image("logo.svg", { width: 120 }); // a path (Node), bytes, or markup
Svg(markup, { width: 120, alt: "Acme" }); // the same thing, named explicitly

With no size it draws at its intrinsic size - the width/height on the file, or its viewBox. Pin one axis and the other follows the ratio, exactly as an unsized <img> behaves. The drawing is scaled uniformly and centred inside its box, which is SVG's own preserveAspectRatio default.

The file is read and parsed when you name it, not at render time, so a broken path or a malformed file fails on that line rather than three call frames deep in a render.

What it draws

ResolvedReported by name, never skipped
path (arcs, shorthands, relative coordinates), rect, circle, ellipse, line, polyline, polygon<text> - convert it to outlines
g, defs, viewBox, every transform function<use>, <symbol>
presentation attributes, style="", and <style> blocks with flat selectorsa CSS combinator or pseudo-class
fill, stroke (width, cap, join, dash), fill-rule, opacityfilter, <mask> - pixel operations
<clipPath>, <linearGradient>, <radialGradient>, href chainsa gradient on a stroke

SVG is a document format, not a graphics format. It carries CSS, text layout, filters and animation, and filters and masks are pixel operations that no vector backend can express - which is why anything that renders SVG completely owns a rasterizer, and why every PDF library ships a subset. Ours is above.

The difference is what happens at its edge: an element jasy cannot draw is a named error with a fix, never a silent skip. A dropped element leaves a logo that renders and is quietly wrong, and nobody finds out. (Measured: react-pdf's <style> handling loses every fill of an Illustrator export made with its default "Internal CSS" option.)

@jasy/pdf: the SVG element <text> is not supported yet.
           Convert the text to outlines in your editor - a PDF cannot lay out SVG text.

The subset was checked against 10,819 real SVG files taken off a working machine: 96.4% render. Correctness is checked against headless Chrome, not against our own reasoning - that is how we found that the root <svg> clips to itself, and that its own fill="none" (the Figma export default, in 778 of those files) has to be read or every unfilled shape comes out black.

Drawing it yourself

Canvas is the escape hatch for what no component covers: a sparkline, a seal, a chart, a signature flourish. You get a box in the layout and a typed pen.

import { Canvas } from "@jasy/pdf";

Canvas({ height: 46 }, (c, { width, height }) => {
  c.move(0, height).line(20, 12).line(60, 22).line(width, 4);
  c.stroke("#1450aa", { width: 1.5, cap: "round", join: "round" });

  c.circle(width - 4, 4, 2.5).fill("#1450aa");
});

The callback is handed the size the layout resolved, so a drawing written against size.width works at any width - put the same function in a 300pt box and a 160pt one and it fits both.

The pen

Building a pathPainting it
move line curve quad closefill(paint?, { rule })
rect circle ellipse polygonstroke(colour?, { width, cap, join, miterLimit, dash })
path(d) - SVG path data, through the same reader Svg() usesfillAndStroke(fill, stroke, opts)

Painting finishes the path and starts a new one, so a shape can never leak into the next. fill takes any colour form the rest of the API takes - and a gradient, resolved against the shape's own bounding box, the way objectBoundingBox works in SVG.

Scopes instead of save/restore

A transform or a clip is a scope that closes itself. There is no save()/restore() pair to forget, and no lineWidth(2) that a later stroke() happens to pick up:

Canvas({ width: 90, height: 90 }, (c, { width }) => {
  for (let i = 0; i < 12; i++) {
    c.group({ translate: [width / 2, width / 2], rotate: i * 30 }, (g) => {
      g.rect(-1, -width / 2 + 6, 2, i % 3 === 0 ? 10 : 5).fill("#0a2348");
    });
  }

  c.clipped(
    (clip) => clip.circle(width / 2, width / 2, 20),
    (draw) => draw.rect(0, 0, width, width).fill("#f3dc29"),
  );
});

Coordinates are top-left and in points - the same system as every other element and the same as SVG. 0,0 is the canvas box's own corner.

It is a block in the layout, not a way around it

A canvas is clipped to its own box, paginates as one unbreakable unit, and can carry alt to become a Figure in a tagged PDF. What it deliberately does not have is text layout: labels belong in Text, in the tree, where they stay selectable, searchable and tagged.

Row({ gap: 8, align: "stretch" }, [
  Column(
    { justify: "between", width: 26 },
    ticks.map((t) => Text(String(t), { size: 7 })),
  ),
  Expanded(
    { flex: 1 },
    Column({ gap: 4 }, [
      Canvas({ height: 170, alt: "Monthly revenue" }, barChart),
      Row(
        { justify: "between" },
        months.map((m) => Text(m, { size: 7 })),
      ),
    ]),
  ),
]);

That is a real chart: the drawing is a canvas, the axes are text in the layout.

Both are components in Vue and Nuxt as well - see the Vue components.

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