mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
SVG export (#598)
* first draft of export to SVG. WIP * enabled text rendeing - which is not quite right atm * placeholder svg icon * size the canvas based on the bounding box of elements * Do not add opacity attributes if default * render background rect * Ensure arrows are in the same SVG group * parse font-size from font * export web fonts * use fixed locations for fonts * Rename export functions * renamed export file * oops broke the icon.
This commit is contained in:
parent
321e4022b0
commit
97b11b0f53
16 changed files with 447 additions and 202 deletions
|
@ -7,6 +7,119 @@ import {
|
|||
} from "../element/bounds";
|
||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
||||
import { Drawable } from "roughjs/bin/core";
|
||||
import { RoughSVG } from "roughjs/bin/svg";
|
||||
import { RoughGenerator } from "roughjs/bin/generator";
|
||||
import { SVG_NS } from "../utils";
|
||||
|
||||
function generateElement(
|
||||
element: ExcalidrawElement,
|
||||
generator: RoughGenerator,
|
||||
) {
|
||||
if (!element.shape) {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
element.shape = generator.rectangle(
|
||||
0,
|
||||
0,
|
||||
element.width,
|
||||
element.height,
|
||||
{
|
||||
stroke: element.strokeColor,
|
||||
fill:
|
||||
element.backgroundColor === "transparent"
|
||||
? undefined
|
||||
: element.backgroundColor,
|
||||
fillStyle: element.fillStyle,
|
||||
strokeWidth: element.strokeWidth,
|
||||
roughness: element.roughness,
|
||||
seed: element.seed,
|
||||
},
|
||||
);
|
||||
break;
|
||||
case "diamond": {
|
||||
const [
|
||||
topX,
|
||||
topY,
|
||||
rightX,
|
||||
rightY,
|
||||
bottomX,
|
||||
bottomY,
|
||||
leftX,
|
||||
leftY,
|
||||
] = getDiamondPoints(element);
|
||||
element.shape = generator.polygon(
|
||||
[
|
||||
[topX, topY],
|
||||
[rightX, rightY],
|
||||
[bottomX, bottomY],
|
||||
[leftX, leftY],
|
||||
],
|
||||
{
|
||||
stroke: element.strokeColor,
|
||||
fill:
|
||||
element.backgroundColor === "transparent"
|
||||
? undefined
|
||||
: element.backgroundColor,
|
||||
fillStyle: element.fillStyle,
|
||||
strokeWidth: element.strokeWidth,
|
||||
roughness: element.roughness,
|
||||
seed: element.seed,
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "ellipse":
|
||||
element.shape = generator.ellipse(
|
||||
element.width / 2,
|
||||
element.height / 2,
|
||||
element.width,
|
||||
element.height,
|
||||
{
|
||||
stroke: element.strokeColor,
|
||||
fill:
|
||||
element.backgroundColor === "transparent"
|
||||
? undefined
|
||||
: element.backgroundColor,
|
||||
fillStyle: element.fillStyle,
|
||||
strokeWidth: element.strokeWidth,
|
||||
roughness: element.roughness,
|
||||
seed: element.seed,
|
||||
curveFitting: 1,
|
||||
},
|
||||
);
|
||||
break;
|
||||
case "arrow": {
|
||||
const [x1, y1, x2, y2, x3, y3, x4, y4] = getArrowPoints(element);
|
||||
const options = {
|
||||
stroke: element.strokeColor,
|
||||
strokeWidth: element.strokeWidth,
|
||||
roughness: element.roughness,
|
||||
seed: element.seed,
|
||||
};
|
||||
element.shape = [
|
||||
// \
|
||||
generator.line(x3, y3, x2, y2, options),
|
||||
// -----
|
||||
generator.line(x1, y1, x2, y2, options),
|
||||
// /
|
||||
generator.line(x4, y4, x2, y2, options),
|
||||
];
|
||||
break;
|
||||
}
|
||||
case "line": {
|
||||
const [x1, y1, x2, y2] = getLinePoints(element);
|
||||
const options = {
|
||||
stroke: element.strokeColor,
|
||||
strokeWidth: element.strokeWidth,
|
||||
roughness: element.roughness,
|
||||
seed: element.seed,
|
||||
};
|
||||
element.shape = generator.line(x1, y1, x2, y2, options);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function renderElement(
|
||||
element: ExcalidrawElement,
|
||||
|
@ -14,147 +127,143 @@ export function renderElement(
|
|||
context: CanvasRenderingContext2D,
|
||||
) {
|
||||
const generator = rc.generator;
|
||||
if (element.type === "selection") {
|
||||
const fillStyle = context.fillStyle;
|
||||
context.fillStyle = "rgba(0, 0, 255, 0.10)";
|
||||
context.fillRect(0, 0, element.width, element.height);
|
||||
context.fillStyle = fillStyle;
|
||||
} else if (element.type === "rectangle") {
|
||||
if (!element.shape) {
|
||||
element.shape = generator.rectangle(0, 0, element.width, element.height, {
|
||||
stroke: element.strokeColor,
|
||||
fill:
|
||||
element.backgroundColor === "transparent"
|
||||
? undefined
|
||||
: element.backgroundColor,
|
||||
fillStyle: element.fillStyle,
|
||||
strokeWidth: element.strokeWidth,
|
||||
roughness: element.roughness,
|
||||
seed: element.seed,
|
||||
});
|
||||
switch (element.type) {
|
||||
case "selection": {
|
||||
const fillStyle = context.fillStyle;
|
||||
context.fillStyle = "rgba(0, 0, 255, 0.10)";
|
||||
context.fillRect(0, 0, element.width, element.height);
|
||||
context.fillStyle = fillStyle;
|
||||
break;
|
||||
}
|
||||
|
||||
context.globalAlpha = element.opacity / 100;
|
||||
rc.draw(element.shape as Drawable);
|
||||
context.globalAlpha = 1;
|
||||
} else if (element.type === "diamond") {
|
||||
if (!element.shape) {
|
||||
const [
|
||||
topX,
|
||||
topY,
|
||||
rightX,
|
||||
rightY,
|
||||
bottomX,
|
||||
bottomY,
|
||||
leftX,
|
||||
leftY,
|
||||
] = getDiamondPoints(element);
|
||||
element.shape = generator.polygon(
|
||||
[
|
||||
[topX, topY],
|
||||
[rightX, rightY],
|
||||
[bottomX, bottomY],
|
||||
[leftX, leftY],
|
||||
],
|
||||
{
|
||||
stroke: element.strokeColor,
|
||||
fill:
|
||||
element.backgroundColor === "transparent"
|
||||
? undefined
|
||||
: element.backgroundColor,
|
||||
fillStyle: element.fillStyle,
|
||||
strokeWidth: element.strokeWidth,
|
||||
roughness: element.roughness,
|
||||
seed: element.seed,
|
||||
},
|
||||
);
|
||||
case "rectangle":
|
||||
case "diamond":
|
||||
case "ellipse":
|
||||
case "line": {
|
||||
generateElement(element, generator);
|
||||
context.globalAlpha = element.opacity / 100;
|
||||
rc.draw(element.shape as Drawable);
|
||||
context.globalAlpha = 1;
|
||||
break;
|
||||
}
|
||||
|
||||
context.globalAlpha = element.opacity / 100;
|
||||
rc.draw(element.shape as Drawable);
|
||||
context.globalAlpha = 1;
|
||||
} else if (element.type === "ellipse") {
|
||||
if (!element.shape) {
|
||||
element.shape = generator.ellipse(
|
||||
element.width / 2,
|
||||
element.height / 2,
|
||||
element.width,
|
||||
element.height,
|
||||
{
|
||||
stroke: element.strokeColor,
|
||||
fill:
|
||||
element.backgroundColor === "transparent"
|
||||
? undefined
|
||||
: element.backgroundColor,
|
||||
fillStyle: element.fillStyle,
|
||||
strokeWidth: element.strokeWidth,
|
||||
roughness: element.roughness,
|
||||
seed: element.seed,
|
||||
curveFitting: 1,
|
||||
},
|
||||
);
|
||||
case "arrow": {
|
||||
generateElement(element, generator);
|
||||
context.globalAlpha = element.opacity / 100;
|
||||
(element.shape as Drawable[]).forEach(shape => rc.draw(shape));
|
||||
context.globalAlpha = 1;
|
||||
break;
|
||||
}
|
||||
|
||||
context.globalAlpha = element.opacity / 100;
|
||||
rc.draw(element.shape as Drawable);
|
||||
context.globalAlpha = 1;
|
||||
} else if (element.type === "arrow") {
|
||||
const [x1, y1, x2, y2, x3, y3, x4, y4] = getArrowPoints(element);
|
||||
const options = {
|
||||
stroke: element.strokeColor,
|
||||
strokeWidth: element.strokeWidth,
|
||||
roughness: element.roughness,
|
||||
seed: element.seed,
|
||||
};
|
||||
|
||||
if (!element.shape) {
|
||||
element.shape = [
|
||||
// \
|
||||
generator.line(x3, y3, x2, y2, options),
|
||||
// -----
|
||||
generator.line(x1, y1, x2, y2, options),
|
||||
// /
|
||||
generator.line(x4, y4, x2, y2, options),
|
||||
];
|
||||
default: {
|
||||
if (isTextElement(element)) {
|
||||
context.globalAlpha = element.opacity / 100;
|
||||
const font = context.font;
|
||||
context.font = element.font;
|
||||
const fillStyle = context.fillStyle;
|
||||
context.fillStyle = element.strokeColor;
|
||||
// Canvas does not support multiline text by default
|
||||
const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
|
||||
const lineHeight = element.height / lines.length;
|
||||
const offset = element.height - element.baseline;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
context.fillText(lines[i], 0, (i + 1) * lineHeight - offset);
|
||||
}
|
||||
context.fillStyle = fillStyle;
|
||||
context.font = font;
|
||||
context.globalAlpha = 1;
|
||||
break;
|
||||
} else {
|
||||
throw new Error("Unimplemented type " + element.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function renderElementToSvg(
|
||||
element: ExcalidrawElement,
|
||||
rsvg: RoughSVG,
|
||||
svgRoot: SVGElement,
|
||||
offsetX?: number,
|
||||
offsetY?: number,
|
||||
) {
|
||||
const generator = rsvg.generator;
|
||||
switch (element.type) {
|
||||
case "selection": {
|
||||
// Since this is used only during editing experience, which is canvas based,
|
||||
// this should not happen
|
||||
throw new Error("Selection rendering is not supported for SVG");
|
||||
}
|
||||
case "rectangle":
|
||||
case "diamond":
|
||||
case "ellipse":
|
||||
case "line": {
|
||||
generateElement(element, generator);
|
||||
const node = rsvg.draw(element.shape as Drawable);
|
||||
const opacity = element.opacity / 100;
|
||||
if (opacity !== 1) {
|
||||
node.setAttribute("stroke-opacity", `${opacity}`);
|
||||
node.setAttribute("fill-opacity", `${opacity}`);
|
||||
}
|
||||
node.setAttribute(
|
||||
"transform",
|
||||
`translate(${offsetX || 0} ${offsetY || 0})`,
|
||||
);
|
||||
svgRoot.appendChild(node);
|
||||
break;
|
||||
}
|
||||
case "arrow": {
|
||||
generateElement(element, generator);
|
||||
const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
|
||||
const opacity = element.opacity / 100;
|
||||
(element.shape as Drawable[]).forEach(shape => {
|
||||
const node = rsvg.draw(shape);
|
||||
if (opacity !== 1) {
|
||||
node.setAttribute("stroke-opacity", `${opacity}`);
|
||||
node.setAttribute("fill-opacity", `${opacity}`);
|
||||
}
|
||||
node.setAttribute(
|
||||
"transform",
|
||||
`translate(${offsetX || 0} ${offsetY || 0})`,
|
||||
);
|
||||
group.appendChild(node);
|
||||
});
|
||||
svgRoot.appendChild(group);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
if (isTextElement(element)) {
|
||||
const opacity = element.opacity / 100;
|
||||
const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
|
||||
if (opacity !== 1) {
|
||||
node.setAttribute("stroke-opacity", `${opacity}`);
|
||||
node.setAttribute("fill-opacity", `${opacity}`);
|
||||
}
|
||||
node.setAttribute(
|
||||
"transform",
|
||||
`translate(${offsetX || 0} ${offsetY || 0})`,
|
||||
);
|
||||
const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
|
||||
const lineHeight = element.height / lines.length;
|
||||
const offset = element.height - element.baseline;
|
||||
const fontSplit = element.font.split(" ").filter(d => !!d.trim());
|
||||
let fontFamily = fontSplit[0];
|
||||
let fontSize = "20px";
|
||||
if (fontSplit.length > 1) {
|
||||
fontFamily = fontSplit[1];
|
||||
fontSize = fontSplit[0];
|
||||
}
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text");
|
||||
text.textContent = lines[i];
|
||||
text.setAttribute("x", "0");
|
||||
text.setAttribute("y", `${(i + 1) * lineHeight - offset}`);
|
||||
text.setAttribute("font-family", fontFamily);
|
||||
text.setAttribute("font-size", fontSize);
|
||||
text.setAttribute("fill", element.strokeColor);
|
||||
node.appendChild(text);
|
||||
}
|
||||
svgRoot.appendChild(node);
|
||||
} else {
|
||||
throw new Error("Unimplemented type " + element.type);
|
||||
}
|
||||
}
|
||||
|
||||
context.globalAlpha = element.opacity / 100;
|
||||
(element.shape as Drawable[]).forEach(shape => rc.draw(shape));
|
||||
context.globalAlpha = 1;
|
||||
return;
|
||||
} else if (element.type === "line") {
|
||||
const [x1, y1, x2, y2] = getLinePoints(element);
|
||||
const options = {
|
||||
stroke: element.strokeColor,
|
||||
strokeWidth: element.strokeWidth,
|
||||
roughness: element.roughness,
|
||||
seed: element.seed,
|
||||
};
|
||||
|
||||
if (!element.shape) {
|
||||
element.shape = generator.line(x1, y1, x2, y2, options);
|
||||
}
|
||||
|
||||
context.globalAlpha = element.opacity / 100;
|
||||
rc.draw(element.shape as Drawable);
|
||||
context.globalAlpha = 1;
|
||||
} else if (isTextElement(element)) {
|
||||
context.globalAlpha = element.opacity / 100;
|
||||
const font = context.font;
|
||||
context.font = element.font;
|
||||
const fillStyle = context.fillStyle;
|
||||
context.fillStyle = element.strokeColor;
|
||||
// Canvas does not support multiline text by default
|
||||
const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
|
||||
const lineHeight = element.height / lines.length;
|
||||
const offset = element.height - element.baseline;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
context.fillText(lines[i], 0, (i + 1) * lineHeight - offset);
|
||||
}
|
||||
context.fillStyle = fillStyle;
|
||||
context.font = font;
|
||||
context.globalAlpha = 1;
|
||||
} else {
|
||||
throw new Error("Unimplemented type " + element.type);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { RoughCanvas } from "roughjs/bin/canvas";
|
||||
import { RoughSVG } from "roughjs/bin/svg";
|
||||
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { getElementAbsoluteCoords, handlerRectangles } from "../element";
|
||||
|
@ -11,7 +12,7 @@ import {
|
|||
SCROLLBAR_WIDTH,
|
||||
} from "../scene/scrollbars";
|
||||
|
||||
import { renderElement } from "./renderElement";
|
||||
import { renderElement, renderElementToSvg } from "./renderElement";
|
||||
|
||||
export function renderScene(
|
||||
elements: readonly ExcalidrawElement[],
|
||||
|
@ -154,3 +155,31 @@ function isVisibleElement(
|
|||
y2 += scrollY;
|
||||
return x2 >= 0 && x1 <= canvasWidth && y2 >= 0 && y1 <= canvasHeight;
|
||||
}
|
||||
|
||||
// This should be only called for exporting purposes
|
||||
export function renderSceneToSvg(
|
||||
elements: readonly ExcalidrawElement[],
|
||||
rsvg: RoughSVG,
|
||||
svgRoot: SVGElement,
|
||||
{
|
||||
offsetX = 0,
|
||||
offsetY = 0,
|
||||
}: {
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
} = {},
|
||||
) {
|
||||
if (!svgRoot) {
|
||||
return;
|
||||
}
|
||||
// render elements
|
||||
elements.forEach(element => {
|
||||
renderElementToSvg(
|
||||
element,
|
||||
rsvg,
|
||||
svgRoot,
|
||||
element.x + offsetX,
|
||||
element.y + offsetY,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue