Skip to content

Commit

Permalink
feat: svg exporter for text shape
Browse files Browse the repository at this point in the history
  • Loading branch information
xiaoiver committed Jan 13, 2025
1 parent f0861af commit 88fd963
Show file tree
Hide file tree
Showing 13 changed files with 342 additions and 6 deletions.
59 changes: 59 additions & 0 deletions __tests__/ssr/snapshots/text.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
80 changes: 80 additions & 0 deletions __tests__/ssr/text.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import _gl from 'gl';
import { JSDOM } from 'jsdom';
import xmlserializer from 'xmlserializer';
import { getCanvas } from '../utils';
import '../useSnapshotMatchers';
import { Canvas, ImageExporter, Text } from '../../packages/core/src';

const dir = `${__dirname}/snapshots`;
let $canvas: HTMLCanvasElement;
let canvas: Canvas;
let exporter: ImageExporter;

describe('Text', () => {
beforeEach(async () => {
$canvas = getCanvas(200, 200);
canvas = await new Canvas({
canvas: $canvas,
}).initialized;
exporter = new ImageExporter({
canvas,
document: new JSDOM().window._document,
xmlserializer,
});
});

afterEach(() => {
canvas.destroy();
});

/**
* @see https://developer.mozilla.org/en-US/docs/Web/SVG/Element/text#example
*/
it('should render multiple text elements correctly.', async () => {
const my = new Text({
x: 20,
y: 35,
content: 'My',
fontFamily: 'sans-serif',
fontSize: 13,
fontStyle: 'italic',
});
canvas.appendChild(my);

const cat = new Text({
x: 40,
y: 35,
content: 'cat',
fontFamily: 'sans-serif',
fontSize: 30,
fontWeight: 700,
});
canvas.appendChild(cat);

const is = new Text({
x: 55,
y: 55,
content: 'is',
fontFamily: 'sans-serif',
fontSize: 13,
fontStyle: 'italic',
});
canvas.appendChild(is);

const grumpy = new Text({
x: 65,
y: 55,
content: 'Grumpy!',
fill: 'red',
fontFamily: 'serif',
fontSize: 40,
fontStyle: 'italic',
});
canvas.appendChild(grumpy);

// canvas.render();

// expect($canvas.getContext('webgl1')).toMatchWebGLSnapshot(dir, 'text');
expect(exporter.toSVG({ grid: true })).toMatchSVGSnapshot(dir, 'text');
});
});
78 changes: 78 additions & 0 deletions __tests__/unit/serialize.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Polyline,
Path,
parseTransform,
Text,
} from '../../packages/core/src';

describe('Serialize', () => {
Expand Down Expand Up @@ -279,6 +280,83 @@ describe('Serialize', () => {
});
});

it('should serialize content correctly.', () => {
const text = new Text({
content: 'Hello, World!',
fontFamily: 'sans-serif',
fontSize: 30,
fill: '#F67676',
});
let serialized = serializeNode(text);
expect(serialized).toEqual({
type: 'text',
children: [],
uid: 4,
attributes: {
batchable: true,
cullable: true,
fill: '#F67676',
fillOpacity: 1,
innerShadowBlurRadius: 0,
innerShadowColor: 'black',
innerShadowOffsetX: 0,
innerShadowOffsetY: 0,
letterSpacing: 0,
lineHeight: 0,
opacity: 1,
content: 'Hello, World!',
fontSize: 30,
fontFamily: 'sans-serif',
fontStyle: 'normal',
fontVariant: 'normal',
fontWeight: 400,
renderable: true,
stroke: 'none',
strokeAlignment: 'center',
strokeDasharray: '',
strokeDashoffset: 0,
strokeLinecap: 'butt',
strokeLinejoin: 'miter',
strokeMiterlimit: 4,
strokeOpacity: 1,
strokeWidth: 1,
transform: {
matrix: {
a: 1,
b: 0,
c: 0,
d: 1,
tx: 0,
ty: 0,
},
pivot: {
x: 0,
y: 0,
},
position: {
x: 0,
y: 0,
},
rotation: 0,
scale: {
x: 1,
y: 1,
},
skew: {
x: 0,
y: 0,
},
},
visible: true,
whiteSpace: 'normal',
wordWrap: false,
wordWrapWidth: 0,
x: 0,
y: 0,
},
});
});

