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.
- package/CHANGELOG.md +14 -0
- package/README.md +21 -12
- package/dist/bundle.js +1 -1
- package/dist/src/Editor.d.ts +2 -1
- package/dist/src/Editor.js +20 -6
- package/dist/src/SVGLoader.d.ts +8 -0
- package/dist/src/SVGLoader.js +105 -6
- package/dist/src/Viewport.d.ts +1 -1
- package/dist/src/Viewport.js +5 -5
- package/dist/src/components/SVGGlobalAttributesObject.js +0 -1
- package/dist/src/components/Text.d.ts +30 -0
- package/dist/src/components/Text.js +111 -0
- package/dist/src/components/localization.d.ts +1 -0
- package/dist/src/components/localization.js +1 -0
- package/dist/src/geometry/Mat33.d.ts +1 -0
- package/dist/src/geometry/Mat33.js +30 -0
- package/dist/src/geometry/Path.js +8 -1
- package/dist/src/geometry/Rect2.d.ts +2 -0
- package/dist/src/geometry/Rect2.js +6 -0
- package/dist/src/localization.d.ts +2 -1
- package/dist/src/localization.js +2 -1
- package/dist/src/rendering/Display.d.ts +2 -0
- package/dist/src/rendering/Display.js +19 -0
- package/dist/src/rendering/localization.d.ts +5 -0
- package/dist/src/rendering/localization.js +4 -0
- package/dist/src/rendering/renderers/AbstractRenderer.d.ts +5 -0
- package/dist/src/rendering/renderers/AbstractRenderer.js +12 -0
- package/dist/src/rendering/renderers/CanvasRenderer.d.ts +3 -0
- package/dist/src/rendering/renderers/CanvasRenderer.js +28 -8
- package/dist/src/rendering/renderers/DummyRenderer.d.ts +3 -0
- package/dist/src/rendering/renderers/DummyRenderer.js +5 -0
- package/dist/src/rendering/renderers/SVGRenderer.d.ts +3 -0
- package/dist/src/rendering/renderers/SVGRenderer.js +30 -1
- package/dist/src/rendering/renderers/TextOnlyRenderer.d.ts +24 -0
- package/dist/src/rendering/renderers/TextOnlyRenderer.js +40 -0
- package/dist/src/testing/loadExpectExtensions.js +1 -4
- package/dist/src/toolbar/HTMLToolbar.js +78 -1
- package/dist/src/toolbar/icons.d.ts +2 -0
- package/dist/src/toolbar/icons.js +18 -0
- package/dist/src/toolbar/localization.d.ts +1 -0
- package/dist/src/toolbar/localization.js +1 -0
- package/dist/src/tools/SelectionTool.js +1 -1
- package/dist/src/tools/TextTool.d.ts +31 -0
- package/dist/src/tools/TextTool.js +174 -0
- package/dist/src/tools/ToolController.d.ts +2 -1
- package/dist/src/tools/ToolController.js +4 -1
- package/dist/src/tools/localization.d.ts +3 -1
- package/dist/src/tools/localization.js +3 -1
- package/dist-test/test-dist-bundle.html +8 -1
- package/package.json +1 -1
- package/src/Editor.css +12 -0
- package/src/Editor.ts +22 -7
- package/src/SVGLoader.ts +124 -6
- package/src/Viewport.ts +5 -5
- package/src/components/SVGGlobalAttributesObject.ts +0 -1
- package/src/components/Text.ts +140 -0
- package/src/components/localization.ts +2 -0
- package/src/geometry/Mat33.test.ts +44 -0
- package/src/geometry/Mat33.ts +41 -0
- package/src/geometry/Path.toString.test.ts +7 -3
- package/src/geometry/Path.ts +11 -1
- package/src/geometry/Rect2.ts +8 -0
- package/src/localization.ts +3 -1
- package/src/rendering/Display.ts +26 -0
- package/src/rendering/localization.ts +10 -0
- package/src/rendering/renderers/AbstractRenderer.ts +16 -0
- package/src/rendering/renderers/CanvasRenderer.ts +34 -10
- package/src/rendering/renderers/DummyRenderer.ts +8 -0
- package/src/rendering/renderers/SVGRenderer.ts +36 -1
- package/src/rendering/renderers/TextOnlyRenderer.ts +51 -0
- package/src/testing/loadExpectExtensions.ts +1 -4
- package/src/toolbar/HTMLToolbar.ts +96 -1
- package/src/toolbar/icons.ts +24 -0
- package/src/toolbar/localization.ts +2 -0
- package/src/toolbar/toolbar.css +6 -3
- package/src/tools/SelectionTool.ts +1 -1
- package/src/tools/TextTool.ts +229 -0
- package/src/tools/ToolController.ts +4 -0
- 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
|
});
|
package/src/geometry/Mat33.ts
CHANGED
@@ -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.
|
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.
|
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.
|
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', () => {
|
package/src/geometry/Path.ts
CHANGED
@@ -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]
|
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(/[.]$/, '');
|
package/src/geometry/Rect2.ts
CHANGED
@@ -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[] {
|
package/src/localization.ts
CHANGED
@@ -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',
|
package/src/rendering/Display.ts
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
},
|