js-draw 0.1.2 → 0.1.5

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 (79) hide show
  1. package/CHANGELOG.md +14 -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 +20 -6
  6. package/dist/src/SVGLoader.d.ts +8 -0
  7. package/dist/src/SVGLoader.js +105 -6
  8. package/dist/src/Viewport.d.ts +1 -1
  9. package/dist/src/Viewport.js +5 -5
  10. package/dist/src/components/SVGGlobalAttributesObject.js +0 -1
  11. package/dist/src/components/Text.d.ts +30 -0
  12. package/dist/src/components/Text.js +111 -0
  13. package/dist/src/components/localization.d.ts +1 -0
  14. package/dist/src/components/localization.js +1 -0
  15. package/dist/src/geometry/Mat33.d.ts +1 -0
  16. package/dist/src/geometry/Mat33.js +30 -0
  17. package/dist/src/geometry/Path.js +8 -1
  18. package/dist/src/geometry/Rect2.d.ts +2 -0
  19. package/dist/src/geometry/Rect2.js +6 -0
  20. package/dist/src/localization.d.ts +2 -1
  21. package/dist/src/localization.js +2 -1
  22. package/dist/src/rendering/Display.d.ts +2 -0
  23. package/dist/src/rendering/Display.js +19 -0
  24. package/dist/src/rendering/localization.d.ts +5 -0
  25. package/dist/src/rendering/localization.js +4 -0
  26. package/dist/src/rendering/renderers/AbstractRenderer.d.ts +5 -0
  27. package/dist/src/rendering/renderers/AbstractRenderer.js +12 -0
  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 +3 -0
  33. package/dist/src/rendering/renderers/SVGRenderer.js +30 -1
  34. package/dist/src/rendering/renderers/TextOnlyRenderer.d.ts +24 -0
  35. package/dist/src/rendering/renderers/TextOnlyRenderer.js +40 -0
  36. package/dist/src/testing/loadExpectExtensions.js +1 -4
  37. package/dist/src/toolbar/HTMLToolbar.js +78 -1
  38. package/dist/src/toolbar/icons.d.ts +2 -0
  39. package/dist/src/toolbar/icons.js +18 -0
  40. package/dist/src/toolbar/localization.d.ts +1 -0
  41. package/dist/src/toolbar/localization.js +1 -0
  42. package/dist/src/tools/SelectionTool.js +1 -1
  43. package/dist/src/tools/TextTool.d.ts +31 -0
  44. package/dist/src/tools/TextTool.js +174 -0
  45. package/dist/src/tools/ToolController.d.ts +2 -1
  46. package/dist/src/tools/ToolController.js +4 -1
  47. package/dist/src/tools/localization.d.ts +3 -1
  48. package/dist/src/tools/localization.js +3 -1
  49. package/dist-test/test-dist-bundle.html +8 -1
  50. package/package.json +1 -1
  51. package/src/Editor.css +12 -0
  52. package/src/Editor.ts +22 -7
  53. package/src/SVGLoader.ts +124 -6
  54. package/src/Viewport.ts +5 -5
  55. package/src/components/SVGGlobalAttributesObject.ts +0 -1
  56. package/src/components/Text.ts +140 -0
  57. package/src/components/localization.ts +2 -0
  58. package/src/geometry/Mat33.test.ts +44 -0
  59. package/src/geometry/Mat33.ts +41 -0
  60. package/src/geometry/Path.toString.test.ts +7 -3
  61. package/src/geometry/Path.ts +11 -1
  62. package/src/geometry/Rect2.ts +8 -0
  63. package/src/localization.ts +3 -1
  64. package/src/rendering/Display.ts +26 -0
  65. package/src/rendering/localization.ts +10 -0
  66. package/src/rendering/renderers/AbstractRenderer.ts +16 -0
  67. package/src/rendering/renderers/CanvasRenderer.ts +34 -10
  68. package/src/rendering/renderers/DummyRenderer.ts +8 -0
  69. package/src/rendering/renderers/SVGRenderer.ts +36 -1
  70. package/src/rendering/renderers/TextOnlyRenderer.ts +51 -0
  71. package/src/testing/loadExpectExtensions.ts +1 -4
  72. package/src/toolbar/HTMLToolbar.ts +96 -1
  73. package/src/toolbar/icons.ts +24 -0
  74. package/src/toolbar/localization.ts +2 -0
  75. package/src/toolbar/toolbar.css +6 -3
  76. package/src/tools/SelectionTool.ts +1 -1
  77. package/src/tools/TextTool.ts +229 -0
  78. package/src/tools/ToolController.ts +4 -0
  79. package/src/tools/localization.ts +7 -2
