napkin-sketchdocs 1.0.0 GitHub

The drawing engines#

On this page

API hub · Reference · Quickstart · Cheatsheet

The app's tools stand on engines that run as plain functions: regions combined and cut, the Eraser, the Wipe Stacks and the Shape Stacker, Split, clipping masks, the Pencil's grades and grain, the Smear and Liquify; the blend modes, the effects and the appearance stack, Splatter, Warp and 3D; measuring along a path; setting type, hyphenating it and drawing it from font files; Text Configurations and Composition Styles. This page is the reference for each as a program calls it from @isocialpractice/napkin-sketch, with a worked example for each that runs as it is.

Contents

What this is#

What the Eraser cuts, what a wipe leaves, where Split cuts and how a Liquify brush bends are not drawing-window code: each is a function in the core, which the window calls on a press and a napkin script calls for its verbs. The same functions are exported from @isocialpractice/napkin-sketch, so a program can do to a sketch what the tools do, with no window, no DOM and no disk.

None of them changes what it is given but the pixel passes, which work on the picture they are handed. An engine that edits marks gives back the edit - which marks changed, which went, which were added - and the caller applies it; one that draws gives back pixels, points or shapes. The sketches they read are the app's own: a page from evaluate, from a .skbk, or from the importer.

The mental model#

 marks --> regions (closed contours) --> booleanOp --> contours
 a page + mark ids --> eraseMarks, wipeMarks, stackFaces --> an edit: changed, removed, added
 a mark + a point --> nearestOnMark --> splitMark --> two marks, or one opened
 a page --> clipIndex --> what each layer is clipped by
 a pencil mark --> rasterizePencil, smudgeBuffer --> its picture's pixels
 marks + dabs --> liquifyMarks --> the marks bent
 two pixels + a mode --> blendPixel --> one pixel
 a picture + effects --> runEffects --> the picture drawn, and what lies under it
 a region + options --> splatterPaths --> hatch paths
 a box + a style --> warpStyleMap --> a map of points
 an outline --> extrudeMesh, revolveMesh --> a mesh;  a mark + 3D --> render3d --> faces
 a path --> lengthTable --> points, directions and bends by distance
 a text --> layoutType --> lines and glyphs --> outlineText + font files --> shapes
 a page + a composition style --> applyCompositionStyle --> the page restyled, and a report

Coordinates are page pixels, y running down, as everywhere in the app.

Reference#

Regions#

A region is the inside of closed contours: an array of contours, each an array of points { x, y }, filled under the non-zero rule unless a call says evenodd. A square is one contour of four points; a ring is two, the hole wound against the outside. Every engine that cuts or combines works on regions, and gives back marks rebuilt from them, with their curves kept where a result runs along a mark's own outline.

Combining regions: booleanOp#

booleanOp(a, b, op, options?) combines two regions: union (in either), difference (in a and not in b) or intersection (in both). It gives back the result's contours, or null when the operands have more than BOOLEAN_EDGE_LIMIT edges between them or floating point leaves pieces that will not close. options.ruleA and options.ruleB say how each operand fills; booleanRegions gives the same result with the vertices where it passes from one operand's edge to the other's, which a refit keeps as corners.

import { booleanOp } from '@isocialpractice/napkin-sketch';

const square = (x, y, size) => [[{ x, y }, { x: x + size, y }, { x: x + size, y: y + size }, { x, y: y + size }]];
const ring = booleanOp(square(0, 0, 100), square(25, 25, 50), 'difference');
console.log(ring.length);
// → 2
const overlap = booleanOp(square(0, 0, 100), square(50, 50, 100), 'intersection');
console.log(JSON.stringify(overlap[0].map((p) => [p.x, p.y]).sort()));
// → [[100,100],[100,50],[50,100],[50,50]]

Erasing: eraseMarks#

eraseMarks(sketch, ids, region) cuts a region out of the marks of ids, as the app's Eraser does, and gives back { changed, removed, skipped, raster }: each changed mark whole, by id; the ids of marks it left nothing of; the marks in reach it passes over - text, pictures and placed files; and the marks whose cut the geometry could not make. eraseRegionOf(eraser) is the region an eraser mark takes away: its swath, the width it is drawn at all along, with round ends. eraseKind(mark) says how a mark is cut: area for a filled shape, a Copic stroke or a profiled one, line for the rest, skip for what it passes over.

