js-draw 0.1.1 → 0.1.4

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.
Files changed (86) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +21 -12
  3. package/dist/bundle.js +1 -1
  4. package/dist/src/Editor.d.ts +2 -1
  5. package/dist/src/Editor.js +24 -6
  6. package/dist/src/EditorImage.js +3 -0
  7. package/dist/src/Pointer.d.ts +3 -2
  8. package/dist/src/Pointer.js +12 -3
  9. package/dist/src/SVGLoader.d.ts +11 -0
  10. package/dist/src/SVGLoader.js +113 -4
  11. package/dist/src/Viewport.d.ts +1 -1
  12. package/dist/src/Viewport.js +12 -2
  13. package/dist/src/components/AbstractComponent.d.ts +6 -0
  14. package/dist/src/components/AbstractComponent.js +11 -0
  15. package/dist/src/components/SVGGlobalAttributesObject.js +0 -1
  16. package/dist/src/components/Stroke.js +1 -1
  17. package/dist/src/components/Text.d.ts +30 -0
  18. package/dist/src/components/Text.js +111 -0
  19. package/dist/src/components/localization.d.ts +1 -0
  20. package/dist/src/components/localization.js +1 -0
  21. package/dist/src/geometry/Mat33.d.ts +1 -0
  22. package/dist/src/geometry/Mat33.js +30 -0
  23. package/dist/src/geometry/Path.js +105 -67
  24. package/dist/src/geometry/Rect2.d.ts +2 -0
  25. package/dist/src/geometry/Rect2.js +6 -0
  26. package/dist/src/rendering/renderers/AbstractRenderer.d.ts +7 -1
  27. package/dist/src/rendering/renderers/AbstractRenderer.js +13 -1
  28. package/dist/src/rendering/renderers/CanvasRenderer.d.ts +3 -0
  29. package/dist/src/rendering/renderers/CanvasRenderer.js +28 -8
  30. package/dist/src/rendering/renderers/DummyRenderer.d.ts +3 -0
  31. package/dist/src/rendering/renderers/DummyRenderer.js +5 -0
  32. package/dist/src/rendering/renderers/SVGRenderer.d.ts +6 -2
  33. package/dist/src/rendering/renderers/SVGRenderer.js +50 -7
  34. package/dist/src/testing/loadExpectExtensions.js +1 -4
  35. package/dist/src/toolbar/HTMLToolbar.d.ts +2 -1
  36. package/dist/src/toolbar/HTMLToolbar.js +242 -154
  37. package/dist/src/toolbar/icons.d.ts +12 -0
  38. package/dist/src/toolbar/icons.js +198 -0
  39. package/dist/src/toolbar/localization.d.ts +5 -1
  40. package/dist/src/toolbar/localization.js +5 -1
  41. package/dist/src/toolbar/types.d.ts +4 -0
  42. package/dist/src/tools/PanZoom.d.ts +9 -6
  43. package/dist/src/tools/PanZoom.js +30 -21
  44. package/dist/src/tools/Pen.js +8 -3
  45. package/dist/src/tools/SelectionTool.js +1 -1
  46. package/dist/src/tools/TextTool.d.ts +30 -0
  47. package/dist/src/tools/TextTool.js +173 -0
  48. package/dist/src/tools/ToolController.d.ts +5 -5
  49. package/dist/src/tools/ToolController.js +10 -9
  50. package/dist/src/tools/localization.d.ts +3 -0
  51. package/dist/src/tools/localization.js +3 -0
  52. package/dist-test/test-dist-bundle.html +8 -1
  53. package/package.json +1 -1
  54. package/src/Editor.css +2 -0
  55. package/src/Editor.ts +26 -7
  56. package/src/EditorImage.ts +4 -0
  57. package/src/Pointer.ts +13 -4
  58. package/src/SVGLoader.ts +146 -5
  59. package/src/Viewport.ts +15 -3
  60. package/src/components/AbstractComponent.ts +16 -1
  61. package/src/components/SVGGlobalAttributesObject.ts +0 -1
  62. package/src/components/Stroke.ts +1 -1
  63. package/src/components/Text.ts +140 -0
  64. package/src/components/localization.ts +2 -0
  65. package/src/geometry/Mat33.test.ts +44 -0
  66. package/src/geometry/Mat33.ts +41 -0
  67. package/src/geometry/Path.fromString.test.ts +94 -4
  68. package/src/geometry/Path.toString.test.ts +7 -3
  69. package/src/geometry/Path.ts +110 -68
  70. package/src/geometry/Rect2.ts +8 -0
  71. package/src/rendering/renderers/AbstractRenderer.ts +18 -1
  72. package/src/rendering/renderers/CanvasRenderer.ts +34 -10
  73. package/src/rendering/renderers/DummyRenderer.ts +8 -0
  74. package/src/rendering/renderers/SVGRenderer.ts +57 -10
  75. package/src/testing/loadExpectExtensions.ts +1 -4
  76. package/src/toolbar/HTMLToolbar.ts +294 -170
  77. package/src/toolbar/icons.ts +227 -0
  78. package/src/toolbar/localization.ts +11 -2
  79. package/src/toolbar/toolbar.css +27 -11
  80. package/src/toolbar/types.ts +5 -0
  81. package/src/tools/PanZoom.ts +37 -27
  82. package/src/tools/Pen.ts +7 -3
  83. package/src/tools/SelectionTool.ts +1 -1
  84. package/src/tools/TextTool.ts +225 -0
  85. package/src/tools/ToolController.ts +7 -5
  86. package/src/tools/localization.ts +7 -0
