graph-explorer
Version:
Graph Explorer can be used to explore and RDF graphs in SPARQL endpoints or on the web.
512 lines (447 loc) • 15.3 kB
text/typescript
/* eslint-disable @typescript-eslint/prefer-for-of */
import { uniqueId } from "lodash";
import { DiagramModel } from "../diagram/model";
import { Rect, Vector, boundsOf } from "../diagram/geometry";
const SVG_NAMESPACE = "http://www.w3.org/2000/svg" as const;
export interface ToSVGOptions {
model: DiagramModel;
paper: SVGSVGElement;
contentBox: Rect;
getOverlayedElement: (id: string) => HTMLElement;
preserveDimensions?: boolean;
convertImagesToDataUris?: boolean;
blacklistedCssAttributes?: string[];
elementsToRemoveSelector?: string;
mockImages?: boolean;
watermarkSvg?: string;
}
interface Bounds {
width: number;
height: number;
}
/**
* Padding (in px) for <foreignObject> elements of exported SVG to
* mitigate issues with elements body overflow caused by missing styles
* in exported image.
*/
const FOREIGN_OBJECT_SIZE_PADDING = 2;
const BORDER_PADDING = 100;
export function toSVG(options: ToSVGOptions): Promise<string> {
return exportSVG(options).then((svg) =>
new XMLSerializer().serializeToString(svg)
);
}
function exportSVG(options: ToSVGOptions): Promise<SVGElement> {
const { contentBox: bbox, watermarkSvg } = options;
const { svgClone, imageBounds } = clonePaperSvg(
options,
FOREIGN_OBJECT_SIZE_PADDING
);
const paddedWidth = bbox.width + 2 * BORDER_PADDING;
const paddedHeight = bbox.height + 2 * BORDER_PADDING;
if (options.preserveDimensions) {
svgClone.setAttribute("width", paddedWidth.toString());
svgClone.setAttribute("height", paddedHeight.toString());
} else {
svgClone.setAttribute("width", "100%");
svgClone.setAttribute("height", "100%");
}
const viewBox: Rect = {
x: bbox.x - BORDER_PADDING,
y: bbox.y - BORDER_PADDING,
width: paddedWidth,
height: paddedHeight,
};
svgClone.setAttribute(
"viewBox",
`${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`
);
addWatermark(svgClone, viewBox, watermarkSvg);
const images: HTMLImageElement[] = [];
const nodes = svgClone.querySelectorAll("img");
foreachNode(nodes, (node) => images.push(node));
const convertingImages = Promise.all(
images.map((img) => {
const exportKey = img.getAttribute("export-key");
img.removeAttribute("export-key");
if (exportKey) {
const { width, height } = imageBounds[exportKey];
img.setAttribute("width", width.toString());
img.setAttribute("height", height.toString());
if (!options.convertImagesToDataUris) {
return Promise.resolve();
}
return exportAsDataUri(img)
.then((dataUri) => {
// check for empty svg data URI which happens when mockJointXHR catches an exception
if (dataUri && dataUri !== "data:image/svg+xml,") {
img.src = dataUri;
}
})
.catch((err) => {
console.warn("Failed to export image: " + img.src, err);
});
} else {
return Promise.resolve();
}
})
);
return convertingImages.then(() => {
// workaround to include only graph-explorer-related stylesheets
const exportedCssText = extractCSSFromDocument(svgClone);
const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
defs.innerHTML = `<style>${exportedCssText}</style>`;
svgClone.insertBefore(defs, svgClone.firstChild);
if (options.elementsToRemoveSelector) {
foreachNode(
svgClone.querySelectorAll(options.elementsToRemoveSelector),
(node) => node.remove()
);
}
return svgClone;
});
}
function addWatermark(svg: SVGElement, viewBox: Rect, watermarkSvg: string) {
const WATERMARK_CLASS = "graph-explorer-exported-watermark";
const WATERMARK_MAX_WIDTH = 120;
const WATERMARK_PADDING = 20;
const image = document.createElementNS(SVG_NAMESPACE, "image");
image.setAttributeNS("http://www.w3.org/1999/xlink", "href", watermarkSvg);
image.setAttribute("class", WATERMARK_CLASS);
const width = Math.min(viewBox.width * 0.2, WATERMARK_MAX_WIDTH);
const x = viewBox.x + viewBox.width - width - WATERMARK_PADDING;
const y = viewBox.y + WATERMARK_PADDING;
image.setAttribute("x", x.toString());
image.setAttribute("y", y.toString());
image.setAttribute("width", width.toString());
image.setAttribute("opacity", "0.3");
svg.insertBefore(image, svg.firstChild);
}
function _clearAttributes(svg: SVGElement) {
const availableIds: Record<string, boolean> = {};
const defss = svg.querySelectorAll("defs");
foreachNode(defss, (defs) => {
foreachNode(defs.childNodes, (def) => {
const id = (def as SVGElement).getAttribute("id");
if (id) {
availableIds[id] = true;
}
});
});
const paths = svg.querySelectorAll("*");
foreachNode(paths, (path) => {
const markerStart = extractId(path.getAttribute("marker-start"));
if (markerStart && !availableIds[markerStart]) {
path.removeAttribute("marker-start");
}
const markerEnd = extractId(path.getAttribute("marker-end"));
if (markerEnd && !availableIds[markerEnd]) {
path.removeAttribute("marker-end");
}
const filterId = extractId(path.getAttribute("filter"));
if (filterId && !availableIds[filterId]) {
path.removeAttribute("filter");
}
});
function extractId(attributeValue: string) {
if (attributeValue) {
return (attributeValue.match(/#(.*?)\)/) || [])[1];
}
return undefined;
}
}
function extractCSSFromDocument(targetSubtree: Element): string {
const exportedRules = new Set<CSSStyleRule>();
for (let i = 0; i < document.styleSheets.length; i++) {
let rules: CSSRuleList;
try {
const cssSheet = document.styleSheets[i] as CSSStyleSheet;
rules = cssSheet.cssRules || cssSheet.rules;
if (!rules) {
continue;
}
} catch (_e) {
continue;
}
for (let j = 0; j < rules.length; j++) {
const rule = rules[j];
if (rule instanceof CSSStyleRule) {
if (targetSubtree.querySelector(rule.selectorText)) {
exportedRules.add(rule);
}
}
}
}
const exportedCssTexts: string[] = [];
exportedRules.forEach((rule) => exportedCssTexts.push(rule.cssText));
return exportedCssTexts.join("\n");
}
function clonePaperSvg(
options: ToSVGOptions,
elementSizePadding: number
): {
svgClone: SVGSVGElement;
imageBounds: Record<string, Bounds>;
} {
const { model, paper, getOverlayedElement } = options;
const svgClone = paper.cloneNode(true) as SVGSVGElement;
svgClone.removeAttribute("class");
svgClone.removeAttribute("style");
function findViewport() {
let child = svgClone.firstChild;
while (child) {
if (child instanceof SVGGElement) {
return child;
}
child = child.nextSibling;
}
return undefined;
}
const viewport = findViewport();
viewport.removeAttribute("transform");
const imageBounds: Record<string, Bounds> = {};
for (const element of model.elements) {
const modelId = element.id;
const overlayedView = getOverlayedElement(modelId);
if (!overlayedView) {
continue;
}
const elementRoot = document.createElementNS(SVG_NAMESPACE, "g");
const overlayedViewContent = overlayedView.firstChild.cloneNode(
true
) as HTMLElement;
elementRoot.setAttribute("class", "graph-explorer-exported-element");
const newRoot = document.createElementNS(SVG_NAMESPACE, "foreignObject");
newRoot.appendChild(overlayedViewContent);
const { x, y, width, height } = boundsOf(element);
newRoot.setAttribute("transform", `translate(${x},${y})`);
newRoot.setAttribute("width", (width + elementSizePadding).toString());
newRoot.setAttribute("height", (height + elementSizePadding).toString());
elementRoot.appendChild(newRoot);
viewport.appendChild(elementRoot);
const clonedNodes = overlayedViewContent.querySelectorAll("img");
foreachNode(overlayedView.querySelectorAll("img"), (img, index) => {
const exportKey = uniqueId("export-key-");
clonedNodes[index].setAttribute("export-key", exportKey);
imageBounds[exportKey] = {
width: img.clientWidth,
height: img.clientHeight,
};
});
}
return { svgClone, imageBounds };
}
function exportAsDataUri(original: HTMLImageElement): Promise<string> {
const url = original.src;
if (!url || url.startsWith("data:")) {
return Promise.resolve(url);
}
return loadCrossOriginImage(original.src).then((image) => {
const canvas = document.createElement("canvas");
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext("2d");
context.drawImage(image, 0, 0);
// match extensions like htttp://example.com/images/foo.JPG&w=200
const extensionMatch = url.match(/\.([a-zA-Z0-9]+)[^.a-zA-Z0-9]?[^.]*$/);
const extension = extensionMatch ? extensionMatch[1].toLowerCase() : "png";
try {
const mimeType = "image/" + (extension === "jpg" ? "jpeg" : extension);
const dataUri = canvas.toDataURL(mimeType);
return Promise.resolve(dataUri);
} catch (_e) {
if (extension !== "svg") {
return Promise.reject("Failed to convert image to data URI");
}
return fetch(url)
.then((response) => response.text())
.then((svg) =>
svg.length > 0 ? "data:image/svg+xml," + encodeURIComponent(svg) : ""
);
}
});
}
function loadCrossOriginImage(src: string): Promise<HTMLImageElement> {
const image = new Image();
image.crossOrigin = "anonymous";
const promise = new Promise<HTMLImageElement>((resolve, reject) => {
image.onload = () => resolve(image);
image.onerror = (ev) => reject(ev);
});
image.src = src;
return promise;
}
function foreachNode<T extends Node>(
nodeList: NodeListOf<T>,
callback: (node?: T, index?: number) => void
) {
for (let i = 0; i < nodeList.length; i++) {
callback(nodeList[i], i);
}
}
export interface ToDataURLOptions {
/** 'image/png' | 'image/jpeg' | ... */
mimeType?: string;
width?: number;
height?: number;
/** Background color, transparent by default. */
backgroundColor?: string;
quality?: number;
}
const MAX_CANVAS_LENGTH = 4096;
export async function toDataURL(
options: ToSVGOptions & ToDataURLOptions
): Promise<string> {
const { paper: _paper, mimeType = "image/png" } = options;
const svgOptions = {
...options,
convertImagesToDataUris: true,
mockImages: false,
preserveDimensions: true,
};
const svg = await exportSVG(svgOptions);
const svgBox: Bounds = {
width: Number(svg.getAttribute("width")),
height: Number(svg.getAttribute("height")),
};
const containerSize =
typeof options.width === "number" || typeof options.height === "number"
? { width: options.width, height: options.height }
: fallbackContainerSize(svgBox);
const { innerSize, outerSize, offset } = computeAutofit(
svgBox,
containerSize
);
svg.setAttribute("width", innerSize.width.toString());
svg.setAttribute("height", innerSize.height.toString());
const svgString = new XMLSerializer().serializeToString(svg);
const { canvas, context } = createCanvas(
outerSize.width,
outerSize.height,
options.backgroundColor
);
const image = await loadImage(
"data:image/svg+xml," + encodeURIComponent(svgString)
);
context.drawImage(
image,
offset.x,
offset.y,
innerSize.width,
innerSize.height
);
return canvas.toDataURL(mimeType, options.quality);
function createCanvas(
canvasWidth: number,
canvasHeight: number,
backgroundColor?: string
) {
const cnv = document.createElement("canvas");
cnv.width = canvasWidth;
cnv.height = canvasHeight;
const cnt = cnv.getContext("2d");
if (backgroundColor) {
cnt.fillStyle = backgroundColor;
cnt.fillRect(0, 0, canvasWidth, canvasHeight);
}
return { canvas: cnv, context: cnt };
}
}
export function loadImage(source: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = function () {
resolve(image);
};
image.onerror = function (ev) {
reject(ev);
};
image.src = source;
});
}
function computeAutofit(itemSize: Bounds, containerSize: Bounds) {
const fit = fitRectKeepingAspectRatio(
itemSize.width,
itemSize.height,
containerSize.width,
containerSize.height
);
const innerSize: Bounds = {
width: Math.floor(fit.width),
height: Math.floor(fit.height),
};
const outerSize: Bounds = {
width:
typeof containerSize.width === "number"
? containerSize.width
: innerSize.width,
height:
typeof containerSize.height === "number"
? containerSize.height
: innerSize.height,
};
const offset: Vector = {
x: Math.round((outerSize.width - innerSize.width) / 2),
y: Math.round((outerSize.height - innerSize.height) / 2),
};
return { innerSize, outerSize, offset };
}
function fallbackContainerSize(itemSize: Bounds): Bounds {
const maxResolutionScale = Math.min(
MAX_CANVAS_LENGTH / itemSize.width,
MAX_CANVAS_LENGTH / itemSize.height
);
const resolutionScale = Math.min(2.0, maxResolutionScale);
const width = Math.floor(itemSize.width * resolutionScale);
const height = Math.floor(itemSize.height * resolutionScale);
return { width, height };
}
export function fitRectKeepingAspectRatio(
sourceWidth: number,
sourceHeight: number,
targetWidth: number | undefined,
targetHeight: number | undefined
): { width: number; height: number } {
if (!(typeof targetWidth === "number" || typeof targetHeight === "number")) {
return { width: sourceWidth, height: sourceHeight };
}
const sourceAspectRatio = sourceWidth / sourceHeight;
targetWidth =
typeof targetWidth === "number"
? targetWidth
: targetHeight * sourceAspectRatio;
targetHeight =
typeof targetHeight === "number"
? targetHeight
: targetWidth / sourceAspectRatio;
if (targetHeight * sourceAspectRatio <= targetWidth) {
return { width: targetHeight * sourceAspectRatio, height: targetHeight };
} else {
return { width: targetWidth, height: targetWidth / sourceAspectRatio };
}
}
/**
* Creates and returns a blob from a data URL (either base64 encoded or not).
*
* @param {string} dataURL The data URL to convert.
* @return {Blob} A blob representing the array buffer data.
*/
export function dataURLToBlob(dataURL: string): Blob {
const BASE64_MARKER = ";base64,";
if (dataURL.indexOf(BASE64_MARKER) === -1) {
const parts = dataURL.split(",");
const contentType = parts[0].split(":")[1];
const raw = decodeURIComponent(parts[1]);
return new Blob([raw], { type: contentType });
} else {
const parts = dataURL.split(BASE64_MARKER);
const contentType = parts[0].split(":")[1];
const raw = window.atob(parts[1]);
const rawLength = raw.length;
const uInt8Array = new Uint8Array(rawLength);
for (let i = 0; i < rawLength; ++i) {
uInt8Array[i] = raw.charCodeAt(i);
}
return new Blob([uInt8Array], { type: contentType });
}
}