import { evaluate, eraseMarks, eraseRegionOf } from '@isocialpractice/napkin-sketch';

const page = evaluate('napkin 1\nline 0 50 200 50\n').book.sketches[0];
const eraser = { id: 'e', tool: 'eraser', color: '#000000', width: 20, points: [{ x: 100, y: 0 }, { x: 100, y: 100 }] };
const cut = eraseMarks(page, page.strokes.map((s) => s.id), eraseRegionOf(eraser));
const [line] = cut.changed.values();
console.log(JSON.stringify(line.vector.anchors.map((a) => [Math.round(a.p.x), a.p.y])));
// → [[0,50],[89,50],[111,50],[200,50]]

The line is cut where the eraser's swath, 20 wide, meets its own width.

Wiping: wipeMarks#

wipeMarks(sketch, ids, op) combines the marks of ids as the app's Wipe Stacks - a vector editor's Pathfinder - do, taking them in paint order, the first at the bottom. op is one of WIPE_OPS:

opPathfinderWhat is leftPainted as
inUniteeverything the marks coverthe topmost mark
out-frontMinus Frontthe bottom mark, less the restthe bottom mark
out-backMinus Backthe top mark, less the restthe top mark
midIntersectwhere all of them overlapthe topmost mark
outerExcludewhere an odd number overlapthe topmost mark
cleanDivideevery piece of their overlaps, a mark eachthe topmost mark over the piece

It gives back an edit, { changed, removed, added, skipped, problem, empty }: the mark a result is painted as is changed in place, the others are removed, and clean's pieces are added, each { stroke, above }, above the id of the layer it goes over. problem is too-few, too-many or failed when the wipe did nothing, and empty says the result left nothing at all. wipeOperand(mark) says what a mark is to a wipe: a filled mark or a closed outline is its inside, anything else the ink it paints.

import { evaluate, wipeMarks } from '@isocialpractice/napkin-sketch';

const page = evaluate('napkin 1\nfill #27486d\nrect 0 0 100 100\ncircle 100 50 50\n').book.sketches[0];
const [square, circle] = page.strokes;
const union = wipeMarks(page, [square.id, circle.id], 'in');
console.log(union.problem, union.changed.has(circle.id), union.removed.has(square.id));
// → null true true

Faces and stacking: arrangeFaces, stackFaces#

arrangeFaces(regions) cuts regions into their faces: every piece some of them cover and the rest do not, each { contours, covers }, covers the indices of the regions it lies in. It is what the Shape Stacker shows under the pointer, and what clean makes marks of. Past WIPE_FACE_LIMIT faces it gives none, with problem saying why.

stackFaces(sketch, ids, at, mode) is the Shape Stacker's drag as one call: the faces of the marks of ids under the points at are merged into one shape (merge), painted as the topmost mark over the first point's face, or taken away from every mark (remove). It gives back a wipe's edit, with missed, the points on no face. stackArrangement, faceAt, facesAlong, facesInBox and stackEdit are its steps, for a caller that picks faces itself.

import { arrangeFaces, evaluate, stackFaces } from '@isocialpractice/napkin-sketch';

const square = (x, y, size) => [[{ x, y }, { x: x + size, y }, { x: x + size, y: y + size }, { x, y: y + size }]];
const { faces } = arrangeFaces([square(0, 0, 100), square(50, 0, 100)]);
console.log(faces.map((face) => face.covers.join('+')).join(' '));
// → 0+1 0 1
const page = evaluate('napkin 1\nfill #d0342c\nrect 0 0 100 100\nfill #27486d\nrect 50 0 100 100\n').book.sketches[0];
const taken = stackFaces(page, page.strokes.map((s) => s.id), [{ x: 75, y: 50 }], 'remove');
console.log(taken.changed.size, taken.missed.length);
// → 2 0

The overlap taken away leaves both squares with a notch where they met.

Splitting: splitMark#

nearestOnMark(mark, point, options?) finds where on a mark's path a point lands, { subpath, segment, t, point, distance }, or null for a mark with no path; with anchorReach, an anchor that near wins over any segment, so a cut there adds none. splitMark(mark, at) cuts the mark there, as the app's Split does, into { first, second }: an open path becomes two marks, the first keeping the mark's id, and a closed one opens there, second null. It is null where there is nothing to cut, such as the end of an open path. splitTarget(marks, point, reach) finds the topmost of marks given in paint order whose path passes within reach of a point, and SPLIT_REACH_PX is the reach a script's split uses.