@@ -0,0 +1,140 @@
1
+ import LineSegment2 from '../geometry/LineSegment2';
2
+ import Mat33 from '../geometry/Mat33';
3
+ import Rect2 from '../geometry/Rect2';
4
+ import AbstractRenderer, { RenderingStyle } from '../rendering/renderers/AbstractRenderer';
5
+ import AbstractComponent from './AbstractComponent';
6
+ import { ImageComponentLocalization } from './localization';
7
+
8
+ export interface TextStyle {
9
+ size: number;
10
+ fontFamily: string;
11
+ fontWeight?: string;
12
+ fontVariant?: string;
13
+ renderingStyle: RenderingStyle;
14
+ }
15
+
16
+
17
+ export default class Text extends AbstractComponent {
18
+ protected contentBBox: Rect2;
19
+
20
+ public constructor(protected textObjects: Array<string|Text>, private transform: Mat33, private style: TextStyle) {
21
+ super();
22
+ this.recomputeBBox();
23
+ }
24
+
25
+ public static applyTextStyles(ctx: CanvasRenderingContext2D, style: TextStyle) {
26
+ // Quote the font family if necessary.
27
+ const fontFamily = style.fontFamily.match(/\s/) ? style.fontFamily.replace(/["]/g, '\\"') : style.fontFamily;
28
+
29
+ ctx.font = [
30
+ (style.size ?? 12) + 'px',
31
+ style.fontWeight ?? '',
32
+ `${fontFamily}`,
33
+ style.fontWeight
34
+ ].join(' ');
35
+
36
+ ctx.textAlign = 'left';
37
+ }
38
+
39
+ private static textMeasuringCtx: CanvasRenderingContext2D;
40
+ private static getTextDimens(text: string, style: TextStyle): Rect2 {
41
+ Text.textMeasuringCtx ??= document.createElement('canvas').getContext('2d')!;
42
+ const ctx = Text.textMeasuringCtx;
43
+ Text.applyTextStyles(ctx, style);
44
+
45
+ const measure = ctx.measureText(text);
46
+
47
+ // Text is drawn with (0,0) at the bottom left of the baseline.
48
+ const textY = -measure.actualBoundingBoxAscent;
49
+ const textHeight = measure.actualBoundingBoxAscent + measure.actualBoundingBoxDescent;
50
+ return new Rect2(0, textY, measure.width, textHeight);
51
+ }
52
+
53
+ private computeBBoxOfPart(part: string|Text) {
54
+ if (typeof part === 'string') {
55
+ const textBBox = Text.getTextDimens(part, this.style);
56
+ return textBBox.transformedBoundingBox(this.transform);
57
+ } else {
58
+ const bbox = part.contentBBox.transformedBoundingBox(this.transform);
59
+ return bbox;
60
+ }
61
+ }
62
+
63
+ private recomputeBBox() {
64
+ let bbox: Rect2|null = null;
65
+
66
+ for (const textObject of this.textObjects) {
67
+ const currentBBox = this.computeBBoxOfPart(textObject);
68
+ bbox ??= currentBBox;
69
+ bbox = bbox.union(currentBBox);
70
+ }
71
+
72
+ this.contentBBox = bbox ?? Rect2.empty;
73
+ }
74
+
75
+ public render(canvas: AbstractRenderer, _visibleRect?: Rect2): void {
76
+ const cursor = this.transform;
77
+
78
+ canvas.startObject(this.contentBBox);
79
+ for (const textObject of this.textObjects) {
80
+ if (typeof textObject === 'string') {
81
+ canvas.drawText(textObject, cursor, this.style);
82
+ } else {
83
+ canvas.pushTransform(cursor);
84
+ textObject.render(canvas);
85
+ canvas.popTransform();
86
+ }
87
+ }
88
+ canvas.endObject(this.getLoadSaveData());
89
+ }
90
+
91
+ public intersects(lineSegment: LineSegment2): boolean {
92
+
93
+ // Convert canvas space to internal space.
94
+ const invTransform = this.transform.inverse();
95
+ const p1InThisSpace = invTransform.transformVec2(lineSegment.p1);
96
+ const p2InThisSpace = invTransform.transformVec2(lineSegment.p2);
97
+ lineSegment = new LineSegment2(p1InThisSpace, p2InThisSpace);
98
+
99
+ for (const subObject of this.textObjects) {
100
+ if (typeof subObject === 'string') {
101
+ const textBBox = Text.getTextDimens(subObject, this.style);
102
+
103
+ // TODO: Use a better intersection check. Perhaps draw the text onto a CanvasElement and
104
+ // use pixel-testing to check for intersection with its contour.
105
+ if (textBBox.getEdges().some(edge => lineSegment.intersection(edge) !== null)) {
106
+ return true;
107
+ }
108
+ } else {
109
+ if (subObject.intersects(lineSegment)) {
110
+ return true;
111
+ }
112
+ }
113
+ }
114
+
115
+ return false;
116
+ }
117
+
118
+ protected applyTransformation(affineTransfm: Mat33): void {
119
+ this.transform = affineTransfm.rightMul(this.transform);
120
+ this.recomputeBBox();
121
+ }
122
+
123
+ private getText() {
124
+ const result: string[] = [];
125
+
126
+ for (const textObject of this.textObjects) {
127
+ if (typeof textObject === 'string') {
128
+ result.push(textObject);
129
+ } else {
130
+ result.push(textObject.getText());
131
+ }
132
+ }
133
+
134
+ return result.join(' ');
135
+ }
136
+
137
+ public description(localizationTable: ImageComponentLocalization): string {
138
+ return localizationTable.text(this.getText());
139
+ }
140
+ }
@@ -1,4 +1,5 @@
1
1
  export interface ImageComponentLocalization {
2
+ text: (text: string)=> string;
2
3
  stroke: string;
3
4
  svgObject: string;
4
5
  }
@@ -6,4 +7,5 @@ export interface ImageComponentLocalization {
6
7
  export const defaultComponentLocalization: ImageComponentLocalization = {
7
8
  stroke: 'Stroke',
8
9
  svgObject: 'SVG Object',
10
+ text: (text) => `Text object: ${text}`,
9
11
  };
@@ -141,4 +141,48 @@ describe('Mat33 tests', () => {
141
141
  fullTransformInverse.transformVec2(fullTransform.transformVec2(Vec2.unitX))
142
142
  ).objEq(Vec2.unitX, fuzz);
143
143
  });
144
+
145
+ it('should convert CSS matrix(...) strings to matricies', () => {
146
+ // From MDN:
147
+ // ⎡ a c e ⎤
148
+ // ⎢ b d f ⎥ = matrix(a,b,c,d,e,f)
149
+ // ⎣ 0 0 1 ⎦
150
+ const identity = Mat33.fromCSSMatrix('matrix(1, 0, 0, 1, 0, 0)');
151
+ expect(identity).objEq(Mat33.identity);
152
+ expect(Mat33.fromCSSMatrix('matrix(1, 2, 3, 4, 5, 6)')).objEq(new Mat33(
153
+ 1, 3, 5,
154
+ 2, 4, 6,
155
+ 0, 0, 1,
156
+ ));
157
+ expect(Mat33.fromCSSMatrix('matrix(1e2, 2, 3, 4, 5, 6)')).objEq(new Mat33(
158
+ 1e2, 3, 5,
159
+ 2, 4, 6,
160
+ 0, 0, 1,
161
+ ));
162
+ expect(Mat33.fromCSSMatrix('matrix(1.6, 2, .3, 4, 5, 6)')).objEq(new Mat33(
163
+ 1.6, .3, 5,
164
+ 2, 4, 6,
165
+ 0, 0, 1,
166
+ ));
167
+ expect(Mat33.fromCSSMatrix('matrix(-1, 2, 3.E-2, 4, -5.123, -6.5)')).objEq(new Mat33(
168
+ -1, 0.03, -5.123,
169
+ 2, 4, -6.5,
170
+ 0, 0, 1,
171
+ ));
172
+ expect(Mat33.fromCSSMatrix('matrix(1.6,\n\t2, .3, 4, 5, 6)')).objEq(new Mat33(
173
+ 1.6, .3, 5,
174
+ 2, 4, 6,
175
+ 0, 0, 1,
176
+ ));
177
+ expect(Mat33.fromCSSMatrix('matrix(1.6,2, .3E-2, 4, 5, 6)')).objEq(new Mat33(
178
+ 1.6, 3e-3, 5,
179
+ 2, 4, 6,
180
+ 0, 0, 1,
181
+ ));
182
+ expect(Mat33.fromCSSMatrix('matrix(-1, 2e6, 3E-2,-5.123, -6.5e-1, 0.01)')).objEq(new Mat33(
183
+ -1, 3E-2, -6.5e-1,
184
+ 2e6, -5.123, 0.01,
185
+ 0, 0, 1,
186
+ ));
187
+ });
144
188
  });