@@ -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
  }
@@ -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,10 +1,11 @@
1
1
  import { CommandLocalization, defaultCommandLocalization } from './commands/localization';
2
2
  import { defaultComponentLocalization, ImageComponentLocalization } from './components/localization';
3
+ import { defaultTextRendererLocalization, TextRendererLocalization } from './rendering/localization';
3
4
  import { defaultToolbarLocalization, ToolbarLocalization } from './toolbar/localization';
4
5
  import { defaultToolLocalization, ToolLocalization } from './tools/localization';
5
6
 
6
7
 
7
- export interface EditorLocalization extends ToolbarLocalization, ToolLocalization, CommandLocalization, ImageComponentLocalization {
8
+ export interface EditorLocalization extends ToolbarLocalization, ToolLocalization, CommandLocalization, ImageComponentLocalization, TextRendererLocalization {
8
9
  undoAnnouncement: (actionDescription: string)=> string;
9
10
  redoAnnouncement: (actionDescription: string)=> string;
10
11
  doneLoading: string;
@@ -17,6 +18,7 @@ export const defaultEditorLocalization: EditorLocalization = {
17
18
  ...defaultToolLocalization,
18
19
  ...defaultCommandLocalization,
19
20
  ...defaultComponentLocalization,
21
+ ...defaultTextRendererLocalization,
20
22
  loading: (percentage: number) => `Loading ${percentage}%...`,
21
23
  imageEditor: 'Image Editor',
22
24
  doneLoading: 'Done loading',
@@ -5,6 +5,7 @@ import { EditorEventType } from '../types';
5
5
  import DummyRenderer from './renderers/DummyRenderer';
6
6
  import { Vec2 } from '../geometry/Vec2';
7
7
  import RenderingCache from './caching/RenderingCache';
8
+ import TextOnlyRenderer from './renderers/TextOnlyRenderer';
8
9
 
9
10
  export enum RenderingMode {
10
11
  DummyRenderer,
@@ -15,6 +16,7 @@ export enum RenderingMode {
15
16
  export default class Display {
16
17
  private dryInkRenderer: AbstractRenderer;
17
18
  private wetInkRenderer: AbstractRenderer;
19
+ private textRenderer: TextOnlyRenderer;
18
20
  private cache: RenderingCache;
19
21
  private resizeSurfacesCallback?: ()=> void;
20
22
  private flattenCallback?: ()=> void;
@@ -31,6 +33,9 @@ export default class Display {
31
33
  throw new Error(`Unknown rendering mode, ${mode}!`);
32
34
  }
33
35
 
36
+ this.textRenderer = new TextOnlyRenderer(editor.viewport, editor.localization);
37
+ this.initializeTextRendering();
38
+
34
39
  const cacheBlockResolution = Vec2.of(600, 600);
35
40
  this.cache = new RenderingCache({
36
41
  createRenderer: () => {
@@ -129,6 +134,27 @@ export default class Display {
129
134
  };
130
135
  }
131
136
 
137
+ private initializeTextRendering() {
138
+ const textRendererOutputContainer = document.createElement('div');
139
+ textRendererOutputContainer.classList.add('textRendererOutputContainer');
140
+
141
+ const rerenderButton = document.createElement('button');
142
+ rerenderButton.classList.add('rerenderButton');
143
+ rerenderButton.innerText = this.editor.localization.rerenderAsText;
144
+
145
+ const rerenderOutput = document.createElement('div');
146
+ rerenderOutput.ariaLive = 'polite';
147
+
148
+ rerenderButton.onclick = () => {
149
+ this.textRenderer.clear();
150
+ this.editor.image.render(this.textRenderer, this.editor.viewport);
151
+ rerenderOutput.innerText = this.textRenderer.getDescription();
152
+ };
153
+
154
+ textRendererOutputContainer.replaceChildren(rerenderButton, rerenderOutput);
155
+ this.editor.createHTMLOverlay(textRendererOutputContainer);
156
+ }
157
+
132
158
  // Clears the drawing surfaces and otherwise prepares for a rerender.
133
159
  public startRerender(): AbstractRenderer {
134
160
  this.resizeSurfacesCallback?.();
@@ -0,0 +1,10 @@
1
+
2
+ export interface TextRendererLocalization {
3
+ textNode(content: string): string;
4
+ rerenderAsText: string;
5
+ }
6
+
7
+ export const defaultTextRendererLocalization: TextRendererLocalization = {
8
+ textNode: (content: string) => `Text: ${content}`,
9
+ rerenderAsText: 'Re-render as text',
10
+ };
@@ -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)) {
@@ -1,5 +1,6 @@
1
1
  // Renderer that outputs nothing. Useful for automated tests.
2
2
 
3
+ import { TextStyle } from '../../components/Text';
3
4
  import Mat33 from '../../geometry/Mat33';
4
5
  import Rect2 from '../../geometry/Rect2';
5
6
  import { Point2, Vec2 } from '../../geometry/Vec2';
@@ -14,6 +15,7 @@ export default class DummyRenderer extends AbstractRenderer {
14
15
  public lastFillStyle: RenderingStyle|null = null;
15
16
  public lastPoint: Point2|null = null;
16
17
  public objectNestingLevel: number = 0;
18
+ public lastText: string|null = null;
17
19
 
18
20
  // List of points drawn since the last clear.
19
21
  public pointBuffer: Point2[] = [];
@@ -40,6 +42,7 @@ export default class DummyRenderer extends AbstractRenderer {
40
42
  this.clearedCount ++;
41
43
  this.renderedPathCount = 0;
42
44
  this.pointBuffer = [];
45
+ this.lastText = null;
43
46
 
44
47
  // Ensure all objects finished rendering
45
48
  if (this.objectNestingLevel > 0) {
@@ -88,6 +91,11 @@ export default class DummyRenderer extends AbstractRenderer {
88
91
  // As such, it is unlikely to be the target of automated tests.
89
92
  }
90
93
 
94
+
95
+ public drawText(text: string, _transform: Mat33, _style: TextStyle): void {
96
+ this.lastText = text;
97
+ }
98
+
91
99
  public startObject(boundingBox: Rect2, _clip: boolean) {
92
100
  super.startObject(boundingBox);
93
101
 
@@ -1,9 +1,11 @@
1
1
 
2
2
  import { LoadSaveDataTable } from '../../components/AbstractComponent';
3
+ import { TextStyle } from '../../components/Text';
4
+ import Mat33 from '../../geometry/Mat33';
3
5
  import Path, { PathCommand, PathCommandType } from '../../geometry/Path';
4
6
  import Rect2 from '../../geometry/Rect2';
5
7
  import { Point2, Vec2 } from '../../geometry/Vec2';
6
- import { svgAttributesDataKey, SVGLoaderUnknownAttribute } from '../../SVGLoader';
8
+ import { svgAttributesDataKey, SVGLoaderUnknownAttribute, SVGLoaderUnknownStyleAttribute, svgStyleAttributesDataKey } from '../../SVGLoader';
7
9
  import Viewport from '../../Viewport';
8
10
  import AbstractRenderer, { RenderingStyle } from './AbstractRenderer';
9
11
 
@@ -107,6 +109,32 @@ export default class SVGRenderer extends AbstractRenderer {
107
109
  this.objectElems?.push(pathElem);
108
110
  }
109
111
 
112
+ public drawText(text: string, transform: Mat33, style: TextStyle): void {
113
+ transform = this.getCanvasToScreenTransform().rightMul(transform);
114
+
115
+ const textElem = document.createElementNS(svgNameSpace, 'text');
116
+ textElem.appendChild(document.createTextNode(text));
117
+ textElem.style.transform = `matrix(
118
+ ${transform.a1}, ${transform.b1},
119
+ ${transform.a2}, ${transform.b2},
120
+ ${transform.a3}, ${transform.b3}
121
+ )`;
122
+ textElem.style.fontFamily = style.fontFamily;
123
+ textElem.style.fontVariant = style.fontVariant ?? '';
124
+ textElem.style.fontWeight = style.fontWeight ?? '';
125
+ textElem.style.fontSize = style.size + 'px';
126
+ textElem.style.fill = style.renderingStyle.fill.toHexString();
127
+
128
+ if (style.renderingStyle.stroke) {
129
+ const strokeStyle = style.renderingStyle.stroke;
130
+ textElem.style.stroke = strokeStyle.color.toHexString();
131
+ textElem.style.strokeWidth = strokeStyle.width + 'px';
132
+ }
133
+
134
+ this.elem.appendChild(textElem);
135
+ this.objectElems?.push(textElem);
136
+ }
137
+
110
138
  public startObject(boundingBox: Rect2) {
111
139
  super.startObject(boundingBox);
112
140
 
@@ -127,12 +155,19 @@ export default class SVGRenderer extends AbstractRenderer {
127
155
  // Restore any attributes unsupported by the app.
128
156
  for (const elem of this.objectElems ?? []) {
129
157
  const attrs = loaderData[svgAttributesDataKey] as SVGLoaderUnknownAttribute[]|undefined;
158
+ const styleAttrs = loaderData[svgStyleAttributesDataKey] as SVGLoaderUnknownStyleAttribute[]|undefined;
130
159
 
131
160
  if (attrs) {
132
161
  for (const [ attr, value ] of attrs) {
133
162
  elem.setAttribute(attr, value);
134
163
  }
135
164
  }
165
+
166
+ if (styleAttrs) {
167
+ for (const attr of styleAttrs) {
168
+ elem.style.setProperty(attr.key, attr.value, attr.priority);
169
+ }
170
+ }
136
171
  }
137
172
  }
138
173
  }
@@ -0,0 +1,51 @@
1
+ import { TextStyle } from '../../components/Text';
2
+ import Mat33 from '../../geometry/Mat33';
3
+ import Rect2 from '../../geometry/Rect2';
4
+ import { Vec2 } from '../../geometry/Vec2';
5
+ import Vec3 from '../../geometry/Vec3';
6
+ import Viewport from '../../Viewport';
7
+ import { TextRendererLocalization } from '../localization';
8
+ import AbstractRenderer, { RenderingStyle } from './AbstractRenderer';
9
+
10
+ // Outputs a description of what was rendered.
11
+
12
+ export default class TextOnlyRenderer extends AbstractRenderer {
13
+ private descriptionBuilder: string[] = [];
14
+ public constructor(viewport: Viewport, private localizationTable: TextRendererLocalization) {
15
+ super(viewport);
16
+ }
17
+
18
+ public displaySize(): Vec3 {
19
+ // We don't have a graphical display, export a reasonable size.
20
+ return Vec2.of(500, 500);
21
+ }
22
+
23
+ public clear(): void {
24
+ this.descriptionBuilder = [];
25
+ }
26
+
27
+ public getDescription(): string {
28
+ return this.descriptionBuilder.join('\n');
29
+ }
30
+
31
+ protected beginPath(_startPoint: Vec3): void {
32
+ }
33
+ protected endPath(_style: RenderingStyle): void {
34
+ }
35
+ protected lineTo(_point: Vec3): void {
36
+ }
37
+ protected moveTo(_point: Vec3): void {
38
+ }
39
+ protected traceCubicBezierCurve(_p1: Vec3, _p2: Vec3, _p3: Vec3): void {
40
+ }
41
+ protected traceQuadraticBezierCurve(_controlPoint: Vec3, _endPoint: Vec3): void {
42
+ }
43
+ public drawText(text: string, _transform: Mat33, _style: TextStyle): void {
44
+ this.descriptionBuilder.push(this.localizationTable.textNode(text));
45
+ }
46
+ public isTooSmallToRender(rect: Rect2): boolean {
47
+ return rect.maxDimension < 10 / this.getSizeOfCanvasPixelOnScreen();
48
+ }
49
+ public drawPoints(..._points: Vec3[]): void {
50
+ }
51
+ }
@@ -15,10 +15,7 @@ export const loadExpectExtensions = () => {
15
15
  return {
16
16
  pass,
17
17
  message: () => {
18
- if (pass) {
19
- return `Expected ${expected} not to .eq ${actual}. Options(${eqArgs})`;
20
- }
21
- return `Expected ${expected} to .eq ${actual}. Options(${eqArgs})`;
18
+ return `Expected ${pass ? '!' : ''}(${actual}).eq(${expected}). Options(${eqArgs})`;
22
19
  },
23
20
  };
24
21
  },