import { evaluate, nearestOnMark, splitMark } from '@isocialpractice/napkin-sketch';

const line = evaluate('napkin 1\nline 0 0 200 0\n').book.sketches[0].strokes[0];
const at = nearestOnMark(line, { x: 80, y: 3 });
const { first, second } = splitMark(line, at);
console.log(first.vector.anchors.map((a) => a.p.x).join(','), second.vector.anchors.map((a) => a.p.x).join(','));
// → 0,80 80,200

Clipping masks#

A clip group is a group layer whose clip names a closed mark inside it: the group shows only inside that mark, which shows nowhere while it clips.

FunctionWhat it gives
makeClip(sketch, ids)What Make Clipping Mask does with the marks of ids: { clip, layers } - the topmost of them, which clips, and the layers to group - or { problem }, too-few or open
releaseClip(sketch, layerId)The clip group Release Clipping Mask takes the clip off for a layer, or null
canClip(mark)Whether a mark can clip: a closed path the Eraser can cut
clipIndex(sketch)Every clip on a page, worked out once: marks, the clip marks, and of(layer), the clips a layer sits under, innermost first
clippedAt(sketch, mark, point, index?)Whether a mark is clipped away at a point
clipMarkOf, clipRegionOf(sketch, group)A group's clip mark, and its inside as a region
shownBounds(sketch, mark, bounds, index?)A mark's box cut to every clip it sits under, or null
normalizeClips(sketch)Takes clip off every layer it does not name a closed mark inside; says whether it took any off

CLIP_GROUP_NAME is the name Make Clipping Mask gives the group.

import { clipIndex, clippedAt, evaluate, makeClip } from '@isocialpractice/napkin-sketch';

const page = evaluate('napkin 1\nclip "Window" {\n  rect 0 0 200 100\n  circle 100 50 40\n}\n').book.sketches[0];
const [rect] = page.strokes;
const index = clipIndex(page);
console.log(clippedAt(page, rect, { x: 100, y: 50 }, index), clippedAt(page, rect, { x: 10, y: 10 }, index));
// → false true
const loose = evaluate('napkin 1\nrect 0 0 200 100\nline 0 0 10 10\n').book.sketches[0];
console.log(JSON.stringify(makeClip(loose, loose.strokes.map((s) => s.id))));
// → {"problem":"open"}

The line is on top, and an open path cannot clip.

Pencils: PENCIL_GRADES, pencilPaint, grainTile#

PENCIL_GRADES is the drawing kit, 30 pencils by hardness: graphite 9H to 9B, charcoal pencils, and vine and compressed charcoal. Each grade has its label and script name, the tone it lays down at its darkest, its lead width, density, grain and softness, and the response of its pressure. pencilPaint(choice) gives the grade a choice names - { medium, grade }, or a name as a script writes it - and graphite HB for one it does not know; parsePencil(name) gives the choice or null. pencilCoverage(grade, tooth, pressure) is what one pass lays down on paper of a tooth height.

grainTile(seed?) is the paper's tooth: a GRAIN_TILE_SIZE square of heights, 0 a valley and 255 a peak, that wraps without a seam; the same seed gives the same bytes everywhere. pencilRegion(mark, scale) is the box a mark's picture covers, and rasterizePencil(mark, region) its pixels through that tooth, straight RGBA; pencilPicture adds the mark's Smear passes.

import { GRAIN_TILE_SIZE, PENCIL_GRADES, grainTile, pencilPaint } from '@isocialpractice/napkin-sketch';

console.log(PENCIL_GRADES.length, pencilPaint('2B').label, '|', pencilPaint('vine-soft').label);
// → 30 Graphite 2B | Vine charcoal, soft
console.log(pencilPaint('nonsense').label, grainTile().length === GRAIN_TILE_SIZE ** 2);
// → Graphite HB true

Smearing: smudgeBuffer#

smudgeBuffer(data, region, pass, rgb, from?) runs one pass of the Smear's stump over a pencil mark's picture, in place: pass is { points, width, strength }, the drag in page pixels, the stump's width and its strength from 0 to 1, and rgb the color the graphite is. It trades graphite with the paper as density, so the darkness is kept and tone moves along the drag. It gives back where it got to, which a later call takes as from to go on with a drag that has grown. smudgeFor(mark, drag, width, strength) is the pass a drag leaves on one mark, which the app keeps in the mark's smudges, and DEFAULT_SMEAR_STRENGTH the strength a new stump has.