@@ -268,4 +268,45 @@ export default class Mat33 {
268
268
  // Translate such that [center] goes to (0, 0)
269
269
  return result.rightMul(Mat33.translation(center.times(-1)));
270
270
  }
271
+
272
+ // Converts a CSS-form matrix(a, b, c, d, e, f) to a Mat33.
273
+ public static fromCSSMatrix(cssString: string): Mat33 {
274
+ if (cssString === '' || cssString === 'none') {
275
+ return Mat33.identity;
276
+ }
277
+
278
+ const numberExp = '([-]?\\d*(?:\\.\\d*)?(?:[eE][-]?\\d+)?)';
279
+ const numberSepExp = '[, \\t\\n]';
280
+ const regExpSource = `^\\s*matrix\\s*\\(${
281
+ [
282
+ // According to MDN, matrix(a,b,c,d,e,f) has form:
283
+ // ⎡ a c e ⎤
284
+ // ⎢ b d f ⎥
285
+ // ⎣ 0 0 1 ⎦
286
+ numberExp, numberExp, numberExp, // a, c, e
287
+ numberExp, numberExp, numberExp, // b, d, f
288
+ ].join(`${numberSepExp}+`)
289
+ }${numberSepExp}*\\)\\s*$`;
290
+ const matrixExp = new RegExp(regExpSource, 'i');
291
+ const match = matrixExp.exec(cssString);
292
+
293
+ if (!match) {
294
+ throw new Error(`Unsupported transformation: ${cssString}`);
295
+ }
296
+
297
+ const matrixData = match.slice(1).map(entry => parseFloat(entry));
298
+ const a = matrixData[0];
299
+ const b = matrixData[1];
300
+ const c = matrixData[2];
301
+ const d = matrixData[3];
302
+ const e = matrixData[4];
303
+ const f = matrixData[5];
304
+
305
+ const transform = new Mat33(
306
+ a, c, e,
307
+ b, d, f,
308
+ 0, 0, 1
309
+ );
310
+ return transform;
311
+ }
271
312
  }
