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.
- package/CHANGELOG.md +13 -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 +24 -6
- package/dist/src/EditorImage.js +3 -0
- package/dist/src/Pointer.d.ts +3 -2
- package/dist/src/Pointer.js +12 -3
- package/dist/src/SVGLoader.d.ts +11 -0
- package/dist/src/SVGLoader.js +113 -4
- package/dist/src/Viewport.d.ts +1 -1
- package/dist/src/Viewport.js +12 -2
- package/dist/src/components/AbstractComponent.d.ts +6 -0
- package/dist/src/components/AbstractComponent.js +11 -0
- package/dist/src/components/SVGGlobalAttributesObject.js +0 -1
- package/dist/src/components/Stroke.js +1 -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 +105 -67
- package/dist/src/geometry/Rect2.d.ts +2 -0
- package/dist/src/geometry/Rect2.js +6 -0
- package/dist/src/rendering/renderers/AbstractRenderer.d.ts +7 -1
- package/dist/src/rendering/renderers/AbstractRenderer.js +13 -1
- 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 +6 -2
- package/dist/src/rendering/renderers/SVGRenderer.js +50 -7
- package/dist/src/testing/loadExpectExtensions.js +1 -4
- package/dist/src/toolbar/HTMLToolbar.d.ts +2 -1
- package/dist/src/toolbar/HTMLToolbar.js +242 -154
- package/dist/src/toolbar/icons.d.ts +12 -0
- package/dist/src/toolbar/icons.js +198 -0
- package/dist/src/toolbar/localization.d.ts +5 -1
- package/dist/src/toolbar/localization.js +5 -1
- package/dist/src/toolbar/types.d.ts +4 -0
- package/dist/src/tools/PanZoom.d.ts +9 -6
- package/dist/src/tools/PanZoom.js +30 -21
- package/dist/src/tools/Pen.js +8 -3
- package/dist/src/tools/SelectionTool.js +1 -1
- package/dist/src/tools/TextTool.d.ts +30 -0
- package/dist/src/tools/TextTool.js +173 -0
- package/dist/src/tools/ToolController.d.ts +5 -5
- package/dist/src/tools/ToolController.js +10 -9
- package/dist/src/tools/localization.d.ts +3 -0
- package/dist/src/tools/localization.js +3 -0
- package/dist-test/test-dist-bundle.html +8 -1
- package/package.json +1 -1
- package/src/Editor.css +2 -0
- package/src/Editor.ts +26 -7
- package/src/EditorImage.ts +4 -0
- package/src/Pointer.ts +13 -4
- package/src/SVGLoader.ts +146 -5
- package/src/Viewport.ts +15 -3
- package/src/components/AbstractComponent.ts +16 -1
- package/src/components/SVGGlobalAttributesObject.ts +0 -1
- package/src/components/Stroke.ts +1 -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.fromString.test.ts +94 -4
- package/src/geometry/Path.toString.test.ts +7 -3
- package/src/geometry/Path.ts +110 -68
- package/src/geometry/Rect2.ts +8 -0
- package/src/rendering/renderers/AbstractRenderer.ts +18 -1
- package/src/rendering/renderers/CanvasRenderer.ts +34 -10
- package/src/rendering/renderers/DummyRenderer.ts +8 -0
- package/src/rendering/renderers/SVGRenderer.ts +57 -10
- package/src/testing/loadExpectExtensions.ts +1 -4
- package/src/toolbar/HTMLToolbar.ts +294 -170
- package/src/toolbar/icons.ts +227 -0
- package/src/toolbar/localization.ts +11 -2
- package/src/toolbar/toolbar.css +27 -11
- package/src/toolbar/types.ts +5 -0
- package/src/tools/PanZoom.ts +37 -27
- package/src/tools/Pen.ts +7 -3
- package/src/tools/SelectionTool.ts +1 -1
- package/src/tools/TextTool.ts +225 -0
- package/src/tools/ToolController.ts +7 -5
- 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
|
});
|
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
|
}
|
@@ -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
|
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(
|
122
|
+
controlPoint1: Vec2.of(2, 2),
|
100
123
|
controlPoint2: Vec2.of(1, -2),
|
101
|
-
endPoint: Vec2.of(5,
|
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(-
|
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.
|
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(/[.]$/, '');
|
@@ -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 = /([
|
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-
|
441
|
+
const argParts = current[2].trim().split(/[^0-9Ee.-]/).filter(
|
422
442
|
part => part.length > 0
|
423
|
-
)
|
424
|
-
|
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
|
427
|
-
const
|
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
|
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
|
-
|
443
|
-
return coordinate;
|
502
|
+
newPos = coordinate;
|
444
503
|
} else {
|
445
|
-
|
446
|
-
return lastPos;
|
504
|
+
newPos = lastPos.plus(coordinate);
|
447
505
|
}
|
448
|
-
});
|
449
|
-
|
450
|
-
let expectedPointArgCount;
|
451
506
|
|
452
|
-
|
453
|
-
|
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
|
-
|
482
|
-
|
483
|
-
|
484
|
-
|
485
|
-
|
486
|
-
|
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
|
-
|
489
|
-
|
490
|
-
expectedPointArgCount = 0;
|
522
|
+
for (let argPos = 0; argPos < allArgs.length; argPos += commandArgCount) {
|
523
|
+
const args = allArgs.slice(argPos, argPos + commandArgCount);
|
491
524
|
|
492
|
-
|
493
|
-
|
494
|
-
|
495
|
-
|
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
|
-
|
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 (
|
509
|
-
firstPos ??=
|
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(
|
556
|
+
return new Path(startPos ?? Vec2.zero, commands);
|
515
557
|
}
|
516
558
|
}
|
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[] {
|
@@ -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 {
|