import { evaluate, rasterizePencil, smudgeBuffer } from '@isocialpractice/napkin-sketch';

const mark = evaluate('napkin 1\npencil 4B\nline 50 0 50 100\n').book.sketches[0].strokes[0];
const region = { scale: 1, x: 20, y: 0, width: 70, height: 100 };
const picture = rasterizePencil(mark, region);
const reached = () => picture.filter((value, i) => i % 4 === 3 && value > 0).length;
const before = reached();
smudgeBuffer(picture, region, { points: [{ x: 30, y: 50 }, { x: 80, y: 50 }], width: 16, strength: 0.6 }, [0x4a, 0x4d, 0x52]);
console.log(reached() > before);
// → true

The stump carried graphite off the line along the drag, so more of the picture holds some.

Liquify: liquifyField, liquifyMarks#

liquifyField(dab) is a Liquify brush's field: where it takes each point, point(p), and how far it turns the plane there, rotation(p). A dab is { mode, x, y, radius, amount?, dx?, dy? }: warp pushes by dx, dy; twirl turns by amount degrees, clockwise on the screen; pucker and bloat draw in or push out by amount, 0 to 1, of a point's distance. The effect falls off smoothly to nothing at the radius, as liquifyFalloff says, and a point past it comes back exactly.

liquifyMarks(marks, dabs, options?) is a drag: each dab bends the marks it reaches, and the next bends what the last left. It gives back the marks bent, by id; pencil marks, text, pictures and eraser marks are left alone (liquifiable). With options.refit, each bent mark is fitted again within that many pixels so its anchors stay few, as refitLiquified does.

import { evaluate, liquifyField, liquifyMarks } from '@isocialpractice/napkin-sketch';

const twirl = liquifyField({ mode: 'twirl', x: 100, y: 100, radius: 50, amount: 90 });
const p = twirl.point({ x: 120, y: 100 });
console.log(Math.round(Math.hypot(p.x - 100, p.y - 100)), JSON.stringify(twirl.point({ x: 200, y: 100 })));
// → 20 {"x":200,"y":100}
const page = evaluate('napkin 1\nrect 20 20 160 100\n').book.sketches[0];
const bent = liquifyMarks(page.strokes, [{ mode: 'warp', x: 100, y: 20, radius: 30, dx: 0, dy: 20 }], { refit: 0.5 });
const [rect] = bent.values();
console.log(bent.size, Math.round(Math.max(...rect.points.filter((q) => q.y < 60).map((q) => q.y))));
// → 1 40

A twirl keeps every point's distance from its centre; the warp pushed the rectangle's top down by its 20 at the brush's centre.

Applying an edit#

eraseMarks, wipeMarks and stackFaces give back an edit and change nothing. The app applies one as one undo step; a program applies it to a page the same way: changed marks in their place, keeping their ids and layers, removed ones gone, and added ones on the layer above names.

import { evaluate, wipeMarks } from '@isocialpractice/napkin-sketch';

/** A page with an edit made: what the app's store does with one, as one undo step. */
function applyEdit(page, edit) {
  const kept = page.strokes
    .filter((mark) => !edit.removed.has(mark.id))
    .map((mark) => (edit.changed.has(mark.id) ? { ...edit.changed.get(mark.id), id: mark.id, layer: mark.layer } : mark));
  const added = edit.added.map(({ stroke, above }, i) => ({ ...stroke, id: stroke.id || `added-${i}`, layer: above }));
  return { ...page, strokes: [...kept, ...added] };
}

const page = evaluate('napkin 1\nfill #27486d\nrect 0 0 100 100\ncircle 100 50 50\n').book.sketches[0];
const after = applyEdit(page, wipeMarks(page, page.strokes.map((s) => s.id), 'clean'));
console.log(page.strokes.length, after.strokes.length);
// → 2 3

The app puts each added mark on a new layer of its own above above; here they join that layer.

Blending: BLEND_MODES, blendPixel#

The sixteen blend modes of a vector editor's Transparency panel, under napkin's names - Stack for Multiply, Sheer for Screen - as the canvas, the PNG and every file draw them. blendModeOf reads any of a mode's names, napkin's, a vector editor's or CSS's; blendPixel mixes two straight RGBA pixels, channels 0 to 1, the source over the backdrop, as blendColor mixes two colors and blendChannel one channel of a separable mode.