@@ -90,15 +90,38 @@ describe('Path.fromString', () => {
90
90
  ]);
91
91
  });
92
92
 
93
+ it('should break compoents at -s', () => {
94
+ const path = Path.fromString('m1-1 L-1-1-3-4-5-6,5-1');
95
+ expect(path.parts.length).toBe(4);
96
+ expect(path.parts).toMatchObject([
97
+ {
98
+ kind: PathCommandType.LineTo,
99
+ point: Vec2.of(-1, -1),
100
+ },
101
+ {
102
+ kind: PathCommandType.LineTo,
103
+ point: Vec2.of(-3, -4),
104
+ },
105
+ {
106
+ kind: PathCommandType.LineTo,
107
+ point: Vec2.of(-5, -6),
108
+ },
109
+ {
110
+ kind: PathCommandType.LineTo,
111
+ point: Vec2.of(5, -1),
112
+ },
113
+ ]);
114
+ });
115
+
93
116
  it('should properly handle cubic Bézier curves', () => {
94
- const path = Path.fromString('c1,1 0,-3 4 5 C1,1 0.1, 0.1 0, 0');
117
+ const path = Path.fromString('m1,1 c1,1 0-3 4 5 C1,1 0.1, 0.1 0, 0');
95
118
  expect(path.parts.length).toBe(2);
96
119
  expect(path.parts).toMatchObject([
97
120
  {
98
121
  kind: PathCommandType.CubicBezierTo,
99
- controlPoint1: Vec2.of(1, 1),
122
+ controlPoint1: Vec2.of(2, 2),
100
123
  controlPoint2: Vec2.of(1, -2),
101
- endPoint: Vec2.of(5, 3),
124
+ endPoint: Vec2.of(5, 6),
102
125
  },
103
126
  {
104
127
  kind: PathCommandType.CubicBezierTo,
@@ -120,7 +143,7 @@ describe('Path.fromString', () => {
120
143
  {
121
144
  kind: PathCommandType.QuadraticBezierTo,
122
145
  controlPoint: Vec2.of(1, 1),
123
- endPoint: Vec2.of(-2, -3),
146
+ endPoint: Vec2.of(-1, -1),
124
147
  },
125
148
  {
126
149
  kind: PathCommandType.QuadraticBezierTo,
@@ -130,4 +153,71 @@ describe('Path.fromString', () => {
130
153
  ]);
131
154
  expect(path.startPoint).toMatchObject(Vec2.of(1, 1));
132
155
  });
156
+
157
+ it('should correctly handle a command followed by multiple sets of arguments', () => {
158
+ // Commands followed by multiple sets of arguments, for example,
159
+ // l 5,10 5,4 3,2,
160
+ // should be interpreted as multiple commands. Our example, is therefore equivalent to,
161
+ // l 5,10 l 5,4 l 3,2
162
+
163
+ const path = Path.fromString(`
164
+ L5,10 1,1
165
+ 2,2 -3,-1
166
+ q 1,2 1,1
167
+ -1,-1 -3,-4
168
+ h -4 -1
169
+ V 3 5 1
170
+ `);
171
+ expect(path.parts).toMatchObject([
172
+ {
173
+ kind: PathCommandType.LineTo,
174
+ point: Vec2.of(1, 1),
175
+ },
176
+ {
177
+ kind: PathCommandType.LineTo,
178
+ point: Vec2.of(2, 2),
179
+ },
180
+ {
181
+ kind: PathCommandType.LineTo,
182
+ point: Vec2.of(-3, -1),
183
+ },
184
+
185
+ // q 1,2 1,1 -1,-1 -3,-4
186
+ {
187
+ kind: PathCommandType.QuadraticBezierTo,
188
+ controlPoint: Vec2.of(-2, 1),
189
+ endPoint: Vec2.of(-2, 0),
190
+ },
191
+ {
192
+ kind: PathCommandType.QuadraticBezierTo,
193
+ controlPoint: Vec2.of(-3, -1),
194
+ endPoint: Vec2.of(-5, -4),
195
+ },
196
+
197
+ // h -4 -1
198
+ {
199
+ kind: PathCommandType.LineTo,
200
+ point: Vec2.of(-9, -4),
201
+ },
202
+ {
203
+ kind: PathCommandType.LineTo,
204
+ point: Vec2.of(-10, -4),
205
+ },
206
+
207
+ // V 3 5 1
208
+ {
209
+ kind: PathCommandType.LineTo,
210
+ point: Vec2.of(-10, 3),
211
+ },
212
+ {
213
+ kind: PathCommandType.LineTo,
214
+ point: Vec2.of(-10, 5),
215
+ },
216
+ {
217
+ kind: PathCommandType.LineTo,
218
+ point: Vec2.of(-10, 1),
219
+ },
220
+ ]);
221
+ expect(path.startPoint).toMatchObject(Vec2.of(5, 10));
222
+ });
133
223
  });
@@ -19,14 +19,18 @@ describe('Path.toString', () => {
19
19
  });
20
20
 
21
21
  it('should fix rounding errors', () => {
22
- const path = new Path(Vec2.of(0.10000001, 0.19999999), [
22
+ const path = new Path(Vec2.of(0.100000001, 0.199999999), [
23
23
  {
24
24
  kind: PathCommandType.QuadraticBezierTo,
25
25
  controlPoint: Vec2.of(9999, -10.999999995),
26
- endPoint: Vec2.of(0.000300001, 1.40000002),
26
+ endPoint: Vec2.of(0.000300001, 1.400000002),
27
27
  },
28
+ {
29
+ kind: PathCommandType.LineTo,
30
+ point: Vec2.of(184.00482359999998, 1)
31
+ }
28
32
  ]);
29
- expect(path.toString()).toBe('M0.1,0.2Q9999,-11 0.0003,1.4');
33
+ expect(path.toString()).toBe('M0.1,0.2Q9999,-11 0.0003,1.4L184.0048236,1');
30
34
  });
31
35
 
32
36
  it('should not remove trailing zeroes before decimal points', () => {
@@ -303,6 +303,8 @@ export default class Path {
303
303
  const postDecimal = parseInt(roundingDownMatch[3], 10);
304
304
  const preDecimal = parseInt(roundingDownMatch[2], 10);
305
305
 
306
+ const origPostDecimalString = roundingDownMatch[3];
307
+
306
308
  let newPostDecimal = (postDecimal + 10 - lastDigit).toString();
307
309
  let carry = 0;
308
310
  if (newPostDecimal.length > postDecimal.toString().length) {
@@ -310,13 +312,21 @@ export default class Path {
310
312
  newPostDecimal = newPostDecimal.substring(1);
311
313
  carry = 1;
312
314
  }
315
+
316
+ // parseInt(...).toString() removes leading zeroes. Add them back.
317
+ while (newPostDecimal.length < origPostDecimalString.length) {
318
+ newPostDecimal = carry.toString(10) + newPostDecimal;
319
+ carry = 0;
320
+ }
321
+
313
322
  text = `${negativeSign + (preDecimal + carry).toString()}.${newPostDecimal}`;
314
323
  }
315
324
 
316
325
  text = text.replace(fixRoundingUpExp, '$1');
317
326
 
318
327
  // Remove trailing zeroes
319
- text = text.replace(/([.][^0]*)0+$/, '$1');
328
+ text = text.replace(/([.]\d*[^0]+)0+$/, '$1');
329
+ text = text.replace(/[.]0+$/, '.');
320
330
 
321
331
  // Remove trailing period
322
332
  return text.replace(/[.]$/, '');
@@ -371,6 +381,7 @@ export default class Path {
371
381
 
372
382
  let lastPos: Point2 = Vec2.zero;
373
383
  let firstPos: Point2|null = null;
384
+ let startPos: Point2|null = null;
374
385
  let isFirstCommand: boolean = true;
375
386
  const commands: PathCommand[] = [];
376
387
 
@@ -413,19 +424,67 @@ export default class Path {
413
424
  endPoint,
414
425
  });
415
426
  };
427
+ const commandArgCounts: Record<string, number> = {
428
+ 'm': 1,
429
+ 'l': 1,
430
+ 'c': 3,
431
+ 'q': 2,
432
+ 'z': 0,
433
+ 'h': 1,
434
+ 'v': 1,
435
+ };
416
436
 
417
437
  // Each command: Command character followed by anything that isn't a command character
418
- const commandExp = /([MmZzLlHhVvCcSsQqTtAa])\s*([^a-zA-Z]*)/g;
438
+ const commandExp = /([MZLHVCSQTA])\s*([^MZLHVCSQTA]*)/ig;
419
439
  let current;
420
440
  while ((current = commandExp.exec(pathString)) !== null) {
421
- const argParts = current[2].trim().split(/[^0-9.-]/).filter(
441
+ const argParts = current[2].trim().split(/[^0-9Ee.-]/).filter(
422
442
  part => part.length > 0
423
- );
424
- const numericArgs = argParts.map(arg => parseFloat(arg));
443
+ ).reduce((accumualtor: string[], current: string): string[] => {
444
+ // As of 09/2022, iOS Safari doesn't support support lookbehind in regular
445
+ // expressions. As such, we need an alternative.
446
+ // Because '-' can be used as a path separator, unless preceeded by an 'e' (as in 1e-5),
447
+ // we need special cases:
448
+ current = current.replace(/([^eE])[-]/g, '$1 -');
449
+ const parts = current.split(' -');
450
+ if (parts[0] !== '') {
451
+ accumualtor.push(parts[0]);
452
+ }
453
+ accumualtor.push(...parts.slice(1).map(part => `-${part}`));
454
+ return accumualtor;
455
+ }, []);
456
+
457
+ let numericArgs = argParts.map(arg => parseFloat(arg));
458
+
459
+ let commandChar = current[1].toLowerCase();
460
+ let uppercaseCommand = current[1] !== commandChar;
461
+
462
+ // Convert commands that don't take points into commands that do.
463
+ if (commandChar === 'v' || commandChar === 'h') {
464
+ numericArgs = numericArgs.reduce((accumulator: number[], current: number): number[] => {
465
+ if (commandChar === 'v') {
466
+ return accumulator.concat(uppercaseCommand ? lastPos.x : 0, current);
467
+ } else {
468
+ return accumulator.concat(current, uppercaseCommand ? lastPos.y : 0);
469
+ }
470
+ }, []);
471
+ commandChar = 'l';
472
+ } else if (commandChar === 'z') {
473
+ if (firstPos) {
474
+ numericArgs = [ firstPos.x, firstPos.y ];
475
+ firstPos = lastPos;
476
+ } else {
477
+ continue;
478
+ }
479
+
480
+ // 'z' always acts like an uppercase lineTo(startPos)
481
+ uppercaseCommand = true;
482
+ commandChar = 'l';
483
+ }
484
+
425
485
 
426
- const commandChar = current[1];
427
- const uppercaseCommand = commandChar !== commandChar.toLowerCase();
428
- const args = numericArgs.reduce((
486
+ const commandArgCount: number = commandArgCounts[commandChar] ?? 0;
487
+ const allArgs = numericArgs.reduce((
429
488
  accumulator: Point2[], current, index, parts
430
489
  ): Point2[] => {
431
490
  if (index % 2 !== 0) {
@@ -435,82 +494,65 @@ export default class Path {
435
494
  } else {
436
495
  return accumulator;
437
496
  }
438
- }, []).map((coordinate: Vec2): Point2 => {
497
+ }, []).map((coordinate, index): Point2 => {
439
498
  // Lowercase commands are relative, uppercase commands use absolute
440
499
  // positioning
500
+ let newPos;
441
501
  if (uppercaseCommand) {
442
- lastPos = coordinate;
443
- return coordinate;
502
+ newPos = coordinate;
444
503
  } else {
445
- lastPos = lastPos.plus(coordinate);
446
- return lastPos;
504
+ newPos = lastPos.plus(coordinate);
447
505
  }
448
- });
449
-
450
- let expectedPointArgCount;
451
506
 
452
- switch (commandChar.toLowerCase()) {
453
- case 'm':
454
- expectedPointArgCount = 1;
455
- moveTo(args[0]);
456
- break;
457
- case 'l':
458
- expectedPointArgCount = 1;
459
- lineTo(args[0]);
460
- break;
461
- case 'z':
462
- expectedPointArgCount = 0;
463
- // firstPos can be null if the stroke data is just 'z'.
464
- if (firstPos) {
465
- lineTo(firstPos);
507
+ if ((index + 1) % commandArgCount === 0) {
508
+ lastPos = newPos;
466
509
  }
467
- break;
468
- case 'c':
469
- expectedPointArgCount = 3;
470
- cubicBezierTo(args[0], args[1], args[2]);
471
- break;
472
- case 'q':
473
- expectedPointArgCount = 2;
474
- quadraticBeierTo(args[0], args[1]);
475
- break;
476
-
477
- // Horizontal line
478
- case 'h':
479
- expectedPointArgCount = 0;
480
510
 
481
- if (uppercaseCommand) {
482
- lineTo(Vec2.of(numericArgs[0], lastPos.y));
483
- } else {
484
- lineTo(lastPos.plus(Vec2.of(numericArgs[0], 0)));
485
- }
486
- break;
511
+ return newPos;
512
+ });
513
+
514
+ if (allArgs.length % commandArgCount !== 0) {
515
+ throw new Error([
516
+ `Incorrect number of arguments: got ${JSON.stringify(allArgs)} with a length of ${allArgs.length} ≠ ${commandArgCount}k, k ∈ ℤ.`,
517
+ `The number of arguments to ${commandChar} must be a multiple of ${commandArgCount}!`,
518
+ `Command: ${current[0]}`,
519
+ ].join('\n'));
520
+ }
487
521
 
488
- // Vertical line
489
- case 'v':
490
- expectedPointArgCount = 0;
522
+ for (let argPos = 0; argPos < allArgs.length; argPos += commandArgCount) {
523
+ const args = allArgs.slice(argPos, argPos + commandArgCount);
491
524
 
492
- if (uppercaseCommand) {
493
- lineTo(Vec2.of(lastPos.x, numericArgs[1]));
494
- } else {
495
- lineTo(lastPos.plus(Vec2.of(0, numericArgs[1])));
525
+ switch (commandChar.toLowerCase()) {
526
+ case 'm':
527
+ if (argPos === 0) {
528
+ moveTo(args[0]);
529
+ } else {
530
+ lineTo(args[0]);
531
+ }
532
+ break;
533
+ case 'l':
534
+ lineTo(args[0]);
535
+ break;
536
+ case 'c':
537
+ cubicBezierTo(args[0], args[1], args[2]);
538
+ break;
539
+ case 'q':
540
+ quadraticBeierTo(args[0], args[1]);
541
+ break;
542
+ default:
543
+ throw new Error(`Unknown path command ${commandChar}`);
496
544
  }
497
- break;
498
- default:
499
- throw new Error(`Unknown path command ${commandChar}`);
500
- }
501
545
 
502
- if (args.length !== expectedPointArgCount) {
503
- throw new Error(`
504
- Incorrect number of arguments: got ${JSON.stringify(args)} with a length of ${args.length} ≠ ${expectedPointArgCount}.
505
- `.trim());
546
+ isFirstCommand = false;
506
547
  }
507
548
 
508
- if (args.length > 0) {
509
- firstPos ??= args[0];
549
+ if (allArgs.length > 0) {
550
+ firstPos ??= allArgs[0];
551
+ startPos ??= firstPos;
552
+ lastPos = allArgs[allArgs.length - 1];
510
553
  }
511
- isFirstCommand = false;
512
554
  }
513
555
 
514
- return new Path(firstPos ?? Vec2.zero, commands);
556
+ return new Path(startPos ?? Vec2.zero, commands);
515
557
  }
516
558
  }
@@ -184,6 +184,14 @@ export default class Rect2 {
184
184
  return this.topLeft.plus(Vec2.of(0, this.h));
185
185
  }
186
186
 
187
+ public get width() {
188
+ return this.w;
189
+ }
190
+
191
+ public get height() {
192
+ return this.h;
193
+ }
194
+
187
195
  // Returns edges in the order
188
196
  // [ rightEdge, topEdge, leftEdge, bottomEdge ]
189
197
  public getEdges(): LineSegment2[] {
@@ -1,4 +1,6 @@
1
1
  import Color4 from '../../Color4';
2
+ import { LoadSaveDataTable } from '../../components/AbstractComponent';
3
+ import { TextStyle } from '../../components/Text';
2
4
  import Mat33 from '../../geometry/Mat33';
3
5
  import Path, { PathCommand, PathCommandType } from '../../geometry/Path';
4
6
  import Rect2 from '../../geometry/Rect2';
@@ -28,6 +30,7 @@ const stylesEqual = (a: RenderingStyle, b: RenderingStyle) => {
28
30
  export default abstract class AbstractRenderer {
29
31
  // If null, this' transformation is linked to the Viewport
30
32
  private selfTransform: Mat33|null = null;
33
+ private transformStack: Array<Mat33|null> = [];
31
34
 
32
35
  protected constructor(private viewport: Viewport) { }
33
36
 
@@ -50,6 +53,7 @@ export default abstract class AbstractRenderer {
50
53
  protected abstract traceQuadraticBezierCurve(
51
54
  controlPoint: Point2, endPoint: Point2,
52
55
  ): void;
56
+ public abstract drawText(text: string, transform: Mat33, style: TextStyle): void;
53
57
 
54
58
  // Returns true iff the given rectangle is so small, rendering anything within
55
59
  // it has no effect on the image.
@@ -128,7 +132,7 @@ export default abstract class AbstractRenderer {
128
132
  this.objectLevel ++;
129
133
  }
130
134
 
131
- public endObject() {
135
+ public endObject(_loaderData?: LoadSaveDataTable) {
132
136
  // Render the paths all at once
133
137
  this.flushPath();
134
138
  this.currentPaths = null;
@@ -165,6 +169,19 @@ export default abstract class AbstractRenderer {
165
169
  this.selfTransform = transform;
166
170
  }
167
171
 
172
+ public pushTransform(transform: Mat33) {
173
+ this.transformStack.push(this.selfTransform);
174
+ this.setTransform(this.getCanvasToScreenTransform().rightMul(transform));
175
+ }
176
+
177
+ public popTransform() {
178
+ if (this.transformStack.length === 0) {
179
+ throw new Error('Unable to pop more transforms than have been pushed!');
180
+ }
181
+
182
+ this.setTransform(this.transformStack.pop() ?? null);
183
+ }
184
+
168
185
  // Get the matrix that transforms a vector on the canvas to a vector on this'
169
186
  // rendering target.
170
187
  public getCanvasToScreenTransform(): Mat33 {