@adecore/drawing 0.0.1 → 0.17.0-beta.2
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.
- package/LICENSE +110 -0
- package/README.md +49 -0
- package/dist/geometry.d.ts +32 -0
- package/dist/geometry.js +233 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/paths.d.ts +12 -0
- package/dist/paths.js +129 -0
- package/dist/protocol.d.ts +1118 -0
- package/dist/protocol.js +102 -0
- package/dist/reading-order.d.ts +2 -0
- package/dist/reading-order.js +75 -0
- package/dist/svg.d.ts +16 -0
- package/dist/svg.js +109 -0
- package/dist/text.d.ts +19 -0
- package/dist/text.js +74 -0
- package/package.json +42 -2
- package/src/geometry.ts +273 -0
- package/src/index.ts +42 -0
- package/src/paths.ts +162 -0
- package/src/protocol.ts +130 -0
- package/src/reading-order.ts +82 -0
- package/src/svg.ts +138 -0
- package/src/text.ts +90 -0
package/src/svg.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { DrawingColor, DrawingElement, DrawingFont } from './protocol.ts';
|
|
2
|
+
import { boundsOfElements, type Rect } from './geometry.ts';
|
|
3
|
+
import { pathsOfElement } from './paths.ts';
|
|
4
|
+
import { DEFAULT_FONT_STACKS, LINE_HEIGHT, approximateMeasure, fontOf, linesOf, writingFrameOf, type MeasureLine, type WrittenElement } from './text.ts';
|
|
5
|
+
|
|
6
|
+
export interface SvgOptions {
|
|
7
|
+
/* What every palette name is in the theme this export is made in. */
|
|
8
|
+
palette: Record<DrawingColor, string>;
|
|
9
|
+
/* The paper behind the drawing, or null for a transparent one. */
|
|
10
|
+
background?: string | null;
|
|
11
|
+
/* World units around the drawing, so nothing touches the edge. */
|
|
12
|
+
margin?: number;
|
|
13
|
+
fonts?: Partial<Record<DrawingFont, string>>;
|
|
14
|
+
/* The paper of a note per palette name; without it the light theme's sheets are used. */
|
|
15
|
+
paper?: Record<DrawingColor, string>;
|
|
16
|
+
/* The edge of that paper, which is the sheet a step deeper into its own color. */
|
|
17
|
+
edge?: Record<DrawingColor, string>;
|
|
18
|
+
/* How wide a line is in the given text, for wrapping a sized text; without it a glyph is estimated. */
|
|
19
|
+
measure?: (element: WrittenElement) => MeasureLine;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_SVG_MARGIN = 32;
|
|
23
|
+
|
|
24
|
+
// Fallback colors for rendering without a host theme.
|
|
25
|
+
export const DEFAULT_PALETTE: Record<DrawingColor, string> = {
|
|
26
|
+
ink: '#18181b',
|
|
27
|
+
muted: '#6f6f78',
|
|
28
|
+
accent: '#4f46e5',
|
|
29
|
+
red: '#d64545',
|
|
30
|
+
orange: '#d97706',
|
|
31
|
+
yellow: '#b7860b',
|
|
32
|
+
green: '#2f8a4f',
|
|
33
|
+
blue: '#2563eb',
|
|
34
|
+
purple: '#7c3aed',
|
|
35
|
+
pink: '#db2777'
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Pale note fills for rendering without a host theme.
|
|
39
|
+
export const DEFAULT_PAPER: Record<DrawingColor, string> = {
|
|
40
|
+
ink: '#f4f4f6',
|
|
41
|
+
muted: '#ececef',
|
|
42
|
+
accent: '#e2e5fd',
|
|
43
|
+
red: '#fddfdf',
|
|
44
|
+
orange: '#fde9d0',
|
|
45
|
+
yellow: '#fff3bf',
|
|
46
|
+
green: '#dcf5e3',
|
|
47
|
+
blue: '#dbe9ff',
|
|
48
|
+
purple: '#eadffb',
|
|
49
|
+
pink: '#fde2ea'
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/* The edge of that paper outside the app: every sheet above, a step deeper into its own color. */
|
|
53
|
+
export const DEFAULT_EDGE: Record<DrawingColor, string> = {
|
|
54
|
+
ink: '#dedee2',
|
|
55
|
+
muted: '#d6d6da',
|
|
56
|
+
accent: '#c3cbf7',
|
|
57
|
+
red: '#f4bebe',
|
|
58
|
+
orange: '#f3d3a8',
|
|
59
|
+
yellow: '#f0dc94',
|
|
60
|
+
green: '#b9e3c6',
|
|
61
|
+
blue: '#b6cff2',
|
|
62
|
+
purple: '#d5bef0',
|
|
63
|
+
pink: '#f3c0d1'
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// XML has no way to write a control character other than tab and the line breaks, not even escaped.
|
|
67
|
+
// oxlint-disable-next-line no-control-regex
|
|
68
|
+
const NOT_XML = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g;
|
|
69
|
+
|
|
70
|
+
function escapeXml(value: string): string {
|
|
71
|
+
return value.replace(NOT_XML, '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function round(value: number): number {
|
|
75
|
+
return Math.round(value * 100) / 100;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function transformOf(element: DrawingElement): string {
|
|
79
|
+
// After the translate the paths are in the element's own frame, so its center is half its size.
|
|
80
|
+
const turn = element.angle ? ` rotate(${round((element.angle * 180) / Math.PI)} ${round(element.w / 2)} ${round(element.h / 2)})` : '';
|
|
81
|
+
return `translate(${round(element.x)} ${round(element.y)})${turn}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function textSvg(element: WrittenElement, options: SvgOptions): string {
|
|
85
|
+
const family = options.fonts?.[fontOf(element.font)] ?? DEFAULT_FONT_STACKS[fontOf(element.font)];
|
|
86
|
+
const anchor = element.align === 'center' ? 'middle' : element.align === 'right' ? 'end' : 'start';
|
|
87
|
+
const frame = writingFrameOf(element);
|
|
88
|
+
const dx = frame.x + (element.align === 'center' ? frame.w / 2 : element.align === 'right' ? frame.w : 0);
|
|
89
|
+
const measure = options.measure?.(element) ?? approximateMeasure(element.size, element.font);
|
|
90
|
+
const lines = linesOf(element, measure)
|
|
91
|
+
.map((line, index) => `<tspan x="${round(dx)}" y="${round(frame.y + (index + 0.8) * element.size * LINE_HEIGHT)}">${escapeXml(line)}</tspan>`)
|
|
92
|
+
.join('');
|
|
93
|
+
return `<text font-family="${escapeXml(family)}" font-size="${element.size}" fill="${options.palette[element.stroke]}" text-anchor="${anchor}">${lines}</text>`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function elementSvg(element: DrawingElement, options: SvgOptions): string {
|
|
97
|
+
const note = element.kind === 'note';
|
|
98
|
+
const paper = options.paper ?? DEFAULT_PAPER;
|
|
99
|
+
const edge = options.edge ?? DEFAULT_EDGE;
|
|
100
|
+
const body =
|
|
101
|
+
element.kind === 'text'
|
|
102
|
+
? textSvg(element, options)
|
|
103
|
+
: pathsOfElement(element)
|
|
104
|
+
.map((path) => {
|
|
105
|
+
const dash = path.dash ? ` stroke-dasharray="${path.dash.join(' ')}"` : '';
|
|
106
|
+
if (path.role === 'stroke') {
|
|
107
|
+
const line = note ? edge[element.fillColor ?? element.stroke] : options.palette[element.stroke];
|
|
108
|
+
return `<path d="${path.d}" fill="none" stroke="${line}" stroke-width="${path.strokeWidth}" stroke-linecap="round"${dash}/>`;
|
|
109
|
+
}
|
|
110
|
+
// A note is filled with its paper, which is a palette of pale sheets of its own.
|
|
111
|
+
const fill = note ? paper : options.palette;
|
|
112
|
+
const color = path.role === 'ink' ? options.palette[element.stroke] : fill[element.fillColor ?? element.stroke];
|
|
113
|
+
// A hachure fill is a bundle of lines, so it arrives as a stroke with its own width.
|
|
114
|
+
return path.strokeWidth > 0 && path.role === 'fill' && element.fill === 'hachure'
|
|
115
|
+
? `<path d="${path.d}" fill="none" stroke="${color}" stroke-width="${path.strokeWidth}"/>`
|
|
116
|
+
: `<path d="${path.d}" fill="${color}" stroke="none"/>`;
|
|
117
|
+
})
|
|
118
|
+
.join('');
|
|
119
|
+
// The paper is drawn first and the note's own words go on top of it.
|
|
120
|
+
const written = note ? textSvg(element, options) : '';
|
|
121
|
+
return `<g transform="${transformOf(element)}">${body}${written}</g>`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/*
|
|
125
|
+
* The whole drawing as one SVG, in the colors of the theme it is exported from. Fonts are named,
|
|
126
|
+
* never embedded: a file that opens outside the app falls back to a system face.
|
|
127
|
+
*/
|
|
128
|
+
export function toSvg(elements: readonly DrawingElement[], options: SvgOptions): string {
|
|
129
|
+
const margin = options.margin ?? DEFAULT_SVG_MARGIN;
|
|
130
|
+
const bounds: Rect = boundsOfElements(elements) ?? { x: 0, y: 0, w: 1, h: 1 };
|
|
131
|
+
const x = round(bounds.x - margin);
|
|
132
|
+
const y = round(bounds.y - margin);
|
|
133
|
+
const w = round(bounds.w + margin * 2);
|
|
134
|
+
const h = round(bounds.h + margin * 2);
|
|
135
|
+
const paper = options.background ? `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${options.background}"/>` : '';
|
|
136
|
+
const body = elements.map((element) => elementSvg(element, options)).join('');
|
|
137
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="${x} ${y} ${w} ${h}">${paper}${body}</svg>`;
|
|
138
|
+
}
|
package/src/text.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { DrawingElement, DrawingFont } from './protocol.ts';
|
|
2
|
+
|
|
3
|
+
/* A drawing sets its lines the way the app does: a little air, no leading of its own. */
|
|
4
|
+
export const LINE_HEIGHT = 1.25;
|
|
5
|
+
|
|
6
|
+
/* What each font name means outside the app, where the interface tokens do not exist. */
|
|
7
|
+
export const DEFAULT_FONT_STACKS: Record<DrawingFont, string> = {
|
|
8
|
+
hand: 'Kalam, "Comic Sans MS", cursive',
|
|
9
|
+
sans: 'system-ui, -apple-system, "Segoe UI", sans-serif',
|
|
10
|
+
mono: 'ui-monospace, SFMono-Regular, Menlo, monospace'
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type WrittenElement = DrawingElement & { kind: 'text' | 'note' };
|
|
14
|
+
|
|
15
|
+
/* How much paper a note keeps free around its text, on every side. */
|
|
16
|
+
export const NOTE_PADDING = 16;
|
|
17
|
+
|
|
18
|
+
/* The corners of a sheet of paper, which are never quite square. */
|
|
19
|
+
export const NOTE_RADIUS = 8;
|
|
20
|
+
|
|
21
|
+
/*
|
|
22
|
+
* Where the glyphs of an element sit inside it: a note writes within its padding, a text is its
|
|
23
|
+
* own box. Everything that lays out or measures a text goes through this, so both stay in step.
|
|
24
|
+
*/
|
|
25
|
+
export function writingFrameOf(element: WrittenElement): { x: number; y: number; w: number } {
|
|
26
|
+
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 };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function textLines(text: string): string[] {
|
|
30
|
+
return text.split('\n');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type MeasureLine = (line: string) => number;
|
|
34
|
+
|
|
35
|
+
/*
|
|
36
|
+
* A measure for where no font exists, such as the daemon writing an SVG for an agent: a glyph is
|
|
37
|
+
* a little over half its size wide, which is what most faces come to on average.
|
|
38
|
+
*/
|
|
39
|
+
export function approximateMeasure(size: number, font: DrawingFont | undefined): MeasureLine {
|
|
40
|
+
const glyph = fontOf(font) === 'mono' ? 0.6 : 0.55;
|
|
41
|
+
return (line) => line.length * size * glyph;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function wrapParagraph(paragraph: string, maxWidth: number, measure: MeasureLine): string[] {
|
|
45
|
+
const lines: string[] = [];
|
|
46
|
+
let line = '';
|
|
47
|
+
const push = (): void => {
|
|
48
|
+
lines.push(line);
|
|
49
|
+
line = '';
|
|
50
|
+
};
|
|
51
|
+
for (const word of paragraph.split(' ')) {
|
|
52
|
+
const candidate = line === '' ? word : `${line} ${word}`;
|
|
53
|
+
if (measure(candidate) <= maxWidth) {
|
|
54
|
+
line = candidate;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (line !== '') {
|
|
58
|
+
push();
|
|
59
|
+
}
|
|
60
|
+
if (measure(word) <= maxWidth) {
|
|
61
|
+
line = word;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
for (const glyph of word) {
|
|
65
|
+
if (line !== '' && measure(line + glyph) > maxWidth) {
|
|
66
|
+
push();
|
|
67
|
+
}
|
|
68
|
+
line += glyph;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
lines.push(line);
|
|
72
|
+
return lines;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function wrapLines(text: string, maxWidth: number, measure: MeasureLine): string[] {
|
|
76
|
+
return textLines(text).flatMap((paragraph) => wrapParagraph(paragraph, maxWidth, measure));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// An unsized text element follows its content; once sized, its box controls wrapping.
|
|
80
|
+
export function linesOf(element: WrittenElement, measure: MeasureLine): string[] {
|
|
81
|
+
if (element.kind === 'note') {
|
|
82
|
+
return wrapLines(element.text, writingFrameOf(element).w, measure);
|
|
83
|
+
}
|
|
84
|
+
return element.sized ? wrapLines(element.text, element.w, measure) : textLines(element.text);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* Absent means hand: a drawing is written by hand unless it says otherwise. */
|
|
88
|
+
export function fontOf(font: DrawingFont | undefined): DrawingFont {
|
|
89
|
+
return font ?? 'hand';
|
|
90
|
+
}
|