import { BLEND_MODES, blendModeOf, blendPixel } from '@isocialpractice/napkin-sketch';

console.log(BLEND_MODES.length, blendModeOf('multiply'), blendModeOf('Color Dodge'));
// → 16 stack bleach
console.log(blendPixel('stack', [1, 0.8, 0.4, 1], [0.5, 0.5, 0.5, 1]));
// → [0.5,0.4,0.2,1]

The appearance stack: addEffect, appearanceSections#

A mark's effects are one list, in the order they apply; the Appearance panel shows it in three sections - those aimed at the stroke, at the fill, and at the whole - and edits it with pure calls that give a new list and never change the one given: addEffect, replaceEffect, removeEffect, duplicateEffect, toggleEffect, retargetEffect and moveEffect. defaultEffect(type) is an effect with a vector editor's defaults, and effectsAfterTransform is a list scaled, turned or mirrored with its mark.

import { addEffect, appearanceSections, defaultEffect, toggleEffect } from '@isocialpractice/napkin-sketch';

let effects = addEffect([], defaultEffect('outer-glow'));
effects = addEffect(effects, defaultEffect('bevel'), 'fill');
effects = toggleEffect(effects, 0);
const sections = appearanceSections(effects);
console.log(sections.fill.length, sections.object.length, effects[0].on);
// → 1 1 false

Effects as pixels: runEffects#

An effect list drawn over a picture as the PNG draws it. fromRgba8 reads 8-bit pixels into the engine's image; runEffects works on that image in place - the matrix is the one the picture is drawn under, which sizes a blur and turns a shadow's offset - and gives back what is laid under it apart, an Outer Shadow or Glow that blends; toRgba8 writes the image back. blankImage and layOnto make a picture and lay one on another in a blend mode.

import { fromRgba8, runEffects, toRgba8 } from '@isocialpractice/napkin-sketch';

// A 9 by 9 picture: its middle pixel white, the rest clear.
const data = new Uint8ClampedArray(9 * 9 * 4);
data.set([255, 255, 255, 255], (4 * 9 + 4) * 4);
const img = fromRgba8(data, 9, 9);
const under = runEffects(img, [{ type: 'blur', radius: 1 }], [1, 0, 0, 1, 0, 0], () => {});
const out = toRgba8(img);
console.log(under.length, out[(4 * 9 + 4) * 4 + 3] < 255, out[(4 * 9 + 5) * 4 + 3] > 0);
// → 0 true true

Splatter: splatterPaths#

A region's fill redrawn as a loose hatch, as a pen scribbles a shape in: the region is closed contours, and the options a vector editor's Scribble Options, which SPLATTER_PRESETS gives by name, and a seed that draws the same hatch every time. Each path is cubic anchors.

import { SPLATTER_PRESETS, splatterPaths } from '@isocialpractice/napkin-sketch';

const square = [[{ x: 0, y: 0 }, { x: 60, y: 0 }, { x: 60, y: 60 }, { x: 0, y: 60 }]];
const paths = splatterPaths(square, { ...SPLATTER_PRESETS.sharp, seed: 1 });
console.log(paths.length, paths[0].length);
// → 1 16

Warp: warpStyleMap#

One of the twelve Warp styles laid over a box: where a point goes, how far the map turns there and its derivative, which the Warp effect bends a mark's anchors and handles by. WARP_STYLES names them; the options are a vector editor's Warp Options.

import { WARP_STYLES, warpStyleMap } from '@isocialpractice/napkin-sketch';

const box = { minX: 0, minY: 0, maxX: 200, maxY: 100 };
const arc = warpStyleMap('arc', box, { orientation: 'horizontal', bend: 50, distortH: 0, distortV: 0, part: 'whole' });
const at = (p) => {
  const q = arc.point(p);
  return [Math.round(q.x), Math.round(q.y)];
};
console.log(WARP_STYLES.length, at({ x: 0, y: 0 }), at({ x: 100, y: 0 }), at({ x: 200, y: 0 }));
// → 12 [-25,52] [100,0] [225,52]

The arc keeps the box's middle where it was and swings its corners out and down, as the top of an arch does.

3D: extrudeMesh, revolveMesh, render3d#

