BORROWDALEJS / PACKAGE REFERENCE

@borrowdale/core

Deterministic hand-drawn shapes for SVG and canvas. The stroke engine began as a TypeScript port of rough.js 4.6.6 and has no runtime dependencies. It draws from its own seeded random generator and its own trigonometry, so its output does not depend on the JavaScript engine.

JavaScript · workspace imports
npm install @borrowdale/core
JavaScript · workspace imports
import { sketch, toSvgDocument, toSvgSketch } from '@borrowdale/core';

const box = sketch(
  { kind: 'rectangle', x: 10, y: 10, width: 120, height: 60 },
  { fill: '#e76f51', fillStyle: 'hachure' },
);

const svg = toSvgDocument({ width: 140, height: 80, body: [toSvgSketch(box)] });

Same input, same drawing

A sketch is reproducible: the same shape and options produce the same strokes on every render, on the server and in the browser. Pass seed to vary a drawing. Without it, the seed is derived from the shape itself.

The engine never calls Math.random, and computes sine, cosine and arctangent with plain arithmetic instead of Math.sin and friends, whose last bit can differ between JavaScript engines. The test suite draws every reference drawing of this package in Node.js and in Chromium, Firefox and WebKit, so in the V8, SpiderMonkey and JavaScriptCore engines, and requires identical output. Coordinates are rounded to two decimals, which keeps the markup small.

The drawing depends on the exact version of the stroke engine, which ships inside this package. Use the same @borrowdale/core version on the server and in the browser.

Shapes

kind Fields
line x1, y1, x2, y2
rectangle x, y, width, height
ellipse cx, cy, width, height
circle cx, cy, diameter
arc cx, cy, width, height, start, stop, closed? (radians)
polygon points
linearPath points (open polyline)
curve points (smooth curve through the points)
path d (SVG path data)

Style

Option Applies to
stroke, strokeWidth outline
strokeLineDash (dash and gap lengths, as in SVG stroke-dasharray) outline; fill lines stay solid
fill, fillStyle (hachure, solid, zigzag, cross-hatch, dashed, zigzag-line, dots) fill
fillWeight, hachureAngle (default −41°; use 41 to mirror), hachureGap line-based fills; dots uses fillWeight as the dot size and hachureGap as the spacing, and ignores hachureAngle
dashLength, dashGap dashed fill
zigzagWidth zigzag-line fill
curveTightness curve shape
curveStepCount, curveFitting curves, ellipses, circles and arcs
roughness, bowing everything

