Skip to content

Commit e097b90

Browse files
authored
fix(Fabtictext) Svg export for text on a path (fabricjs#10284)
1 parent 630cf5c commit e097b90

File tree

13 files changed

+168
-68
lines changed

13 files changed

+168
-68
lines changed

.codesandbox/templates/vanilla/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
<body>
99
<canvas id="canvas"></canvas>
10+
<div id="svgout"></div>
1011
<script src="./src/index.ts" type="module"></script>
1112
</body>
1213
</html>

.codesandbox/templates/vanilla/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as fabric from 'fabric';
22
import './styles.css';
3-
import { testCase } from './testcases/loadingSvgs';
3+
import { testCase } from './testcases/simpleTextbox';
44

55
const el = document.getElementById('canvas');
66
const canvas = (window.canvas = new fabric.Canvas(el));
Lines changed: 76 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,86 @@
11
import * as fabric from 'fabric';
22

3+
const makePath = (textObj): string => {
4+
const diameter = 300; // Circle diameter
5+
const radius = diameter / 2; // Convert diameter to radius
6+
const arcAngle = 360; // Angle covered by the arc
7+
const startAngle = 0; // Starting angle for the text (X start point)
8+
9+
const charCount = textObj.text.length; // Number of characters in the text
10+
11+
// Calculate the angle between each character
12+
const anglePerChar = arcAngle / (charCount - 1);
13+
14+
// Adjust the startAngle by subtracting 90° to shift the starting point to the top (12 o'clock)
15+
let angle = startAngle + 90;
16+
17+
// Construct the path, starting from the first character's position
18+
let pathString = `M ${radius * Math.cos((angle * Math.PI) / 180)} ${
19+
radius * Math.sin((angle * Math.PI) / 180)
20+
}`;
21+
22+
// Loop through each character and add to the path
23+
for (let i = 0; i < charCount; i++) {
24+
const theta = (angle * Math.PI) / 180; // Convert angle to radians
25+
const xPos = radius * Math.cos(theta); // Calculate x position
26+
const yPos = radius * Math.sin(theta); // Calculate y position
27+
28+
// Add arc segment for each letter
29+
pathString += ` A ${radius} ${radius} 0 0 1 ${xPos} ${yPos}`;
30+
31+
// Increment the angle for the next character
32+
angle += anglePerChar;
33+
}
34+
35+
return pathString;
36+
};
37+
338
export function testCase(canvas: fabric.Canvas) {
4-
const textValue = 'fabric.js sandbox';
5-
const text = new fabric.Textbox(textValue, {
6-
originX: 'center',
7-
splitByGrapheme: true,
39+
fabric.config.NUM_FRACTION_DIGITS = 9;
40+
const textValue = 'testing 123 123 123 ';
41+
const text = new fabric.FabricText(textValue, {
842
width: 200,
943
top: 20,
10-
styles: fabric.util.stylesFromArray(
11-
[
12-
{
13-
style: {
14-
fontWeight: 'bold',
15-
fontSize: 64,
16-
},
17-
start: 0,
18-
end: 9,
44+
fill: '',
45+
stroke: 'red',
46+
objectCaching: false,
47+
styles: {
48+
0: {
49+
0: {
50+
fontSize: 60,
51+
fill: 'blue',
52+
},
53+
1: {
54+
fontSize: 90,
55+
fill: 'green',
56+
},
57+
2: {
58+
fontSize: 20,
59+
fill: 'Yellow',
60+
},
61+
3: {
62+
fontWeigth: 'bold',
63+
fill: 'transparent',
64+
strokeWidth: 4,
65+
strole: 'blue',
66+
},
67+
4: {
68+
fontWeigth: 'bold',
69+
fill: 'transparent',
70+
strokeWidth: 4,
71+
strole: 'blue',
1972
},
20-
],
21-
textValue,
22-
),
73+
},
74+
},
75+
});
76+
const pathString = makePath(text);
77+
const pathObject = new fabric.Path(pathString, {
78+
fill: 'transparent',
79+
stroke: 'red',
80+
objectCaching: false,
2381
});
82+
text.set('path', pathObject);
2483
canvas.add(text);
2584
canvas.centerObjectH(text);
26-
function animate(toState) {
27-
text.animate(
28-
{ scaleX: Math.max(toState, 0.1) * 2 },
29-
{
30-
onChange: () => canvas.renderAll(),
31-
onComplete: () => animate(!toState),
32-
duration: 1000,
33-
easing: toState
34-
? fabric.util.ease.easeInOutQuad
35-
: fabric.util.ease.easeInOutSine,
36-
},
37-
);
38-
}
39-
// animate(1);
85+
document.getElementById('svgout')?.innerHTML = canvas.toSVG();
4086
}

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## [next]
44

5+
- fix(Fabtictext) Svg export for text on a path [#10284](https://github.com/fabricjs/fabric.js/pull/10284)
56
- fix(FabricImage): add href parsing fixes for #10421 [#10465](https://github.com/fabricjs/fabric.js/pull/10465)
67

78
## [6.6.1]

src/canvas/StaticCanvas.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import {
3434
enlivenObjects,
3535
} from '../util/misc/objectEnlive';
3636
import { pick } from '../util/misc/pick';
37-
import { matrixToSVG } from '../util/misc/svgParsing';
37+
import { matrixToSVG } from '../util/misc/svgExport';
3838
import { toFixed } from '../util/misc/toFixed';
3939
import { isFiller, isPattern, isTextObject } from '../util/typeAssertions';
4040
import { StaticCanvasDOMManager } from './DOMManagers/StaticCanvasDOMManager';

src/gradient/Gradient.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { FabricObject } from '../shapes/Object/FabricObject';
55
import type { TMat2D } from '../typedefs';
66
import { uid } from '../util/internals/uid';
77
import { pick } from '../util/misc/pick';
8-
import { matrixToSVG } from '../util/misc/svgParsing';
8+
import { matrixToSVG } from '../util/misc/svgExport';
99
import { linearDefaultCoords, radialDefaultCoords } from './constants';
1010
import { parseColorStops } from './parser/parseColorStops';
1111
import { parseCoords } from './parser/parseCoords';

src/shapes/Object/FabricObjectSVGExportMixin.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import type { TSVGReviver } from '../../typedefs';
22
import { uid } from '../../util/internals/uid';
3-
import { colorPropToSVG, matrixToSVG } from '../../util/misc/svgParsing';
3+
import { colorPropToSVG } from '../../util/misc/svgParsing';
44
import { FILL, NONE, STROKE } from '../../constants';
55
import type { FabricObject } from './FabricObject';
66
import { isFiller } from '../../util/typeAssertions';
7+
import { matrixToSVG } from '../../util/misc/svgExport';
78

89
export class FabricObjectSVGExportMixin {
910
/**

src/shapes/Text/Text.spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { roundSnapshotOptions } from '../../../jest.extend';
22
import { cache } from '../../cache';
33
import { config } from '../../config';
4+
import { Path } from '../Path';
45
import { FabricText } from './Text';
56

67
afterEach(() => {
@@ -52,6 +53,19 @@ describe('FabricText', () => {
5253
expect(text.toSVG()).toMatchSnapshot();
5354
});
5455

56+
it('toSVG with a path', async () => {
57+
const path = new Path('M 10 10 H 50 V 60', { fill: '', stroke: 'red' });
58+
const text = new FabricText(
59+
'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
60+
{ scaleX: 2, scaleY: 2 },
61+
);
62+
const plainSvg = text.toSVG();
63+
text.path = path;
64+
const svg = text.toSVG();
65+
expect(svg).toMatchSnapshot();
66+
expect(svg.includes(plainSvg)).toBe(false);
67+
});
68+
5569
it('subscript/superscript', async () => {
5670
const text = await FabricText.fromObject({
5771
text: 'xxxxxx',

src/shapes/Text/TextSVGExportMixin.ts

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ import { toFixed } from '../../util/misc/toFixed';
77
import { FabricObjectSVGExportMixin } from '../Object/FabricObjectSVGExportMixin';
88
import { type TextStyleDeclaration } from './StyledText';
99
import { JUSTIFY } from '../Text/constants';
10-
import type { FabricText } from './Text';
10+
import type { FabricText, GraphemeBBox } from './Text';
1111
import { STROKE, FILL } from '../../constants';
12+
import { createRotateMatrix } from '../../util/misc/matrix';
13+
import { radiansToDegrees } from '../../util/misc/radiansDegreesConversion';
14+
import { Point } from '../../Point';
15+
import { matrixToSVG } from '../../util/misc/svgExport';
1216

1317
const multipleSpacesRegex = / +/g;
1418
const dblQuoteRegex = /"/g;
@@ -31,11 +35,23 @@ export class TextSVGExportMixin extends FabricObjectSVGExportMixin {
3135
}
3236

3337
toSVG(this: TextSVGExportMixin & FabricText, reviver?: TSVGReviver): string {
34-
return this._createBaseSVGMarkup(this._toSVG(), {
35-
reviver,
36-
noStyle: true,
37-
withShadow: true,
38-
});
38+
const textSvg = this._createBaseSVGMarkup(this._toSVG(), {
39+
reviver,
40+
noStyle: true,
41+
withShadow: true,
42+
}),
43+
path = this.path;
44+
if (path) {
45+
return (
46+
textSvg +
47+
path._createBaseSVGMarkup(path._toSVG(), {
48+
reviver,
49+
withShadow: true,
50+
additionalTransform: matrixToSVG(this.calcOwnMatrix()),
51+
})
52+
);
53+
}
54+
return textSvg;
3955
}
4056

4157
private _getSVGLeftTopOffsets(this: TextSVGExportMixin & FabricText) {
@@ -142,22 +158,34 @@ export class TextSVGExportMixin extends FabricObjectSVGExportMixin {
142158
styleDecl: TextStyleDeclaration,
143159
left: number,
144160
top: number,
161+
charBox: GraphemeBBox,
145162
) {
163+
const numFractionDigit = config.NUM_FRACTION_DIGITS;
146164
const styleProps = this.getSvgSpanStyles(
147165
styleDecl,
148166
char !== char.trim() || !!char.match(multipleSpacesRegex),
149167
),
150168
fillStyles = styleProps ? `style="${styleProps}"` : '',
151169
dy = styleDecl.deltaY,
152-
dySpan = dy ? ` dy="${toFixed(dy, config.NUM_FRACTION_DIGITS)}" ` : '';
170+
dySpan = dy ? ` dy="${toFixed(dy, numFractionDigit)}" ` : '',
171+
{ angle, renderLeft, renderTop, width } = charBox;
172+
let angleAttr = '';
173+
if (renderLeft !== undefined) {
174+
const wBy2 = width / 2;
175+
angle &&
176+
(angleAttr = ` rotate="${toFixed(radiansToDegrees(angle), numFractionDigit)}"`);
177+
const m = createRotateMatrix({ angle: radiansToDegrees(angle!) });
178+
m[4] = renderLeft!;
179+
m[5] = renderTop!;
180+
const renderPoint = new Point(-wBy2, 0).transform(m);
181+
left = renderPoint.x;
182+
top = renderPoint.y;
183+
}
153184

154-
return `<tspan x="${toFixed(
155-
left,
156-
config.NUM_FRACTION_DIGITS,
157-
)}" y="${toFixed(
185+
return `<tspan x="${toFixed(left, numFractionDigit)}" y="${toFixed(
158186
top,
159-
config.NUM_FRACTION_DIGITS,
160-
)}" ${dySpan}${fillStyles}>${escapeXml(char)}</tspan>`;
187+
numFractionDigit,
188+
)}" ${dySpan}${angleAttr}${fillStyles}>${escapeXml(char)}</tspan>`;
161189
}
162190

163191
private _setSVGTextLineText(
@@ -181,7 +209,7 @@ export class TextSVGExportMixin extends FabricObjectSVGExportMixin {
181209
textTopOffset +=
182210
(lineHeight * (1 - this._fontSizeFraction)) / this.lineHeight;
183211
for (let i = 0, len = line.length - 1; i <= len; i++) {
184-
timeToRender = i === len || this.charSpacing;
212+
timeToRender = i === len || this.charSpacing || this.path;
185213
charsToRender += line[i];
186214
charBox = this.__charBounds[lineIndex][i];
187215
if (boxWidth === 0) {
@@ -196,7 +224,7 @@ export class TextSVGExportMixin extends FabricObjectSVGExportMixin {
196224
}
197225
}
198226
if (!timeToRender) {
199-
// if we have charSpacing, we render char by char
227+
// if we have charSpacing or a path, we render char by char
200228
actualStyle =
201229
actualStyle || this.getCompleteStyleDeclaration(lineIndex, i);
202230
nextStyle = this.getCompleteStyleDeclaration(lineIndex, i + 1);
@@ -210,6 +238,7 @@ export class TextSVGExportMixin extends FabricObjectSVGExportMixin {
210238
style,
211239
textLeftOffset,
212240
textTopOffset,
241+
charBox,
213242
),
214243
);
215244
charsToRender = '';

src/shapes/Text/__snapshots__/Text.spec.ts.snap

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,13 @@ exports[`FabricText toSVG with NUM_FRACTION_DIGITS 2`] = `
212212
</g>
213213
"
214214
`;
215+
216+
exports[`FabricText toSVG with a path 1`] = `
217+
"<g transform="matrix(2 0 0 2 1061 46.2)" style="" >
218+
<text xml:space="preserve" font-family="Times New Roman" font-size="40" font-style="normal" font-weight="normal" style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(0,0,0); fill-rule: nonzero; opacity: 1; white-space: pre;" ><tspan x="-530" y="12.5656" >x</tspan><tspan x="-510" y="12.5656" >x</tspan><tspan x="-490" y="12.5656" >x</tspan><tspan x="-470" y="12.5656" >x</tspan><tspan x="-450" y="12.5656" >x</tspan><tspan x="-430" y="12.5656" >x</tspan><tspan x="-410" y="12.5656" >x</tspan><tspan x="-390" y="12.5656" >x</tspan><tspan x="-370" y="12.5656" >x</tspan><tspan x="-350" y="12.5656" >x</tspan><tspan x="-330" y="12.5656" >x</tspan><tspan x="-310" y="12.5656" >x</tspan><tspan x="-290" y="12.5656" >x</tspan><tspan x="-270" y="12.5656" >x</tspan><tspan x="-250" y="12.5656" >x</tspan><tspan x="-230" y="12.5656" >x</tspan><tspan x="-210" y="12.5656" >x</tspan><tspan x="-190" y="12.5656" >x</tspan><tspan x="-170" y="12.5656" >x</tspan><tspan x="-150" y="12.5656" >x</tspan><tspan x="-130" y="12.5656" >x</tspan><tspan x="-110" y="12.5656" >x</tspan><tspan x="-90" y="12.5656" >x</tspan><tspan x="-70" y="12.5656" >x</tspan><tspan x="-50" y="12.5656" >x</tspan><tspan x="-30" y="12.5656" >x</tspan><tspan x="-10" y="12.5656" >x</tspan><tspan x="10" y="12.5656" >x</tspan><tspan x="30" y="12.5656" >x</tspan><tspan x="50" y="12.5656" >x</tspan><tspan x="70" y="12.5656" >x</tspan><tspan x="90" y="12.5656" >x</tspan><tspan x="110" y="12.5656" >x</tspan><tspan x="130" y="12.5656" >x</tspan><tspan x="150" y="12.5656" >x</tspan><tspan x="170" y="12.5656" >x</tspan><tspan x="190" y="12.5656" >x</tspan><tspan x="210" y="12.5656" >x</tspan><tspan x="230" y="12.5656" >x</tspan><tspan x="250" y="12.5656" >x</tspan><tspan x="270" y="12.5656" >x</tspan><tspan x="290" y="12.5656" >x</tspan><tspan x="310" y="12.5656" >x</tspan><tspan x="330" y="12.5656" >x</tspan><tspan x="350" y="12.5656" >x</tspan><tspan x="370" y="12.5656" >x</tspan><tspan x="390" y="12.5656" >x</tspan><tspan x="410" y="12.5656" >x</tspan><tspan x="430" y="12.5656" >x</tspan><tspan x="450" y="12.5656" >x</tspan><tspan x="470" y="12.5656" >x</tspan><tspan x="490" y="12.5656" >x</tspan><tspan x="510" y="12.5656" >x</tspan></text>
219+
</g>
220+
<g transform="matrix(1 0 0 1 30 35)" style="" >
221+
<path style="stroke: rgb(255,0,0); stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: none; fill-rule: nonzero; opacity: 1;" transform="matrix(2 0 0 2 1061 46.2)" d="M 10 10 L 50 10 L 50 60" stroke-linecap="round" />
222+
</g>
223+
"
224+
`;

0 commit comments

Comments
 (0)