An outline built in depth, pushed back into a solid or turned round an axis, and a mark's 3D effect drawn as shaded faces, far ones first: each a flat fill a renderer draws as it draws any shape. triangulate cuts a polygon with holes into triangles; threeDView and projectPoint put a point of the solid on the page.

import { defaultEffect, extrudeMesh, render3d, revolveMesh } from '@isocialpractice/napkin-sketch';

const square = { rings: [[{ x: 0, y: 0 }, { x: 40, y: 0 }, { x: 40, y: 40 }, { x: 0, y: 40 }]], closed: true };
const block = extrudeMesh(square, 20, true);
console.log(block.faces.length, block.faces.filter((face) => face.kind === 'cap').length, block.closed);
// → 6 2 true
const ring = revolveMesh(square, { angle: 360, offset: 10, axis: 'left', segments: 12 }, 0.5);
console.log(ring.faces.length);
// → 48
const mark = { id: 'm', tool: 'pen', color: '#27486d', width: 2, fill: '#ffcc00', points: [{ x: 0, y: 0 }, { x: 40, y: 0 }, { x: 40, y: 40 }, { x: 0, y: 40 }, { x: 0, y: 0 }] };
const faces = render3d(mark, defaultEffect('3d'));
console.log(faces.length > 0, faces.every((face) => face.points.length > 0));
// → true true

The block is six faces, two of them its caps; turned round an axis 10 off its left edge, the square is a ring of 48 faces, its four edges at twelve steps.

Measuring along a path: lengthTable, pointAtLength#

A path walked by distance rather than by its curves' own parameter: a table of a path's lengths, then the point, the direction, the normal and the bend so far along, and the nearest length to a point. Text on a path, Splatter and Warp measure by it.

import { lengthTable, pointAtLength, tangentAtLength } from '@isocialpractice/napkin-sketch';

const line = lengthTable([{ p: { x: 0, y: 0 } }, { p: { x: 100, y: 0 } }]);
console.log(line.total, pointAtLength(line, 25), tangentAtLength(line, 25));
// → 100 {"x":25,"y":0} {"x":1,"y":0}
const arch = lengthTable([
  { p: { x: 0, y: 0 }, hOut: { x: 0, y: -55 } },
  { p: { x: 100, y: 0 }, hIn: { x: 100, y: -55 } },
]);
const middle = pointAtLength(arch, arch.total / 2);
console.log(Math.round(arch.total), Math.round(middle.x), Math.round(middle.y));
// → 145 50 -41

Halfway along the arch by distance is its top, 41 above the line it stands on.

Setting type: layoutType, hyphenate#

A text mark set as the app sets it - point, area or path type, horizontal or vertical - into lines and glyphs, each glyph placed by its own matrix, with whatever did not fit said in overset. With no measure it is set in the built-in face; the app passes the canvas's. hyphenate breaks a word where the en-US patterns allow, as a paragraph with hyphenation on does.

import { hyphenate, layoutType } from '@isocialpractice/napkin-sketch';

const text = { id: 't', tool: 'text', color: '#1f2328', width: 1, points: [{ x: 20, y: 40 }], text: 'Acme Corp\nannual report', fontSize: 24 };
const layout = layoutType(text);
console.log(layout.lines.length, layout.glyphs.length, layout.overset);
// → 2 22 false
console.log(hyphenate('composition').join('-'));
// → com-po-si-tion

Fonts as shapes: openSfnt, glyphOutline, outlineText#

A font file read with no dependency - TrueType and CFF outlines, the character map, advances and kerning - and a text drawn as its glyphs' outlines, a mark a glyph, as Expand Text to Shape and a PNG with fonts given draw it. fileOutlineSource turns font files by family into the measure a text is set by and the faces it is drawn from.

import { HEADLESS_MEASURE, fileOutlineSource, glyphOutline, isSfntProblem, layoutType, openSfnt, outlineText } from '@isocialpractice/napkin-sketch';
import { readFile } from 'node:fs/promises';

const bytes = new Uint8Array(await readFile('fonts/acme-sans.ttf'));
const face = openSfnt(bytes);
if (isSfntProblem(face)) throw new Error(face.problem);
const a = glyphOutline(face, face.glyphId('A'.codePointAt(0)));
console.log(face.format, a.contours.length > 0);
// → truetype true
const source = fileOutlineSource({ 'Acme Sans': bytes }, HEADLESS_MEASURE);
const text = { id: 't', tool: 'text', color: '#1f2328', width: 1, points: [{ x: 20, y: 60 }], text: 'AVTo', fontSize: 48, fontFamily: 'Acme Sans' };
const shapes = outlineText(text, layoutType(text, source.measure), source);
console.log(shapes.marks.map((made) => made.label).join(' '));
// → A V T o

