js-draw 0.1.2 → 0.1.3

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 (60) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +2 -2
  3. package/dist/bundle.js +1 -1
  4. package/dist/src/Editor.js +2 -3
  5. package/dist/src/SVGLoader.d.ts +8 -0
  6. package/dist/src/SVGLoader.js +105 -6
  7. package/dist/src/Viewport.d.ts +1 -1
  8. package/dist/src/Viewport.js +2 -2
  9. package/dist/src/components/SVGGlobalAttributesObject.js +0 -1
  10. package/dist/src/components/Text.d.ts +30 -0
  11. package/dist/src/components/Text.js +109 -0
  12. package/dist/src/components/localization.d.ts +1 -0
  13. package/dist/src/components/localization.js +1 -0
  14. package/dist/src/geometry/Mat33.d.ts +1 -0
  15. package/dist/src/geometry/Mat33.js +30 -0
  16. package/dist/src/geometry/Path.js +8 -1
  17. package/dist/src/geometry/Rect2.d.ts +2 -0
  18. package/dist/src/geometry/Rect2.js +6 -0
  19. package/dist/src/rendering/renderers/AbstractRenderer.d.ts +5 -0
  20. package/dist/src/rendering/renderers/AbstractRenderer.js +12 -0
  21. package/dist/src/rendering/renderers/CanvasRenderer.d.ts +3 -0
  22. package/dist/src/rendering/renderers/CanvasRenderer.js +28 -8
  23. package/dist/src/rendering/renderers/DummyRenderer.d.ts +3 -0
  24. package/dist/src/rendering/renderers/DummyRenderer.js +5 -0
  25. package/dist/src/rendering/renderers/SVGRenderer.d.ts +3 -0
  26. package/dist/src/rendering/renderers/SVGRenderer.js +30 -1
  27. package/dist/src/testing/loadExpectExtensions.js +1 -4
  28. package/dist/src/toolbar/HTMLToolbar.js +52 -1
  29. package/dist/src/toolbar/icons.d.ts +2 -0
  30. package/dist/src/toolbar/icons.js +17 -0
  31. package/dist/src/tools/SelectionTool.js +1 -1
  32. package/dist/src/tools/TextTool.d.ts +29 -0
  33. package/dist/src/tools/TextTool.js +154 -0
  34. package/dist/src/tools/ToolController.d.ts +2 -1
  35. package/dist/src/tools/ToolController.js +4 -1
  36. package/dist/src/tools/localization.d.ts +3 -1
  37. package/dist/src/tools/localization.js +3 -1
  38. package/package.json +1 -1
  39. package/src/Editor.ts +3 -3
  40. package/src/SVGLoader.ts +124 -6
  41. package/src/Viewport.ts +2 -2
  42. package/src/components/SVGGlobalAttributesObject.ts +0 -1
  43. package/src/components/Text.ts +136 -0
  44. package/src/components/localization.ts +2 -0
  45. package/src/geometry/Mat33.test.ts +44 -0
  46. package/src/geometry/Mat33.ts +41 -0
  47. package/src/geometry/Path.toString.test.ts +7 -3
  48. package/src/geometry/Path.ts +11 -1
  49. package/src/geometry/Rect2.ts +8 -0
  50. package/src/rendering/renderers/AbstractRenderer.ts +16 -0
  51. package/src/rendering/renderers/CanvasRenderer.ts +34 -10
  52. package/src/rendering/renderers/DummyRenderer.ts +8 -0
  53. package/src/rendering/renderers/SVGRenderer.ts +36 -1
  54. package/src/testing/loadExpectExtensions.ts +1 -4
  55. package/src/toolbar/HTMLToolbar.ts +64 -1
  56. package/src/toolbar/icons.ts +23 -0
  57. package/src/tools/SelectionTool.ts +1 -1
  58. package/src/tools/TextTool.ts +206 -0
  59. package/src/tools/ToolController.ts +4 -0
  60. package/src/tools/localization.ts +7 -2
package/src/SVGLoader.ts CHANGED
@@ -2,9 +2,12 @@ import Color4 from './Color4';
2
2
  import AbstractComponent from './components/AbstractComponent';
3
3
  import Stroke from './components/Stroke';