An option set to undefined counts as not set. Anything else is checked, and a value that cannot be drawn throws an error naming the field or option, and the value where one was given:

  • Every number a shape needs must be present and finite and within ±1,000,000. Sizes must not be negative. Numbers given as strings are rejected, and the message quotes them (got "10").
  • Every number in path data must be within that range as written, and again once relative coordinates are resolved and arcs are turned into curves. A large arc's curve control points can lie outside the drawn shape, so a path can be rejected even when everything it draws is in range.
  • points must be an array of [x, y] pairs: at least 3 for polygon, 2 for linearPath and curve. An arc's stop must be greater than its start, and closed, when given, a boolean.
  • Unknown option names are rejected (Unknown option: "roughnes"). roughness and bowing must be from 0 to 10, curveTightness and curveFitting from 0 to 1, and curveStepCount an integer from 1 to 1000. hachureAngle may be any finite number of degrees: 400 draws as 40 does. strokeLineDash must be a plain array of 1 to 16 lengths from 0 to 1,000,000, with no holes, adding up to at least 1.
  • strokeWidth, fillWeight, hachureGap, dashLength, dashGap and zigzagWidth must be numbers from 0 to 1,000,000. Left out, strokeWidth is 1, fillWeight half the stroke width, hachureGap four stroke widths, and dashLength, dashGap and zigzagWidth the hachure gap.
  • stroke and fill must be a colour (a hex code, a name, rgb(), rgba(), hsl() or hsla()), or url(#id) naming a pattern or gradient in the same document, such as pattern().fill, at most 100 characters. Other CSS paints are rejected: a url() naming another file would have the page fetch it, and a var() would paint with a value the page sets. So are inherit, initial, unset and revert, which are not colours. currentColor is a colour: it paints with the color of the element the drawing is in. The renderers check that the paints of a sketch built by hand are colours or url(#id) of up to 1,000 characters, and throw Invalid sketch: stroke must be … otherwise.

Valid input can still describe an enormous amount of work, such as a dashed fill with no dash length or path data with a million segments. Each sketch call therefore stops after 200,000 units of drawing work and throws Drawing too complex (more than 200000 operations) instead of hanging or running out of memory. A call given a Budget (see below) stops at that budget's limit instead. The budget is coarse, with two known limits:

  • A drawing just under the budget can still take a few seconds and produce several megabytes of path data (for example a fine zigzag-line fill over a 3000 × 3000 shape). Set your own time and size limits around sketch when the input is untrusted.
  • A filled shape taller than about 66,000 units can exceed the budget even with a wide hachureGap, and whether it does depends on the seed. Keep filled shapes smaller, or scale the drawing down.

To bound several drawings together, such as every shape of one chart, pass them one Budget:

JavaScript · workspace imports
import { Budget, sketch } from '@borrowdale/core';

const budget = new Budget(400_000);
const bars = data.map((bar) => sketch(bar, { fill: 'teal' }, budget));

Every call then charges the same budget, and the call that takes the total past maxOperations throws. budget.spent reads the total so far. maxOperations is a whole number from 1 to Number.MAX_SAFE_INTEGER, and budget.maxOperations reads it back. A budget is never reset. Once a call has taken it past its limit, every later call given it throws too, so make a new one for each drawing. A budget's limit replaces the 200,000 of a single call, so the two known limits above grow with it: a budget in the millions lets one call run out of memory. sketch accepts only a budget made with new Budget().

Seeds and escaping

toSeed(value) turns any string or number into the seed a drawing uses, an integer from 1 to 2^31 − 1.

Without a seed, the seed comes from stableKey(shape): the shape serialised with its keys sorted. { x: 0, y: 0, … } and { y: 0, x: 0, … } are therefore drawn identically.

svgElement(tag, attributes, children?) builds an SVG element string and escapes every attribute value, throwing for a character no escape can make fit (see escapeAttribute), as toSvgSketch and toSvgDocument do. Use it to build your own markup around Borrowdale output:

JavaScript · workspace imports
svgElement('rect', { width: 300, height: 200, fill: '#fdf8ef' });
// <rect width="300" height="200" fill="#fdf8ef"/>

children, and the defs and body of toSvgDocument, are inserted as markup without escaping. Only pass markup you built yourself, never text from users. The tag and attribute names are written as given too, so they must be your own literals: no escaping makes an attribute such as onload safe.

escapeAttribute(value) escapes a single string for use inside a double-quoted attribute. It throws for a character an XML document cannot hold (a control character other than tab, line feed and carriage return, U+FFFE, U+FFFF or an unpaired surrogate), since no escape makes it fit and a standalone SVG holding one would not draw at all.

Portable maths

@borrowdale/core/math exports the engine's own sin and cos, and a natural logarithm log, for numbers that must be the same in every JavaScript engine (for example points on a circle placed next to a drawing, or positions on a logarithmic scale):

JavaScript · workspace imports
import { cos, log, sin } from '@borrowdale/core/math';

const point = [cx + r * cos(angle), cy + r * sin(angle)];
const along = (log(value) - log(low)) / (log(high) - log(low));

They give the same result in every JavaScript engine. That result can differ from Math.sin, Math.cos and Math.log in the last bit; log is within one unit in the last place of the true value. sin and cos accept a finite number of radians within ±1,000,000, exported as MAX_ANGLE; not far past that range the result would be wrong without any sign of it. log accepts a finite number above 0. Each throws a RangeError for anything else.

Values in error messages

Every @borrowdale package writes a rejected value into an error message the same way: strings quoted and escaped, a short array in brackets, and anything that could throw or run code when converted named by its kind. Every character of a message that could break or disguise a line of a log, such as a line break or a bidirectional override, is written as its \u escape. Messages are plain text, not HTML: escape them before putting them into a page.

The wording of these messages is for people and can change in any release, so do not match on it in code.

The helpers that do this, and the colour checks every package applies, live in @borrowdale/core/internal, which the packages share. It is not public API and can change in any release.

Rendering

Function Output Needs
toSvgSketch(sketch) <path> markup string nothing (SSR-safe)
toSvgDocument({ width, height, defs?, body }) complete <svg> string nothing
appendSketch(parent, sketch) appends <path> elements to an SVG element a DOM
drawSketch(context2d, sketch, paint?) draws into a canvas Path2D

toSvgDocument and the paint of drawSketch reject an option name they do not take (Unknown option: "def"); an option set to undefined counts as not set.

A dashed outline keeps its strokeLineDash in the sketch. toSvgSketch and appendSketch write it as stroke-dasharray, and drawSketch sets it with setLineDash. drawSketch draws every outline with its own dash, starting at its beginning, and a solid one solid, whatever dash the canvas had. Outlines use butt caps, miter joins and a miter limit of 4, matching SVG defaults. A hand-built sketch's dash list with negative or non-finite entries draws solid. The caller's canvas state is restored even if drawing throws; the error still propagates and any pixels already drawn remain. Caller transforms, clipping, opacity and compositing still apply.

The optional paint argument to drawSketch supplies stroke and/or fill overrides as colours, CanvasGradient or CanvasPattern objects. It replaces existing paints without enabling a stroke or fill whose value is none. A canvas cannot paint url(#id) and ignores it, keeping the paint it had, so pass a CanvasPattern or CanvasGradient here for such a sketch. Canvas state is restored after each path, including when drawing throws.

The type declarations refer to DOM types (Element, CanvasRenderingContext2D), so TypeScript projects need "dom" in compilerOptions.lib, even when they only render on the server.

Validation without drawing

assertDrawable(shape, options) checks the same shape and style rules as sketch without drawing anything. It returns nothing and throws on invalid input. It checks structure and ranges only; drawing still stops at its work budget.

License

MIT. The stroke engine is ported from MIT-licensed code by Preet Shihn; its trigonometry uses constants from fdlibm, and its logarithm is a port of fdlibm's; see THIRD_PARTY_NOTICES.md.

Try these APIs in the playground ↗