@stocksharp/diagram 0.4.0 → 0.5.0
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/README.md +42 -0
- package/dist/esm/canvas-renderer.js +91 -16
- package/dist/esm/canvas-renderer.js.map +1 -1
- package/dist/esm/diagram/context-menu.js +272 -0
- package/dist/esm/diagram/context-menu.js.map +1 -0
- package/dist/esm/diagram/stocksharp-diagram.js +83 -5
- package/dist/esm/diagram/stocksharp-diagram.js.map +1 -1
- package/dist/esm/draw-surface.js +2 -0
- package/dist/esm/draw-surface.js.map +1 -0
- package/dist/esm/i18n.js.map +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/svg-surface.js +341 -0
- package/dist/esm/svg-surface.js.map +1 -0
- package/dist/ssdiagram.js +879 -164
- package/dist/ssdiagram.js.map +4 -4
- package/dist/types/canvas-renderer.d.ts +12 -0
- package/dist/types/canvas-renderer.d.ts.map +1 -1
- package/dist/types/diagram/api.d.ts +36 -2
- package/dist/types/diagram/api.d.ts.map +1 -1
- package/dist/types/diagram/context-menu.d.ts +31 -0
- package/dist/types/diagram/context-menu.d.ts.map +1 -0
- package/dist/types/diagram/stocksharp-diagram.d.ts +11 -5
- package/dist/types/diagram/stocksharp-diagram.d.ts.map +1 -1
- package/dist/types/draw-surface.d.ts +52 -0
- package/dist/types/draw-surface.d.ts.map +1 -0
- package/dist/types/i18n.d.ts +4 -0
- package/dist/types/i18n.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/svg-surface.d.ts +74 -0
- package/dist/types/svg-surface.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/canvas-renderer.ts +103 -16
- package/src/diagram/api.ts +46 -2
- package/src/diagram/context-menu.ts +298 -0
- package/src/diagram/stocksharp-diagram.ts +93 -6
- package/src/draw-surface.ts +61 -0
- package/src/i18n.ts +7 -0
- package/src/index.ts +8 -0
- package/src/svg-surface.ts +430 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import type { DrawSurface } from './draw-surface.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A DrawSurface that writes SVG instead of pixels.
|
|
5
|
+
*
|
|
6
|
+
* The renderer is not aware of it: it issues the same calls it issues to a 2D context, and they are
|
|
7
|
+
* recorded as elements rather than painted. That is the whole point -- one renderer, so the vector
|
|
8
|
+
* output cannot drift from what the screen shows.
|
|
9
|
+
*
|
|
10
|
+
* Canvas semantics are followed rather than approximated, because the difference is visible:
|
|
11
|
+
* - a transform is baked into each element as a matrix, so nesting never has to be reconstructed;
|
|
12
|
+
* - arcTo is resolved the way canvas resolves it (tangent point, then a real arc), which is what
|
|
13
|
+
* draws every rounded node corner;
|
|
14
|
+
* - arc emits the leading straight segment canvas draws when a subpath is already open, and
|
|
15
|
+
* splits a full circle in two, since a single SVG arc cannot close on itself;
|
|
16
|
+
* - text metrics come from a real 2D context, so glyphs land where the on-screen layout put them.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
type Matrix = [number, number, number, number, number, number]; // a b c d e f
|
|
20
|
+
|
|
21
|
+
const IDENTITY: Matrix = [1, 0, 0, 1, 0, 0];
|
|
22
|
+
|
|
23
|
+
function multiply(m: Matrix, n: Matrix): Matrix {
|
|
24
|
+
return [
|
|
25
|
+
m[0] * n[0] + m[2] * n[1],
|
|
26
|
+
m[1] * n[0] + m[3] * n[1],
|
|
27
|
+
m[0] * n[2] + m[2] * n[3],
|
|
28
|
+
m[1] * n[2] + m[3] * n[3],
|
|
29
|
+
m[0] * n[4] + m[2] * n[5] + m[4],
|
|
30
|
+
m[1] * n[4] + m[3] * n[5] + m[5],
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isIdentity(m: Matrix): boolean {
|
|
35
|
+
return m[0] === 1 && m[1] === 0 && m[2] === 0 && m[3] === 1 && m[4] === 0 && m[5] === 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Trims float noise: coordinates carry three decimals, which is finer than any renderer needs. */
|
|
39
|
+
function n(value: number): string {
|
|
40
|
+
if (!Number.isFinite(value)) return '0';
|
|
41
|
+
return (Math.round(value * 1000) / 1000).toString();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function escapeXml(value: string): string {
|
|
45
|
+
return value
|
|
46
|
+
.replace(/&/g, '&')
|
|
47
|
+
.replace(/</g, '<')
|
|
48
|
+
.replace(/>/g, '>')
|
|
49
|
+
.replace(/"/g, '"');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface SurfaceState {
|
|
53
|
+
fillStyle: string;
|
|
54
|
+
strokeStyle: string;
|
|
55
|
+
lineWidth: number;
|
|
56
|
+
lineJoin: CanvasLineJoin;
|
|
57
|
+
lineCap: CanvasLineCap;
|
|
58
|
+
globalAlpha: number;
|
|
59
|
+
font: string;
|
|
60
|
+
textAlign: CanvasTextAlign;
|
|
61
|
+
textBaseline: CanvasTextBaseline;
|
|
62
|
+
dash: readonly number[];
|
|
63
|
+
matrix: Matrix;
|
|
64
|
+
clipId: string | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const ANCHOR: Record<string, string> = {
|
|
68
|
+
left: 'start', start: 'start',
|
|
69
|
+
center: 'middle',
|
|
70
|
+
right: 'end', end: 'end',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// dominant-baseline rather than alignment-baseline: it is the one browsers and renderers agree on
|
|
74
|
+
// for <text>, and these four are the values the renderer actually sets.
|
|
75
|
+
const BASELINE: Record<string, string> = {
|
|
76
|
+
top: 'text-before-edge',
|
|
77
|
+
hanging: 'hanging',
|
|
78
|
+
middle: 'central',
|
|
79
|
+
alphabetic: 'alphabetic',
|
|
80
|
+
ideographic: 'ideographic',
|
|
81
|
+
bottom: 'text-after-edge',
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export interface SvgSurfaceOptions {
|
|
85
|
+
width: number;
|
|
86
|
+
height: number;
|
|
87
|
+
/** Painted as a full-size rect before anything else. Omit to leave the drawing transparent. */
|
|
88
|
+
background?: string;
|
|
89
|
+
/** Borrowed only for measureText; the SVG never draws on it. */
|
|
90
|
+
metrics?: CanvasRenderingContext2D;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class SvgSurface implements DrawSurface {
|
|
94
|
+
private readonly width: number;
|
|
95
|
+
private readonly height: number;
|
|
96
|
+
private readonly background: string | undefined;
|
|
97
|
+
private readonly metrics: CanvasRenderingContext2D | null;
|
|
98
|
+
|
|
99
|
+
private readonly body: string[] = [];
|
|
100
|
+
private readonly defs: string[] = [];
|
|
101
|
+
private readonly stack: SurfaceState[] = [];
|
|
102
|
+
|
|
103
|
+
private state: SurfaceState = {
|
|
104
|
+
fillStyle: '#000000',
|
|
105
|
+
strokeStyle: '#000000',
|
|
106
|
+
lineWidth: 1,
|
|
107
|
+
lineJoin: 'miter',
|
|
108
|
+
lineCap: 'butt',
|
|
109
|
+
globalAlpha: 1,
|
|
110
|
+
font: '10px sans-serif',
|
|
111
|
+
textAlign: 'start',
|
|
112
|
+
textBaseline: 'alphabetic',
|
|
113
|
+
dash: [],
|
|
114
|
+
matrix: IDENTITY,
|
|
115
|
+
clipId: null,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** Path being built by moveTo/lineTo/arc/arcTo, in the units the caller passes. */
|
|
119
|
+
private path: string[] = [];
|
|
120
|
+
private cursor: [number, number] | null = null;
|
|
121
|
+
private subpathStart: [number, number] | null = null;
|
|
122
|
+
private clipSeq = 0;
|
|
123
|
+
|
|
124
|
+
constructor(options: SvgSurfaceOptions) {
|
|
125
|
+
this.width = options.width;
|
|
126
|
+
this.height = options.height;
|
|
127
|
+
this.background = options.background;
|
|
128
|
+
this.metrics = options.metrics ?? null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ===== state =========================================================
|
|
132
|
+
|
|
133
|
+
get fillStyle(): string | CanvasGradient | CanvasPattern { return this.state.fillStyle; }
|
|
134
|
+
set fillStyle(value: string | CanvasGradient | CanvasPattern) {
|
|
135
|
+
// Gradients and patterns are canvas objects with no serialisable form here. The renderer
|
|
136
|
+
// uses plain colours; anything else is left as the previous colour rather than guessed at.
|
|
137
|
+
if (typeof value === 'string') this.state.fillStyle = value;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
get strokeStyle(): string | CanvasGradient | CanvasPattern { return this.state.strokeStyle; }
|
|
141
|
+
set strokeStyle(value: string | CanvasGradient | CanvasPattern) {
|
|
142
|
+
if (typeof value === 'string') this.state.strokeStyle = value;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
get lineWidth(): number { return this.state.lineWidth; }
|
|
146
|
+
set lineWidth(value: number) { this.state.lineWidth = value; }
|
|
147
|
+
|
|
148
|
+
get lineJoin(): CanvasLineJoin { return this.state.lineJoin; }
|
|
149
|
+
set lineJoin(value: CanvasLineJoin) { this.state.lineJoin = value; }
|
|
150
|
+
|
|
151
|
+
get lineCap(): CanvasLineCap { return this.state.lineCap; }
|
|
152
|
+
set lineCap(value: CanvasLineCap) { this.state.lineCap = value; }
|
|
153
|
+
|
|
154
|
+
get globalAlpha(): number { return this.state.globalAlpha; }
|
|
155
|
+
set globalAlpha(value: number) { this.state.globalAlpha = value; }
|
|
156
|
+
|
|
157
|
+
get font(): string { return this.state.font; }
|
|
158
|
+
set font(value: string) {
|
|
159
|
+
this.state.font = value;
|
|
160
|
+
if (this.metrics !== null) this.metrics.font = value;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
get textAlign(): CanvasTextAlign { return this.state.textAlign; }
|
|
164
|
+
set textAlign(value: CanvasTextAlign) { this.state.textAlign = value; }
|
|
165
|
+
|
|
166
|
+
get textBaseline(): CanvasTextBaseline { return this.state.textBaseline; }
|
|
167
|
+
set textBaseline(value: CanvasTextBaseline) { this.state.textBaseline = value; }
|
|
168
|
+
|
|
169
|
+
save(): void {
|
|
170
|
+
this.stack.push({ ...this.state, dash: [...this.state.dash], matrix: [...this.state.matrix] as Matrix });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
restore(): void {
|
|
174
|
+
const previous = this.stack.pop();
|
|
175
|
+
if (previous !== undefined) this.state = previous;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
setLineDash(segments: readonly number[]): void {
|
|
179
|
+
this.state.dash = [...segments];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ===== transform =====================================================
|
|
183
|
+
|
|
184
|
+
setTransform(a: number, b: number, c: number, d: number, e: number, f: number): void {
|
|
185
|
+
this.state.matrix = [a, b, c, d, e, f];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
translate(x: number, y: number): void {
|
|
189
|
+
this.state.matrix = multiply(this.state.matrix, [1, 0, 0, 1, x, y]);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
rotate(angle: number): void {
|
|
193
|
+
const cos = Math.cos(angle);
|
|
194
|
+
const sin = Math.sin(angle);
|
|
195
|
+
this.state.matrix = multiply(this.state.matrix, [cos, sin, -sin, cos, 0, 0]);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ===== paths =========================================================
|
|
199
|
+
|
|
200
|
+
beginPath(): void {
|
|
201
|
+
this.path = [];
|
|
202
|
+
this.cursor = null;
|
|
203
|
+
this.subpathStart = null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
closePath(): void {
|
|
207
|
+
if (this.cursor === null) return;
|
|
208
|
+
this.path.push('Z');
|
|
209
|
+
if (this.subpathStart !== null) this.cursor = [...this.subpathStart] as [number, number];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
moveTo(x: number, y: number): void {
|
|
213
|
+
this.path.push(`M${n(x)} ${n(y)}`);
|
|
214
|
+
this.cursor = [x, y];
|
|
215
|
+
this.subpathStart = [x, y];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
lineTo(x: number, y: number): void {
|
|
219
|
+
if (this.cursor === null) { this.moveTo(x, y); return; }
|
|
220
|
+
this.path.push(`L${n(x)} ${n(y)}`);
|
|
221
|
+
this.cursor = [x, y];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
arc(cx: number, cy: number, r: number, start: number, end: number, ccw = false): void {
|
|
225
|
+
const sx = cx + r * Math.cos(start);
|
|
226
|
+
const sy = cy + r * Math.sin(start);
|
|
227
|
+
|
|
228
|
+
// Canvas joins an open subpath to the arc's first point with a straight line.
|
|
229
|
+
if (this.cursor === null) this.moveTo(sx, sy);
|
|
230
|
+
else this.lineTo(sx, sy);
|
|
231
|
+
|
|
232
|
+
let delta = end - start;
|
|
233
|
+
|
|
234
|
+
if (ccw) {
|
|
235
|
+
while (delta > 0) delta -= Math.PI * 2;
|
|
236
|
+
if (delta <= -Math.PI * 2) delta = -Math.PI * 2;
|
|
237
|
+
} else {
|
|
238
|
+
while (delta < 0) delta += Math.PI * 2;
|
|
239
|
+
if (delta >= Math.PI * 2) delta = Math.PI * 2;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const sweep = ccw ? 0 : 1;
|
|
243
|
+
|
|
244
|
+
// A single SVG arc cannot end where it began, so a full turn is drawn as two halves.
|
|
245
|
+
if (Math.abs(delta) >= Math.PI * 2 - 1e-9) {
|
|
246
|
+
const mid = start + delta / 2;
|
|
247
|
+
const mx = cx + r * Math.cos(mid);
|
|
248
|
+
const my = cy + r * Math.sin(mid);
|
|
249
|
+
this.path.push(`A${n(r)} ${n(r)} 0 0 ${sweep} ${n(mx)} ${n(my)}`);
|
|
250
|
+
this.path.push(`A${n(r)} ${n(r)} 0 0 ${sweep} ${n(sx)} ${n(sy)}`);
|
|
251
|
+
this.cursor = [sx, sy];
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const ex = cx + r * Math.cos(start + delta);
|
|
256
|
+
const ey = cy + r * Math.sin(start + delta);
|
|
257
|
+
const large = Math.abs(delta) > Math.PI ? 1 : 0;
|
|
258
|
+
|
|
259
|
+
this.path.push(`A${n(r)} ${n(r)} 0 ${large} ${sweep} ${n(ex)} ${n(ey)}`);
|
|
260
|
+
this.cursor = [ex, ey];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
arcTo(x1: number, y1: number, x2: number, y2: number, r: number): void {
|
|
264
|
+
if (this.cursor === null) { this.moveTo(x1, y1); return; }
|
|
265
|
+
|
|
266
|
+
const [x0, y0] = this.cursor;
|
|
267
|
+
|
|
268
|
+
// Canvas degenerates to a straight line when there is no corner to round.
|
|
269
|
+
const a = [x0 - x1, y0 - y1];
|
|
270
|
+
const b = [x2 - x1, y2 - y1];
|
|
271
|
+
const la = Math.hypot(a[0], a[1]);
|
|
272
|
+
const lb = Math.hypot(b[0], b[1]);
|
|
273
|
+
|
|
274
|
+
if (la === 0 || lb === 0 || r === 0) { this.lineTo(x1, y1); return; }
|
|
275
|
+
|
|
276
|
+
const ua = [a[0] / la, a[1] / la];
|
|
277
|
+
const ub = [b[0] / lb, b[1] / lb];
|
|
278
|
+
const cross = ua[0] * ub[1] - ua[1] * ub[0];
|
|
279
|
+
|
|
280
|
+
if (Math.abs(cross) < 1e-9) { this.lineTo(x1, y1); return; } // collinear
|
|
281
|
+
|
|
282
|
+
const angle = Math.acos(Math.max(-1, Math.min(1, ua[0] * ub[0] + ua[1] * ub[1])));
|
|
283
|
+
const tangent = r / Math.tan(angle / 2);
|
|
284
|
+
|
|
285
|
+
// The corner cannot swallow more than the shorter arm.
|
|
286
|
+
const t = Math.min(tangent, la, lb);
|
|
287
|
+
const radius = t * Math.tan(angle / 2);
|
|
288
|
+
|
|
289
|
+
const t1 = [x1 + ua[0] * t, y1 + ua[1] * t];
|
|
290
|
+
const t2 = [x1 + ub[0] * t, y1 + ub[1] * t];
|
|
291
|
+
|
|
292
|
+
this.lineTo(t1[0], t1[1]);
|
|
293
|
+
|
|
294
|
+
// Turning left in screen coordinates (y down) is a counter-clockwise sweep.
|
|
295
|
+
const sweep = cross < 0 ? 1 : 0;
|
|
296
|
+
this.path.push(`A${n(radius)} ${n(radius)} 0 0 ${sweep} ${n(t2[0])} ${n(t2[1])}`);
|
|
297
|
+
this.cursor = [t2[0], t2[1]];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
fill(): void {
|
|
301
|
+
if (this.path.length === 0) return;
|
|
302
|
+
this.emit(`<path d="${this.path.join(' ')}" ${this.fillAttrs()}/>`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
stroke(): void {
|
|
306
|
+
if (this.path.length === 0) return;
|
|
307
|
+
this.emit(`<path d="${this.path.join(' ')}" fill="none" ${this.strokeAttrs()}/>`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
clip(): void {
|
|
311
|
+
if (this.path.length === 0) return;
|
|
312
|
+
|
|
313
|
+
const id = `clip${++this.clipSeq}`;
|
|
314
|
+
this.defs.push(`<clipPath id="${id}"><path d="${this.path.join(' ')}"/></clipPath>`);
|
|
315
|
+
this.state.clipId = id;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ===== rectangles ====================================================
|
|
319
|
+
|
|
320
|
+
clearRect(x: number, y: number, w: number, h: number): void {
|
|
321
|
+
// Nothing to clear in a document that is built up rather than painted over: SVG has no
|
|
322
|
+
// eraser, and the first thing written is the background anyway.
|
|
323
|
+
void x; void y; void w; void h;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
fillRect(x: number, y: number, w: number, h: number): void {
|
|
327
|
+
this.emit(`<rect x="${n(x)}" y="${n(y)}" width="${n(w)}" height="${n(h)}" ${this.fillAttrs()}/>`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
strokeRect(x: number, y: number, w: number, h: number): void {
|
|
331
|
+
this.emit(`<rect x="${n(x)}" y="${n(y)}" width="${n(w)}" height="${n(h)}" fill="none" ${this.strokeAttrs()}/>`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ===== text ==========================================================
|
|
335
|
+
|
|
336
|
+
fillText(text: string, x: number, y: number): void {
|
|
337
|
+
if (text === '') return;
|
|
338
|
+
|
|
339
|
+
const anchor = ANCHOR[this.state.textAlign] ?? 'start';
|
|
340
|
+
const baseline = BASELINE[this.state.textBaseline] ?? 'alphabetic';
|
|
341
|
+
|
|
342
|
+
// The canvas font string is CSS font shorthand, so it transfers verbatim -- no parsing, and
|
|
343
|
+
// no chance of losing the weight or the fallback list on the way.
|
|
344
|
+
this.emit(
|
|
345
|
+
`<text x="${n(x)}" y="${n(y)}" style="font:${escapeXml(this.state.font)}" ` +
|
|
346
|
+
`text-anchor="${anchor}" dominant-baseline="${baseline}" ${this.fillAttrs()}>` +
|
|
347
|
+
`${escapeXml(text)}</text>`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
measureText(text: string): { width: number } {
|
|
351
|
+
if (this.metrics !== null) return { width: this.metrics.measureText(text).width };
|
|
352
|
+
|
|
353
|
+
// Without a context to ask, a rough advance keeps layout sane rather than collapsing to
|
|
354
|
+
// zero and stacking every label at one point.
|
|
355
|
+
const size = parseFloat(this.state.font) || 10;
|
|
356
|
+
return { width: text.length * size * 0.55 };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ===== images ========================================================
|
|
360
|
+
|
|
361
|
+
drawImage(image: CanvasImageSource, dx: number, dy: number, dw: number, dh: number): void {
|
|
362
|
+
const href = this.imageHref(image);
|
|
363
|
+
if (href === null) return;
|
|
364
|
+
|
|
365
|
+
this.emit(`<image x="${n(dx)}" y="${n(dy)}" width="${n(dw)}" height="${n(dh)}" href="${escapeXml(href)}"${this.commonAttrs()}/>`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private imageHref(image: CanvasImageSource): string | null {
|
|
369
|
+
try {
|
|
370
|
+
if (typeof HTMLCanvasElement !== 'undefined' && image instanceof HTMLCanvasElement) return image.toDataURL();
|
|
371
|
+
if (typeof HTMLImageElement !== 'undefined' && image instanceof HTMLImageElement) return image.src;
|
|
372
|
+
} catch {
|
|
373
|
+
// A canvas tainted by a cross-origin draw refuses toDataURL. Dropping the image beats
|
|
374
|
+
// failing the whole export for one picture.
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ===== output ========================================================
|
|
381
|
+
|
|
382
|
+
/** The finished document. Safe to call more than once; it does not consume the recording. */
|
|
383
|
+
toSvg(): string {
|
|
384
|
+
const background = this.background === undefined
|
|
385
|
+
? ''
|
|
386
|
+
: `<rect width="100%" height="100%" fill="${escapeXml(this.background)}"/>`;
|
|
387
|
+
|
|
388
|
+
const defs = this.defs.length === 0 ? '' : `<defs>${this.defs.join('')}</defs>`;
|
|
389
|
+
|
|
390
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${n(this.width)}" height="${n(this.height)}" ` +
|
|
391
|
+
`viewBox="0 0 ${n(this.width)} ${n(this.height)}">` +
|
|
392
|
+
`${defs}${background}${this.body.join('')}</svg>`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ===== internals =====================================================
|
|
396
|
+
|
|
397
|
+
private emit(element: string): void {
|
|
398
|
+
this.body.push(element);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private commonAttrs(): string {
|
|
402
|
+
const parts: string[] = [];
|
|
403
|
+
|
|
404
|
+
if (!isIdentity(this.state.matrix)) {
|
|
405
|
+
parts.push(` transform="matrix(${this.state.matrix.map(n).join(' ')})"`);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (this.state.globalAlpha !== 1) parts.push(` opacity="${n(this.state.globalAlpha)}"`);
|
|
409
|
+
if (this.state.clipId !== null) parts.push(` clip-path="url(#${this.state.clipId})"`);
|
|
410
|
+
|
|
411
|
+
return parts.join('');
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private fillAttrs(): string {
|
|
415
|
+
return `fill="${escapeXml(this.state.fillStyle)}"${this.commonAttrs()}`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private strokeAttrs(): string {
|
|
419
|
+
const parts = [
|
|
420
|
+
`stroke="${escapeXml(this.state.strokeStyle)}"`,
|
|
421
|
+
`stroke-width="${n(this.state.lineWidth)}"`,
|
|
422
|
+
];
|
|
423
|
+
|
|
424
|
+
if (this.state.lineJoin !== 'miter') parts.push(`stroke-linejoin="${this.state.lineJoin}"`);
|
|
425
|
+
if (this.state.lineCap !== 'butt') parts.push(`stroke-linecap="${this.state.lineCap}"`);
|
|
426
|
+
if (this.state.dash.length > 0) parts.push(`stroke-dasharray="${this.state.dash.map(n).join(' ')}"`);
|
|
427
|
+
|
|
428
|
+
return parts.join(' ') + this.commonAttrs();
|
|
429
|
+
}
|
|
430
|
+
}
|