@adecore/drawing 0.0.1 → 0.17.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,102 @@
1
+ import { z } from 'zod';
2
+ export const DrawingFontSchema = z.enum(['hand', 'sans', 'mono']);
3
+ /*
4
+ * Palette names keep a document independent of the theme that renders it.
5
+ */
6
+ export const DrawingColorSchema = z.enum(['ink', 'muted', 'accent', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink']);
7
+ /* In the order the color menu shows them. */
8
+ export const DRAWING_COLORS = DrawingColorSchema.options;
9
+ export const DrawingFillSchema = z.enum(['none', 'solid', 'hachure']);
10
+ export const DrawingStrokeStyleSchema = z.enum(['solid', 'dashed', 'dotted']);
11
+ export const DrawingStrokeWidthSchema = z.union([z.literal(1), z.literal(2), z.literal(4)]);
12
+ /* Architect, Artist, Cartoonist. Absent reads as 1, so a file written by hand looks hand drawn. */
13
+ export const DrawingRoughnessSchema = z.union([z.literal(0), z.literal(1), z.literal(2)]);
14
+ export const DrawingAlignSchema = z.enum(['left', 'center', 'right']);
15
+ export const DRAWING_TEXT_SIZE_MIN = 12;
16
+ export const DRAWING_TEXT_SIZE_MAX = 96;
17
+ const ElementBaseSchema = z.object({
18
+ id: z.string().min(1),
19
+ x: z.number(),
20
+ y: z.number(),
21
+ w: z.number(),
22
+ h: z.number(),
23
+ // Radians around the center; absent means 0.
24
+ angle: z.number().optional(),
25
+ stroke: DrawingColorSchema,
26
+ strokeWidth: DrawingStrokeWidthSchema,
27
+ strokeStyle: DrawingStrokeStyleSchema.optional(),
28
+ fill: DrawingFillSchema.optional(),
29
+ fillColor: DrawingColorSchema.optional(),
30
+ roughness: DrawingRoughnessSchema.optional(),
31
+ // Keeps the wobble the same on every render, on every machine.
32
+ seed: z.number().int().nonnegative(),
33
+ locked: z.boolean().optional()
34
+ });
35
+ export const DrawingElementSchema = z.discriminatedUnion('kind', [
36
+ ElementBaseSchema.extend({ kind: z.literal('rect'), radius: z.number().nonnegative().optional() }),
37
+ ElementBaseSchema.extend({ kind: z.literal('diamond') }),
38
+ ElementBaseSchema.extend({ kind: z.literal('ellipse') }),
39
+ // Points are relative to (x, y) and their bounds are the box. An arrow is a line with a head.
40
+ ElementBaseSchema.extend({
41
+ kind: z.literal('line'),
42
+ points: z.array(z.tuple([z.number(), z.number()])).min(2),
43
+ arrowStart: z.boolean().optional(),
44
+ arrowEnd: z.boolean().optional()
45
+ }),
46
+ // A stroke: relative points with an optional pressure, drawn through perfect-freehand.
47
+ ElementBaseSchema.extend({
48
+ kind: z.literal('freehand'),
49
+ points: z.array(z.tuple([z.number(), z.number(), z.number().optional()])).min(1)
50
+ }),
51
+ ElementBaseSchema.extend({
52
+ kind: z.literal('text'),
53
+ text: z.string(),
54
+ size: z.number().int().min(DRAWING_TEXT_SIZE_MIN).max(DRAWING_TEXT_SIZE_MAX),
55
+ // Absent means 'hand', the default a drawing is written in.
56
+ font: DrawingFontSchema.optional(),
57
+ align: DrawingAlignSchema.optional(),
58
+ // Set once the box was dragged by hand; absent means the box follows the glyphs.
59
+ sized: z.boolean().optional()
60
+ }),
61
+ /*
62
+ * A sticky note: a sheet of paper in `fillColor` with the text written on it. It is one element
63
+ * rather than a shape with a text on top, so moving the note takes what it says along.
64
+ */
65
+ ElementBaseSchema.extend({
66
+ kind: z.literal('note'),
67
+ text: z.string(),
68
+ size: z.number().int().min(DRAWING_TEXT_SIZE_MIN).max(DRAWING_TEXT_SIZE_MAX),
69
+ font: DrawingFontSchema.optional(),
70
+ align: DrawingAlignSchema.optional()
71
+ })
72
+ ]);
73
+ export const DRAWING_VERSION = 1;
74
+ export const DrawingContentSchema = z.object({
75
+ // In stacking order, back to front.
76
+ elements: z.array(DrawingElementSchema)
77
+ });
78
+ export const DrawingDocumentSchema = DrawingContentSchema.extend({
79
+ version: z.literal(DRAWING_VERSION),
80
+ // Goes up by one on every write; a save that names an older rev is a conflict.
81
+ rev: z.number().int().nonnegative()
82
+ });
83
+ export const EMPTY_DRAWING = { version: 1, rev: 0, elements: [] };
84
+ /* One version so far, so reading is a parse. A file that is not a drawing at all reads as null. */
85
+ export function migrateDrawing(value) {
86
+ const parsed = DrawingDocumentSchema.safeParse(value);
87
+ return parsed.success ? parsed.data : null;
88
+ }
89
+ /*
90
+ * Element ids never leave their own file, so they only have to be unique inside it. A repeat would
91
+ * make selection and hit tests pick two elements at once, so such a file is refused by name.
92
+ */
93
+ export function duplicateElementIdIn(elements) {
94
+ const seen = new Set();
95
+ for (const element of elements) {
96
+ if (seen.has(element.id)) {
97
+ return element.id;
98
+ }
99
+ seen.add(element.id);
100
+ }
101
+ return null;
102
+ }
@@ -0,0 +1,2 @@
1
+ import type { DrawingElement } from './protocol.ts';
2
+ export declare function readingOrder(elements: readonly DrawingElement[]): string[];
@@ -0,0 +1,75 @@
1
+ import { absolutePoints, boundsOf, centerOf, hitsElement } from './geometry.js';
2
+ /* Two texts within this many units of each other are on the same line and read left to right. */
3
+ const ROW_TOLERANCE = 24;
4
+ /* How far from an arrow's end a label may sit and still be what the arrow points at. */
5
+ const LABEL_REACH = 48;
6
+ function labelOf(element) {
7
+ return element.kind === 'text' || element.kind === 'note' ? element.text.trim() || null : null;
8
+ }
9
+ /* A shape says what it is by the text inside it, which is how a box on a diagram is named. */
10
+ function labelInside(element, elements) {
11
+ for (const candidate of elements) {
12
+ if (candidate.kind !== 'text' || candidate.id === element.id) {
13
+ continue;
14
+ }
15
+ const center = centerOf(boundsOf(candidate));
16
+ if (hitsElement({ ...element, fill: 'solid' }, center, 0)) {
17
+ return labelOf(candidate);
18
+ }
19
+ }
20
+ return null;
21
+ }
22
+ /* What an arrow points at: the text at that end, or the shape there and the text it holds. */
23
+ function labelAt(point, elements, arrowId) {
24
+ let best = null;
25
+ for (const element of elements) {
26
+ if (element.id === arrowId || element.kind === 'line' || element.kind === 'freehand') {
27
+ continue;
28
+ }
29
+ const center = centerOf(boundsOf(element));
30
+ const distance = Math.hypot(center.x - point.x, center.y - point.y);
31
+ const inside = hitsElement({ ...element, fill: 'solid' }, point, LABEL_REACH);
32
+ if (!inside && distance > LABEL_REACH) {
33
+ continue;
34
+ }
35
+ const label = labelOf(element) ?? labelInside(element, elements);
36
+ if (label && (!best || distance < best.distance)) {
37
+ best = { label, distance };
38
+ }
39
+ }
40
+ return best?.label ?? null;
41
+ }
42
+ /*
43
+ * A drawing as lines an agent can read: the texts top to bottom and left to right, then every
44
+ * arrow as the two things it connects. A diagram becomes a list without anyone looking at it.
45
+ */
46
+ export function readingOrder(elements) {
47
+ // A row is measured from its top text, so a staircase of texts cannot chain into one long line.
48
+ const rows = [];
49
+ const labeled = elements.filter((element) => labelOf(element) !== null).sort((left, right) => left.y - right.y || left.x - right.x);
50
+ for (const element of labeled) {
51
+ const row = rows.at(-1);
52
+ if (row && element.y - row[0].y <= ROW_TOLERANCE) {
53
+ row.push(element);
54
+ }
55
+ else {
56
+ rows.push([element]);
57
+ }
58
+ }
59
+ const texts = rows.flatMap((row) => row.sort((left, right) => left.x - right.x || left.y - right.y)).map((element) => labelOf(element));
60
+ const arrows = [];
61
+ for (const element of elements) {
62
+ if (element.kind !== 'line' || (!element.arrowEnd && !element.arrowStart)) {
63
+ continue;
64
+ }
65
+ const points = absolutePoints(element);
66
+ const from = labelAt(points[0], elements, element.id);
67
+ const to = labelAt(points.at(-1), elements, element.id);
68
+ if (!from || !to || from === to) {
69
+ continue;
70
+ }
71
+ // An arrow that only has a head at its start reads the other way around.
72
+ arrows.push(element.arrowEnd ? `${from} -> ${to}` : `${to} -> ${from}`);
73
+ }
74
+ return [...texts, ...arrows];
75
+ }
package/dist/svg.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ import type { DrawingColor, DrawingElement, DrawingFont } from './protocol.ts';
2
+ import { type MeasureLine, type WrittenElement } from './text.ts';
3
+ export interface SvgOptions {
4
+ palette: Record<DrawingColor, string>;
5
+ background?: string | null;
6
+ margin?: number;
7
+ fonts?: Partial<Record<DrawingFont, string>>;
8
+ paper?: Record<DrawingColor, string>;
9
+ edge?: Record<DrawingColor, string>;
10
+ measure?: (element: WrittenElement) => MeasureLine;
11
+ }
12
+ export declare const DEFAULT_SVG_MARGIN = 32;
13
+ export declare const DEFAULT_PALETTE: Record<DrawingColor, string>;
14
+ export declare const DEFAULT_PAPER: Record<DrawingColor, string>;
15
+ export declare const DEFAULT_EDGE: Record<DrawingColor, string>;
16
+ export declare function toSvg(elements: readonly DrawingElement[], options: SvgOptions): string;
package/dist/svg.js ADDED
@@ -0,0 +1,109 @@
1
+ import { boundsOfElements, centerOf, boundsOf } from './geometry.js';
2
+ import { pathsOfElement } from './paths.js';
3
+ import { DEFAULT_FONT_STACKS, LINE_HEIGHT, approximateMeasure, fontOf, linesOf, writingFrameOf } from './text.js';
4
+ export const DEFAULT_SVG_MARGIN = 32;
5
+ // Fallback colors for rendering without a host theme.
6
+ export const DEFAULT_PALETTE = {
7
+ ink: '#18181b',
8
+ muted: '#6f6f78',
9
+ accent: '#4f46e5',
10
+ red: '#d64545',
11
+ orange: '#d97706',
12
+ yellow: '#b7860b',
13
+ green: '#2f8a4f',
14
+ blue: '#2563eb',
15
+ purple: '#7c3aed',
16
+ pink: '#db2777'
17
+ };
18
+ // Pale note fills for rendering without a host theme.
19
+ export const DEFAULT_PAPER = {
20
+ ink: '#f4f4f6',
21
+ muted: '#ececef',
22
+ accent: '#e2e5fd',
23
+ red: '#fddfdf',
24
+ orange: '#fde9d0',
25
+ yellow: '#fff3bf',
26
+ green: '#dcf5e3',
27
+ blue: '#dbe9ff',
28
+ purple: '#eadffb',
29
+ pink: '#fde2ea'
30
+ };
31
+ /* The edge of that paper outside the app: every sheet above, a step deeper into its own color. */
32
+ export const DEFAULT_EDGE = {
33
+ ink: '#dedee2',
34
+ muted: '#d6d6da',
35
+ accent: '#c3cbf7',
36
+ red: '#f4bebe',
37
+ orange: '#f3d3a8',
38
+ yellow: '#f0dc94',
39
+ green: '#b9e3c6',
40
+ blue: '#b6cff2',
41
+ purple: '#d5bef0',
42
+ pink: '#f3c0d1'
43
+ };
44
+ // XML has no way to write a control character other than tab and the line breaks, not even escaped.
45
+ // oxlint-disable-next-line no-control-regex
46
+ const NOT_XML = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g;
47
+ function escapeXml(value) {
48
+ return value.replace(NOT_XML, '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
49
+ }
50
+ function round(value) {
51
+ return Math.round(value * 100) / 100;
52
+ }
53
+ function transformOf(element) {
54
+ const center = centerOf(boundsOf(element));
55
+ const turn = element.angle ? ` rotate(${round((element.angle * 180) / Math.PI)} ${round(center.x)} ${round(center.y)})` : '';
56
+ return `translate(${round(element.x)} ${round(element.y)})${turn}`;
57
+ }
58
+ function textSvg(element, options) {
59
+ const family = options.fonts?.[fontOf(element.font)] ?? DEFAULT_FONT_STACKS[fontOf(element.font)];
60
+ const anchor = element.align === 'center' ? 'middle' : element.align === 'right' ? 'end' : 'start';
61
+ const frame = writingFrameOf(element);
62
+ const dx = frame.x + (element.align === 'center' ? frame.w / 2 : element.align === 'right' ? frame.w : 0);
63
+ const measure = options.measure?.(element) ?? approximateMeasure(element.size, element.font);
64
+ const lines = linesOf(element, measure)
65
+ .map((line, index) => `<tspan x="${round(dx)}" y="${round(frame.y + (index + 0.8) * element.size * LINE_HEIGHT)}">${escapeXml(line)}</tspan>`)
66
+ .join('');
67
+ return `<text font-family="${escapeXml(family)}" font-size="${element.size}" fill="${options.palette[element.stroke]}" text-anchor="${anchor}">${lines}</text>`;
68
+ }
69
+ function elementSvg(element, options) {
70
+ const note = element.kind === 'note';
71
+ const paper = options.paper ?? DEFAULT_PAPER;
72
+ const edge = options.edge ?? DEFAULT_EDGE;
73
+ const body = element.kind === 'text'
74
+ ? textSvg(element, options)
75
+ : pathsOfElement(element)
76
+ .map((path) => {
77
+ const dash = path.dash ? ` stroke-dasharray="${path.dash.join(' ')}"` : '';
78
+ if (path.role === 'stroke') {
79
+ const line = note ? edge[element.fillColor ?? element.stroke] : options.palette[element.stroke];
80
+ return `<path d="${path.d}" fill="none" stroke="${line}" stroke-width="${path.strokeWidth}" stroke-linecap="round"${dash}/>`;
81
+ }
82
+ // A note is filled with its paper, which is a palette of pale sheets of its own.
83
+ const fill = note ? paper : options.palette;
84
+ const color = path.role === 'ink' ? options.palette[element.stroke] : fill[element.fillColor ?? element.stroke];
85
+ // A hachure fill is a bundle of lines, so it arrives as a stroke with its own width.
86
+ return path.strokeWidth > 0 && path.role === 'fill' && element.fill === 'hachure'
87
+ ? `<path d="${path.d}" fill="none" stroke="${color}" stroke-width="${path.strokeWidth}"/>`
88
+ : `<path d="${path.d}" fill="${color}" stroke="none"/>`;
89
+ })
90
+ .join('');
91
+ // The paper is drawn first and the note's own words go on top of it.
92
+ const written = note ? textSvg(element, options) : '';
93
+ return `<g transform="${transformOf(element)}">${body}${written}</g>`;
94
+ }
95
+ /*
96
+ * The whole drawing as one SVG, in the colors of the theme it is exported from. Fonts are named,
97
+ * never embedded: a file that opens outside the app falls back to a system face.
98
+ */
99
+ export function toSvg(elements, options) {
100
+ const margin = options.margin ?? DEFAULT_SVG_MARGIN;
101
+ const bounds = boundsOfElements(elements) ?? { x: 0, y: 0, w: 1, h: 1 };
102
+ const x = round(bounds.x - margin);
103
+ const y = round(bounds.y - margin);
104
+ const w = round(bounds.w + margin * 2);
105
+ const h = round(bounds.h + margin * 2);
106
+ const paper = options.background ? `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${options.background}"/>` : '';
107
+ const body = elements.map((element) => elementSvg(element, options)).join('');
108
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="${x} ${y} ${w} ${h}">${paper}${body}</svg>`;
109
+ }
package/dist/text.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { DrawingElement, DrawingFont } from './protocol.ts';
2
+ export declare const LINE_HEIGHT = 1.25;
3
+ export declare const DEFAULT_FONT_STACKS: Record<DrawingFont, string>;
4
+ export type WrittenElement = DrawingElement & {
5
+ kind: 'text' | 'note';
6
+ };
7
+ export declare const NOTE_PADDING = 16;
8
+ export declare const NOTE_RADIUS = 8;
9
+ export declare function writingFrameOf(element: WrittenElement): {
10
+ x: number;
11
+ y: number;
12
+ w: number;
13
+ };
14
+ export declare function textLines(text: string): string[];
15
+ export type MeasureLine = (line: string) => number;
16
+ export declare function approximateMeasure(size: number, font: DrawingFont | undefined): MeasureLine;
17
+ export declare function wrapLines(text: string, maxWidth: number, measure: MeasureLine): string[];
18
+ export declare function linesOf(element: WrittenElement, measure: MeasureLine): string[];
19
+ export declare function fontOf(font: DrawingFont | undefined): DrawingFont;
package/dist/text.js ADDED
@@ -0,0 +1,74 @@
1
+ /* A drawing sets its lines the way the app does: a little air, no leading of its own. */
2
+ export const LINE_HEIGHT = 1.25;
3
+ /* What each font name means outside the app, where the interface tokens do not exist. */
4
+ export const DEFAULT_FONT_STACKS = {
5
+ hand: 'Kalam, "Comic Sans MS", cursive',
6
+ sans: 'system-ui, -apple-system, "Segoe UI", sans-serif',
7
+ mono: 'ui-monospace, SFMono-Regular, Menlo, monospace'
8
+ };
9
+ /* How much paper a note keeps free around its text, on every side. */
10
+ export const NOTE_PADDING = 16;
11
+ /* The corners of a sheet of paper, which are never quite square. */
12
+ export const NOTE_RADIUS = 8;
13
+ /*
14
+ * Where the glyphs of an element sit inside it: a note writes within its padding, a text is its
15
+ * own box. Everything that lays out or measures a text goes through this, so both stay in step.
16
+ */
17
+ export function writingFrameOf(element) {
18
+ return element.kind === 'note' ? { x: NOTE_PADDING, y: NOTE_PADDING, w: Math.max(1, element.w - NOTE_PADDING * 2) } : { x: 0, y: 0, w: element.w };
19
+ }
20
+ export function textLines(text) {
21
+ return text.split('\n');
22
+ }
23
+ /*
24
+ * A measure for where no font exists, such as the daemon writing an SVG for an agent: a glyph is
25
+ * a little over half its size wide, which is what most faces come to on average.
26
+ */
27
+ export function approximateMeasure(size, font) {
28
+ const glyph = fontOf(font) === 'mono' ? 0.6 : 0.55;
29
+ return (line) => line.length * size * glyph;
30
+ }
31
+ function wrapParagraph(paragraph, maxWidth, measure) {
32
+ const lines = [];
33
+ let line = '';
34
+ const push = () => {
35
+ lines.push(line);
36
+ line = '';
37
+ };
38
+ for (const word of paragraph.split(' ')) {
39
+ const candidate = line === '' ? word : `${line} ${word}`;
40
+ if (measure(candidate) <= maxWidth) {
41
+ line = candidate;
42
+ continue;
43
+ }
44
+ if (line !== '') {
45
+ push();
46
+ }
47
+ if (measure(word) <= maxWidth) {
48
+ line = word;
49
+ continue;
50
+ }
51
+ for (const glyph of word) {
52
+ if (line !== '' && measure(line + glyph) > maxWidth) {
53
+ push();
54
+ }
55
+ line += glyph;
56
+ }
57
+ }
58
+ lines.push(line);
59
+ return lines;
60
+ }
61
+ export function wrapLines(text, maxWidth, measure) {
62
+ return textLines(text).flatMap((paragraph) => wrapParagraph(paragraph, maxWidth, measure));
63
+ }
64
+ // An unsized text element follows its content; once sized, its box controls wrapping.
65
+ export function linesOf(element, measure) {
66
+ if (element.kind === 'note') {
67
+ return wrapLines(element.text, writingFrameOf(element).w, measure);
68
+ }
69
+ return element.sized ? wrapLines(element.text, element.w, measure) : textLines(element.text);
70
+ }
71
+ /* Absent means hand: a drawing is written by hand unless it says otherwise. */
72
+ export function fontOf(font) {
73
+ return font ?? 'hand';
74
+ }
package/package.json CHANGED
@@ -1,11 +1,51 @@
1
1
  {
2
2
  "name": "@adecore/drawing",
3
- "version": "0.0.1",
4
- "description": "Placeholder for the first release.",
3
+ "version": "0.17.0-beta.1",
4
+ "description": "Drawing schemas, geometry, deterministic paths, SVG export and reading order.",
5
5
  "license": "FSL-1.1-MIT",
6
+ "author": "Bas Milius",
6
7
  "repository": {
7
8
  "type": "git",
8
9
  "url": "git+https://github.com/basmilius/adecore.git",
9
10
  "directory": "packages/drawing"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/basmilius/adecore/issues"
14
+ },
15
+ "homepage": "https://adecore.dev/drawing/",
16
+ "type": "module",
17
+ "sideEffects": false,
18
+ "files": [
19
+ "dist",
20
+ "src",
21
+ "!src/**/*.test.ts",
22
+ "!src/fixtures"
23
+ ],
24
+ "exports": {
25
+ ".": {
26
+ "source": "./src/index.ts",
27
+ "types": "./dist/index.d.ts",
28
+ "default": "./dist/index.js"
29
+ },
30
+ "./protocol": {
31
+ "source": "./src/protocol.ts",
32
+ "types": "./dist/protocol.d.ts",
33
+ "default": "./dist/protocol.js"
34
+ }
35
+ },
36
+ "scripts": {
37
+ "build": "bun scripts/build.ts",
38
+ "typecheck": "tsc --noEmit",
39
+ "test": "bun test --conditions=source"
40
+ },
41
+ "devDependencies": {
42
+ "@types/bun": "^1.4.2",
43
+ "@types/node": "^24.13.6",
44
+ "typescript": "^7.0.2"
45
+ },
46
+ "dependencies": {
47
+ "zod": "^4.6.5",
48
+ "perfect-freehand": "^1.2.3",
49
+ "roughjs": "^4.6.6"
10
50
  }
11
51
  }