4
4
  import SVGGlobalAttributesObject from './components/SVGGlobalAttributesObject';
5
+ import Text, { TextStyle } from './components/Text';
5
6
  import UnknownSVGObject from './components/UnknownSVGObject';
7
+ import Mat33 from './geometry/Mat33';
6
8
  import Path from './geometry/Path';
7
9
  import Rect2 from './geometry/Rect2';
10
+ import { Vec2 } from './geometry/Vec2';
8
11
  import { RenderablePathSpec, RenderingStyle } from './rendering/renderers/AbstractRenderer';
9
12
  import { ComponentAddedListener, ImageLoader, OnDetermineExportRectListener, OnProgressListener } from './types';
10
13
 
@@ -15,10 +18,14 @@ export const defaultSVGViewRect = new Rect2(0, 0, 500, 500);
15
18
 
16
19
  // Key to retrieve unrecognised attributes from an AbstractComponent
17
20
  export const svgAttributesDataKey = 'svgAttrs';
21
+ export const svgStyleAttributesDataKey = 'svgStyleAttrs';
18
22
 
19
23
  // [key, value]
20
24
  export type SVGLoaderUnknownAttribute = [ string, string ];
21
25
 
26
+ // [key, value, priority]
27
+ export type SVGLoaderUnknownStyleAttribute = { key: string, value: string, priority?: string };
28
+
22
29
  export default class SVGLoader implements ImageLoader {
23
30
  private onAddComponent: ComponentAddedListener|null = null;
24
31
  private onProgress: OnProgressListener|null = null;
@@ -97,10 +104,11 @@ export default class SVGLoader implements ImageLoader {
97
104
  private attachUnrecognisedAttrs(
98
105
  elem: AbstractComponent,
99
106
  node: SVGElement,
100
- supportedAttrs: Set<string>
107
+ supportedAttrs: Set<string>,
108
+ supportedStyleAttrs?: Set<string>
101
109
  ) {
102
110
  for (const attr of node.getAttributeNames()) {
103
- if (supportedAttrs.has(attr)) {
111
+ if (supportedAttrs.has(attr) || (attr === 'style' && supportedStyleAttrs)) {
104
112
  continue;
105
113
  }
106
114
 
@@ -108,6 +116,27 @@ export default class SVGLoader implements ImageLoader {
108
116
  [ attr, node.getAttribute(attr) ] as SVGLoaderUnknownAttribute,
109
117
  );
110
118
  }
119
+
120
+ if (supportedStyleAttrs) {
121
+ for (const attr of node.style) {
122
+ if (attr === '' || !attr) {
123
+ continue;
124
+ }
125
+
126
+ if (supportedStyleAttrs.has(attr)) {
127
+ continue;
128
+ }
129
+
130
+ // TODO: Do we need special logic for !important properties?
131
+ elem.attachLoadSaveData(svgStyleAttributesDataKey,
132
+ {
133
+ key: attr,
134
+ value: node.style.getPropertyValue(attr),
135
+ priority: node.style.getPropertyPriority(attr)
136
+ } as SVGLoaderUnknownStyleAttribute
137
+ );
138
+ }
139
+ }
111
140
  }
112
141
 
113
142
  // Adds a stroke with a single path
@@ -115,9 +144,14 @@ export default class SVGLoader implements ImageLoader {
115
144
  let elem: AbstractComponent;
116
145
  try {
117
146
  const strokeData = this.strokeDataFromElem(node);
147
+
118
148
  elem = new Stroke(strokeData);
149
+
150
+ const supportedStyleAttrs = [ 'stroke', 'fill', 'stroke-width' ];
119
151
  this.attachUnrecognisedAttrs(
120
- elem, node, new Set([ 'stroke', 'fill', 'stroke-width', 'd' ]),
152
+ elem, node,
153
+ new Set([ ...supportedStyleAttrs, 'd' ]),
154
+ new Set(supportedStyleAttrs)
121
155
  );
122
156
  } catch (e) {
123
157
  console.error(
@@ -131,6 +165,80 @@ export default class SVGLoader implements ImageLoader {
131
165
  this.onAddComponent?.(elem);
132
166
  }
133
167
 
168
+ private makeText(elem: SVGTextElement|SVGTSpanElement): Text {
169
+ const contentList: Array<Text|string> = [];
170
+ for (const child of elem.childNodes) {
171
+ if (child.nodeType === Node.TEXT_NODE) {
172
+ contentList.push(child.nodeValue ?? '');
173
+ } else if (child.nodeType === Node.ELEMENT_NODE) {
174
+ const subElem = child as SVGElement;
175
+ if (subElem.tagName.toLowerCase() === 'tspan') {
176
+ contentList.push(this.makeText(subElem as SVGTSpanElement));
177
+ } else {
178
+ throw new Error(`Unrecognized text child element: ${subElem}`);
179
+ }
180
+ } else {
181
+ throw new Error(`Unrecognized text child node: ${child}.`);
182
+ }
183
+ }
184
+
185
+ // Compute styles.
186
+ const computedStyles = window.getComputedStyle(elem);
187
+ const fontSizeMatch = /^([-0-9.e]+)px/i.exec(computedStyles.fontSize);
188
+
189
+ const supportedStyleAttrs = [
190
+ 'fontFamily',
191
+ 'fill',
192
+ 'transform'
193
+ ];
194
+ let fontSize = 12;
195
+ if (fontSizeMatch) {
196
+ supportedStyleAttrs.push('fontSize');
197
+ fontSize = parseFloat(fontSizeMatch[1]);
198
+ }
199
+ const style: TextStyle = {
200
+ size: fontSize,
201
+ fontFamily: computedStyles.fontFamily || 'sans',
202
+ renderingStyle: {
203
+ fill: Color4.fromString(computedStyles.fill)
204
+ },
205
+ };
206
+
207
+ // Compute transform matrix
208
+ let transform = Mat33.fromCSSMatrix(computedStyles.transform);
209
+ const supportedAttrs = [];
210
+ const elemX = elem.getAttribute('x');
211
+ const elemY = elem.getAttribute('y');
212
+ if (elemX && elemY) {
213
+ const x = parseFloat(elemX);
214
+ const y = parseFloat(elemY);
215
+ if (!isNaN(x) && !isNaN(y)) {
216
+ supportedAttrs.push('x', 'y');
217
+ transform = transform.rightMul(Mat33.translation(Vec2.of(x, y)));
218
+ }
219
+ }
220
+
221
+ const result = new Text(contentList, transform, style);
222
+ this.attachUnrecognisedAttrs(
223
+ result,
224
+ elem,
225
+ new Set(supportedAttrs),
226
+ new Set(supportedStyleAttrs)
227
+ );
228
+
229
+ return result;
230
+ }
231
+
232
+ private addText(elem: SVGTextElement|SVGTSpanElement) {
233
+ try {
234
+ const textElem = this.makeText(elem);
235
+ this.onAddComponent?.(textElem);
236
+ } catch (e) {
237
+ console.error('Invalid text object in node', elem, '. Adding as an unknown object. Error:', e);
238
+ this.addUnknownNode(elem);
239
+ }
240
+ }
241
+
134
242
  private addUnknownNode(node: SVGElement) {
135
243
  const component = new UnknownSVGObject(node);
136
244
  this.onAddComponent?.(component);
@@ -142,13 +250,14 @@ export default class SVGLoader implements ImageLoader {
142
250
  return;
143
251
  }
144
252
 
145
- const components = viewBoxAttr.split(/[ \t,]/);
253
+ const components = viewBoxAttr.split(/[ \t\n,]+/);
146
254
  const x = parseFloat(components[0]);
147
255
  const y = parseFloat(components[1]);
148
256
  const width = parseFloat(components[2]);
149
257
  const height = parseFloat(components[3]);
150
258
 
151
259
  if (isNaN(x) || isNaN(y) || isNaN(width) || isNaN(height)) {
260
+ console.warn(`node ${node} has an unparsable viewbox. Viewbox: ${viewBoxAttr}. Match: ${components}.`);
152
261
  return;
153
262
  }
154
263
 
@@ -162,6 +271,7 @@ export default class SVGLoader implements ImageLoader {
162
271
 
163
272
  private async visit(node: Element) {
164
273
  this.totalToProcess += node.childElementCount;
274
+ let visitChildren = true;
165
275
 
166
276
  switch (node.tagName.toLowerCase()) {
167
277
  case 'g':
@@ -170,6 +280,10 @@ export default class SVGLoader implements ImageLoader {
170
280
  case 'path':
171
281
  this.addPath(node as SVGPathElement);
172
282
  break;
283
+ case 'text':
284
+ this.addText(node as SVGTextElement);
285
+ visitChildren = false;
286
+ break;
173
287
  case 'svg':
174
288
  this.updateViewBox(node as SVGSVGElement);
175
289
  this.updateSVGAttrs(node as SVGSVGElement);
@@ -184,8 +298,10 @@ export default class SVGLoader implements ImageLoader {
184
298
  return;
185
299
  }
186
300
 
187
- for (const child of node.children) {
188
- await this.visit(child);
301
+ if (visitChildren) {
302
+ for (const child of node.children) {
303
+ await this.visit(child);
304
+ }
189
305
  }
190
306
 
191
307
  this.processedCount ++;
@@ -265,8 +381,10 @@ export default class SVGLoader implements ImageLoader {
265
381
  'http://www.w3.org/2000/svg', 'svg'
266
382
  );
267
383
  svgElem.innerHTML = text;
384
+ sandboxDoc.body.appendChild(svgElem);
268
385
 
269
386
  return new SVGLoader(svgElem, () => {
387
+ svgElem.remove();
270
388
  sandbox.remove();
271
389
  });
272
390
  }
package/src/Viewport.ts CHANGED
@@ -170,7 +170,7 @@ export class Viewport {
170
170
  // Returns a Command that transforms the view such that [rect] is visible, and perhaps
171
171
  // centered in the viewport.
172
172
  // Returns null if no transformation is necessary
173
- public zoomTo(toMakeVisible: Rect2): Command {
173
+ public zoomTo(toMakeVisible: Rect2, allowZoomIn: boolean = true, allowZoomOut: boolean = true): Command {
174
174
  let transform = Mat33.identity;
175
175
 
176
176
  if (toMakeVisible.w === 0 || toMakeVisible.h === 0) {
@@ -195,7 +195,7 @@ export class Viewport {
195
195
  // Ensure that toMakeVisible is at least 1/8th of the visible region.
196
196
  const muchSmallerThanTarget = toMakeVisible.maxDimension / targetRect.maxDimension < 0.125;
197
197
 
198
- if (largerThanTarget || muchSmallerThanTarget) {
198
+ if ((largerThanTarget && allowZoomOut) || (muchSmallerThanTarget && allowZoomIn)) {
199
199
  // If larger than the target, ensure that the longest axis is visible.
200
200
  // If smaller, shrink the visible rectangle as much as possible
201
201
  const multiplier = (largerThanTarget ? Math.max : Math.min)(
@@ -20,7 +20,6 @@ export default class SVGGlobalAttributesObject extends AbstractComponent {
20
20
  return;
21
21
  }
22
22
 
23
- console.log('Rendering to SVG.', this.attrs);
24
23
  for (const [ attr, value ] of this.attrs) {
25
24
  canvas.setRootSVGAttribute(attr, value);
26
25
  }
@@ -0,0 +1,136 @@
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
+ ctx.font = [
27
+ (style.size ?? 12) + 'px',
28
+ style.fontWeight ?? '',
29
+ `"${style.fontFamily.replace(/["]/g, '\\"')}"`,
30
+ style.fontWeight
31
+ ].join(' ');
32
+ ctx.textAlign = 'left';
33
+ }
34
+
35
+ private static textMeasuringCtx: CanvasRenderingContext2D;
36
+ private static getTextDimens(text: string, style: TextStyle): Rect2 {
37
+ Text.textMeasuringCtx ??= document.createElement('canvas').getContext('2d')!;
38
+ const ctx = Text.textMeasuringCtx;
39
+ Text.applyTextStyles(ctx, style);
40
+
41
+ const measure = ctx.measureText(text);
42
+
43
+ // Text is drawn with (0,0) at the bottom left of the baseline.
44
+ const textY = -measure.actualBoundingBoxAscent;
45
+ const textHeight = measure.actualBoundingBoxAscent + measure.actualBoundingBoxDescent;
46
+ return new Rect2(0, textY, measure.width, textHeight);
47
+ }
48
+
49
+ private computeBBoxOfPart(part: string|Text) {
50
+ if (typeof part === 'string') {
51
+ const textBBox = Text.getTextDimens(part, this.style);
52
+ return textBBox.transformedBoundingBox(this.transform);
53
+ } else {
54
+ const bbox = part.contentBBox.transformedBoundingBox(this.transform);
55
+ return bbox;
56
+ }
57
+ }
58
+
59
+ private recomputeBBox() {
60
+ let bbox: Rect2|null = null;
61
+
62
+ for (const textObject of this.textObjects) {
63
+ const currentBBox = this.computeBBoxOfPart(textObject);
64
+ bbox ??= currentBBox;
65
+ bbox = bbox.union(currentBBox);
66
+ }
67
+
68
+ this.contentBBox = bbox ?? Rect2.empty;
69
+ }
70
+
71
+ public render(canvas: AbstractRenderer, _visibleRect?: Rect2): void {
72
+ const cursor = this.transform;
73
+
74
+ canvas.startObject(this.contentBBox);
75
+ for (const textObject of this.textObjects) {
76
+ if (typeof textObject === 'string') {
77
+ canvas.drawText(textObject, cursor, this.style);
78
+ } else {
79
+ canvas.pushTransform(cursor);
80
+ textObject.render(canvas);
81
+ canvas.popTransform();
82
+ }
83
+ }
84
+ canvas.endObject(this.getLoadSaveData());
85
+ }
86
+
87
+ public intersects(lineSegment: LineSegment2): boolean {
88
+
89
+ // Convert canvas space to internal space.
90
+ const invTransform = this.transform.inverse();
91
+ const p1InThisSpace = invTransform.transformVec2(lineSegment.p1);
92
+ const p2InThisSpace = invTransform.transformVec2(lineSegment.p2);
93
+ lineSegment = new LineSegment2(p1InThisSpace, p2InThisSpace);
94
+
95
+ for (const subObject of this.textObjects) {
96
+ if (typeof subObject === 'string') {
97
+ const textBBox = Text.getTextDimens(subObject, this.style);
98
+
99
+ // TODO: Use a better intersection check. Perhaps draw the text onto a CanvasElement and
100
+ // use pixel-testing to check for intersection with its contour.
101
+ if (textBBox.getEdges().some(edge => lineSegment.intersection(edge) !== null)) {
102
+ return true;
103
+ }
104
+ } else {
105
+ if (subObject.intersects(lineSegment)) {
106
+ return true;
107
+ }
108
+ }
109
+ }
110
+
111
+ return false;
112
+ }
113
+
114
+ protected applyTransformation(affineTransfm: Mat33): void {
115
+ this.transform = affineTransfm.rightMul(this.transform);
116
+ this.recomputeBBox();
117
+ }
118
+
119
+ private getText() {
120
+ const result: string[] = [];
121
+
122
+ for (const textObject of this.textObjects) {
123
+ if (typeof textObject === 'string') {
124
+ result.push(textObject);
125
+ } else {
126
+ result.push(textObject.getText());
127
+ }
128
+ }
129
+
130
+ return result.join(' ');
131
+ }
132
+
133
+ public description(localizationTable: ImageComponentLocalization): string {
134
+ return localizationTable.text(this.getText());
135
+ }
136
+ }
@@ -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
  }
@@ -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(/[.]$/, '');
@@ -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,5 +1,6 @@
1
1
  import Color4 from '../../Color4';
2
2
  import { LoadSaveDataTable } from '../../components/AbstractComponent';
3
+ import { TextStyle } from '../../components/Text';
3
4
  import Mat33 from '../../geometry/Mat33';
4
5
  import Path, { PathCommand, PathCommandType } from '../../geometry/Path';
5
6
  import Rect2 from '../../geometry/Rect2';
@@ -29,6 +30,7 @@ const stylesEqual = (a: RenderingStyle, b: RenderingStyle) => {
29
30
  export default abstract class AbstractRenderer {
30
31
  // If null, this' transformation is linked to the Viewport
31
32
  private selfTransform: Mat33|null = null;
33
+ private transformStack: Array<Mat33|null> = [];
32
34
 
33
35
  protected constructor(private viewport: Viewport) { }
34
36
 
@@ -51,6 +53,7 @@ export default abstract class AbstractRenderer {
51
53
  protected abstract traceQuadraticBezierCurve(
52
54
  controlPoint: Point2, endPoint: Point2,
53
55
  ): void;
56
+ public abstract drawText(text: string, transform: Mat33, style: TextStyle): void;
54
57
 
55
58
  // Returns true iff the given rectangle is so small, rendering anything within
56
59
  // it has no effect on the image.
@@ -166,6 +169,19 @@ export default abstract class AbstractRenderer {
166
169
  this.selfTransform = transform;
167
170
  }
168
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
+
169
185
  // Get the matrix that transforms a vector on the canvas to a vector on this'
170
186
  // rendering target.
171
187
  public getCanvasToScreenTransform(): Mat33 {
@@ -1,4 +1,5 @@
1
1
  import Color4 from '../../Color4';
2
+ import Text, { TextStyle } from '../../components/Text';
2
3
  import Mat33 from '../../geometry/Mat33';
3
4
  import Rect2 from '../../geometry/Rect2';
4
5
  import { Point2, Vec2 } from '../../geometry/Vec2';
@@ -26,16 +27,7 @@ export default class CanvasRenderer extends AbstractRenderer {
26
27
  this.setDraftMode(false);
27
28
  }
28
29
 
29
- public canRenderFromWithoutDataLoss(other: AbstractRenderer) {
30
- return other instanceof CanvasRenderer;
31
- }
32
-
33
- public renderFromOtherOfSameType(transformBy: Mat33, other: AbstractRenderer): void {
34
- if (!(other instanceof CanvasRenderer)) {
35
- throw new Error(`${other} cannot be rendered onto ${this}`);
36
- }
37
- transformBy = this.getCanvasToScreenTransform().rightMul(transformBy);
38
- this.ctx.save();
30
+ private transformBy(transformBy: Mat33) {
39
31
  // From MDN, transform(a,b,c,d,e,f)
40
32
  // takes input such that
41
33
  // ⎡ a c e ⎤
@@ -46,6 +38,19 @@ export default class CanvasRenderer extends AbstractRenderer {
46
38
  transformBy.a2, transformBy.b2, // c, d
47
39
  transformBy.a3, transformBy.b3, // e, f
48
40
  );
41
+ }
42
+
43
+ public canRenderFromWithoutDataLoss(other: AbstractRenderer) {
44
+ return other instanceof CanvasRenderer;
45
+ }
46
+
47
+ public renderFromOtherOfSameType(transformBy: Mat33, other: AbstractRenderer): void {
48
+ if (!(other instanceof CanvasRenderer)) {
49
+ throw new Error(`${other} cannot be rendered onto ${this}`);
50
+ }
51
+ transformBy = this.getCanvasToScreenTransform().rightMul(transformBy);
52
+ this.ctx.save();
53
+ this.transformBy(transformBy);
49
54
  this.ctx.drawImage(other.ctx.canvas, 0, 0);
50
55
  this.ctx.restore();
51
56
  }
@@ -143,6 +148,25 @@ export default class CanvasRenderer extends AbstractRenderer {
143
148
  super.drawPath(path);
144
149
  }
145
150
 
151
+ public drawText(text: string, transform: Mat33, style: TextStyle): void {
152
+ this.ctx.save();
153
+ transform = this.getCanvasToScreenTransform().rightMul(transform);
154
+ this.transformBy(transform);
155
+ Text.applyTextStyles(this.ctx, style);
156
+
157
+ if (style.renderingStyle.fill.a !== 0) {
158
+ this.ctx.fillStyle = style.renderingStyle.fill.toHexString();
159
+ this.ctx.fillText(text, 0, 0);
160
+ }
161
+ if (style.renderingStyle.stroke) {
162
+ this.ctx.strokeStyle = style.renderingStyle.stroke.color.toHexString();
163
+ this.ctx.lineWidth = style.renderingStyle.stroke.width;
164
+ this.ctx.strokeText(text, 0, 0);
165
+ }
166
+
167
+ this.ctx.restore();
168
+ }
169
+
146
170
  private clipLevels: number[] = [];
147
171
  public startObject(boundingBox: Rect2, clip: boolean) {
148
172
  if (this.isTooSmallToRender(boundingBox)) {