Text Configurations: applyTextConfig, resolveRunAttrs#

A book's named character attributes: a run follows one by its id, and what it sets beyond it is its own, an override. newTextConfig makes one, applyTextConfig puts a stretch of a text on it - the text carrying a copy of its definition - and resolveRunAttrs is what a run draws as: its base, then the configuration, then its overrides.

import { applyTextConfig, baseAttrs, newTextConfig, resolveRunAttrs, styleSegments } from '@isocialpractice/napkin-sketch';

const heading = newTextConfig([], { fontSize: 40, fontStyle: 'Bold' }, 'tc_heading', 'Heading');
const text = { id: 't', tool: 'text', color: '#1f2328', width: 1, points: [{ x: 20, y: 60 }], text: 'Acme Corp', fontSize: 24 };
const styled = applyTextConfig(text, { start: 0, end: 4 }, heading);
console.log(styleSegments(styled).map((segment) => `${segment.end}:${segment.attrs.fontSize}`).join(' '));
// → 4:40 9:24
console.log(resolveRunAttrs(baseAttrs(text), heading, { color: '#c0392b' }).fontStyle);
// → Bold

Composition styles on a page: applyCompositionStyle#

A page restyled by its layers' names, as the Composition Styles panel does it, with the report of what did not match. captureCompositionStyle records a page's named layers as a style; the compose reference has the format.

import { applyCompositionStyle, captureCompositionStyle, compositionReportTitle, evaluate } from '@isocialpractice/napkin-sketch';

const page = evaluate('napkin 1\nlayer "Background"\nfill #f4f1ea\nrect 0 0 200 100\nlayer "Title"\ntext "Acme Corp" at 20 50\n').book.sketches[0];
const day = captureCompositionStyle(page, 'Day');
console.log(Object.keys(day.layers), Object.keys(day.text));
// → ["Title","Background"] ["Title"]
const night = { napkinCompositionStyle: 1, name: 'Night', strokes: { Background: { fill: '#000133' } }, text: { Titel: { color: '#ffffff' } } };
const { report } = applyCompositionStyle(page, night);
console.log(compositionReportTitle(report), report.items[0].suggestions);
// → Night: 1 rule did not match ["Title"]

Worked examples#

A gap through every line on a page: the Eraser's cut made with a program, and the page written out as SVG.

import { eraseMarks, eraseRegionOf, evaluate, renderSketch } from '@isocialpractice/napkin-sketch';

const page = evaluate('napkin 1\npage 240 160\nwidth 4\nrepeat 4 as i {\n  line 20 (30 + i * 30) 220 (30 + i * 30)\n}\n').book.sketches[0];
const swath = eraseRegionOf({ id: 'gap', tool: 'eraser', color: '#000000', width: 24, points: [{ x: 120, y: 0 }, { x: 120, y: 160 }] });
const cut = eraseMarks(page, page.strokes.map((s) => s.id), swath);
const strokes = page.strokes.map((mark) => cut.changed.get(mark.id) ?? mark).filter((mark) => !cut.removed.has(mark.id));
const svg = renderSketch({ ...page, strokes }, { format: 'svg' });
console.log(cut.changed.size, svg.startsWith('<?xml'));
// → 4 true

A Liquify drag from a program: a Warp pushed across a circle in ten small dabs, as a pointer moving across it gives them, then fitted again once.

import { evaluate, liquifyMarks } from '@isocialpractice/napkin-sketch';

const page = evaluate('napkin 1\ncircle 100 100 60\n').book.sketches[0];
const dabs = Array.from({ length: 10 }, (_, k) => ({ mode: 'warp', x: 40 + k * 4, y: 100, radius: 40, dx: 4, dy: 0 }));
const [circle] = liquifyMarks(page.strokes, dabs, { refit: 0.5 }).values();
console.log(Math.round(Math.min(...circle.points.map((p) => p.x))), circle.vector.closed);
// → 52 true

The left of the circle was pushed in toward its middle - its leftmost point, once at 40, is at 52 - and the circle is still one closed path.

Limits#

For agents#

See also#