it('should parse transform correctly.', () => {
const parsed = parseTransform('translate(10, 20)');
expect(parsed.position).toEqual({ x: 10, y: 20 });
Expand Down
80 changes: 79 additions & 1 deletion packages/core/src/utils/serialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Polyline,
Path,
Rect,
Text,
Shape,
RoughRect,
shiftPoints,
Expand All @@ -29,6 +30,7 @@ import {
import { IRough } from '../shapes/mixins/Rough';
import { Drawable } from 'roughjs/bin/core';
import { opSet2Absolute } from './rough';
import { fontStringFromTextStyle } from './font';

type SerializedTransform = {
matrix: {
Expand Down Expand Up @@ -116,6 +118,21 @@ const rectAttributes = [
] as const;
const polylineAttributes = ['points'] as const;
const pathAttributes = ['d'] as const;
const textAttributes = [
'x',
'y',
'content',
'fontFamily',
'fontSize',
'fontWeight',
'fontStyle',
'fontVariant',
'letterSpacing',
'lineHeight',
'whiteSpace',
'wordWrap',
'wordWrapWidth',
] as const;

/**
* No need to output default value in SVG Element.
Expand Down Expand Up @@ -155,6 +172,7 @@ type EllipseAttributeName = (typeof ellipseAttributes)[number];
type RectAttributeName = (typeof rectAttributes)[number];
type PolylineAttributeName = (typeof polylineAttributes)[number];
type PathAttributeName = (typeof pathAttributes)[number];
type TextAttributeName = (typeof textAttributes)[number];

interface SerializedNode {
uid: number;
Expand All @@ -165,6 +183,7 @@ interface SerializedNode {
| 'rect'
| 'polyline'
| 'path'
| 'text'
| 'rough-circle'
| 'rough-ellipse'
| 'rough-rect'
Expand All @@ -177,6 +196,7 @@ interface SerializedNode {
Partial<Pick<Rect, RectAttributeName>> &
Partial<Pick<Polyline, PolylineAttributeName>> &
Partial<Pick<Path, PathAttributeName>> &
Partial<Pick<Text, TextAttributeName>> &
Partial<IRough & { drawableSets: Drawable['sets'] }>;
children?: SerializedNode[];
}
Expand All @@ -200,7 +220,8 @@ export function typeofShape(
...(typeof polylineAttributes & typeof renderableAttributes),
]
| ['rough-path', ...(typeof pathAttributes & typeof renderableAttributes)]
| ['path', ...(typeof pathAttributes & typeof renderableAttributes)] {
| ['path', ...(typeof pathAttributes & typeof renderableAttributes)]
| ['text', ...(typeof textAttributes & typeof renderableAttributes)] {
if (shape instanceof Group) {
return ['g', commonAttributes];
} else if (shape instanceof Circle) {
Expand All @@ -213,6 +234,8 @@ export function typeofShape(
return ['polyline', [...renderableAttributes, ...polylineAttributes]];
} else if (shape instanceof Path) {
return ['path', [...renderableAttributes, ...pathAttributes]];
} else if (shape instanceof Text) {
return ['text', [...renderableAttributes, ...textAttributes]];
} else if (shape instanceof RoughCircle) {
return [
'rough-circle',
Expand Down Expand Up @@ -256,6 +279,8 @@ export async function deserializeNode(data: SerializedNode) {
shape = new Polyline();
} else if (type === 'path') {
shape = new Path();
} else if (type === 'text') {
shape = new Text();
} else if (type === 'rough-circle') {
shape = new RoughCircle();
// TODO: implement with path
Expand Down Expand Up @@ -600,6 +625,45 @@ export function exportFillImage(
element.setAttribute('fill', `url(#${$pattern.id})`);
}

/**
* use <text> and <tspan> to render text.
* @see https://developer.mozilla.org/en-US/docs/Web/SVG/Element/text#example
*/
export function exportText(
node: SerializedNode,
$g: SVGElement,
doc: Document,
) {
const {
content,
fontFamily,
fontSize,
fontWeight,
fontStyle,
fontVariant,
fill,
} = node.attributes;
$g.textContent = content;

let styleCSSText = '';
const fontStyleString = fontStringFromTextStyle({
fontFamily,
fontSize,
fontWeight,
fontStyle,
fontVariant,
});
if (fontStyleString) {
styleCSSText += `font: ${fontStyleString};`;
}
if (fill) {
styleCSSText += `fill: ${fill as string};`;
}
if (styleCSSText) {
$g.setAttribute('style', styleCSSText);
}
}

export function exportRough(
node: SerializedNode,
$g: SVGElement,
Expand Down Expand Up @@ -652,6 +716,17 @@ export function toSVGElement(node: SerializedNode, doc?: Document) {
strokeAlignment,
cornerRadius,
zIndex,
fontFamily,
fontSize,
fontWeight,
fontStyle,
fontVariant,
content,
letterSpacing,
lineHeight,
whiteSpace,
wordWrap,
wordWrapWidth,
...rest
} = attributes;

Expand Down Expand Up @@ -743,6 +818,9 @@ export function toSVGElement(node: SerializedNode, doc?: Document) {
if (isRough) {
exportRough(node, $g, doc);
}
if (content) {
exportText(node, $g, doc);
}

const { a, b, c, d, tx, ty } = transform.matrix;
if (a !== 1 || b !== 0 || c !== 0 || d !== 1 || tx !== 0 || ty !== 0) {
Expand Down
Loading

0 comments on commit 88fd963

Please sign in to comment.