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.
- package/CHANGELOG.md +5 -0
- package/README.md +2 -2
- package/dist/bundle.js +1 -1
- package/dist/src/Editor.js +2 -3
- 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 +2 -2
- package/dist/src/components/SVGGlobalAttributesObject.js +0 -1
- package/dist/src/components/Text.d.ts +30 -0
- package/dist/src/components/Text.js +109 -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/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/testing/loadExpectExtensions.js +1 -4
- package/dist/src/toolbar/HTMLToolbar.js +52 -1
- package/dist/src/toolbar/icons.d.ts +2 -0
- package/dist/src/toolbar/icons.js +17 -0
- package/dist/src/tools/SelectionTool.js +1 -1
- package/dist/src/tools/TextTool.d.ts +29 -0
- package/dist/src/tools/TextTool.js +154 -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/package.json +1 -1
- package/src/Editor.ts +3 -3
- package/src/SVGLoader.ts +124 -6
- package/src/Viewport.ts +2 -2
- package/src/components/SVGGlobalAttributesObject.ts +0 -1
- package/src/components/Text.ts +136 -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/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/testing/loadExpectExtensions.ts +1 -4
- package/src/toolbar/HTMLToolbar.ts +64 -1
- package/src/toolbar/icons.ts +23 -0
- package/src/tools/SelectionTool.ts +1 -1
- package/src/tools/TextTool.ts +206 -0
- package/src/tools/ToolController.ts +4 -0
- 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,
|
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
|
-
|
188
|
-
|
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)(
|
@@ -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
|
});
|
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[] {
|
@@ -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)) {
|