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.
An SVG logo
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
| Resolved | Reported 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 selectors | a CSS combinator or pseudo-class |
fill, stroke (width, cap, join, dash), fill-rule, opacity | filter, <mask> - pixel operations |
<clipPath>, <linearGradient>, <radialGradient>, href chains | a 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 path | Painting it |
|---|---|
move line curve quad close | fill(paint?, { rule }) |
rect circle ellipse polygon | stroke(colour?, { width, cap, join, miterLimit, dash }) |
path(d) - SVG path data, through the same reader Svg() uses | fillAndStroke(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.