UNPKG

js-draw

Version:

Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.

686 lines (685 loc) 30.6 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.SVGLoaderLoadMethod = exports.svgLoaderAutoresizeClassName = exports.svgLoaderAttributeContainerID = exports.svgStyleAttributesDataKey = exports.svgAttributesDataKey = exports.defaultSVGViewRect = void 0; const math_1 = require("@js-draw/math"); const BackgroundComponent_1 = __importStar(require("../components/BackgroundComponent")); const ImageComponent_1 = __importDefault(require("../components/ImageComponent")); const Stroke_1 = __importDefault(require("../components/Stroke")); const SVGGlobalAttributesObject_1 = __importDefault(require("../components/SVGGlobalAttributesObject")); const TextComponent_1 = __importStar(require("../components/TextComponent")); const UnknownSVGObject_1 = __importDefault(require("../components/UnknownSVGObject")); const RenderablePathSpec_1 = require("../rendering/RenderablePathSpec"); const SVGRenderer_1 = require("../rendering/renderers/SVGRenderer"); const determineFontSize_1 = __importDefault(require("./utils/determineFontSize")); // Size of a loaded image if no size is specified. exports.defaultSVGViewRect = new math_1.Rect2(0, 0, 500, 500); // Key to retrieve unrecognised attributes from an AbstractComponent exports.svgAttributesDataKey = 'svgAttrs'; // Like {@link svgAttributesDataKey}, but for styles exports.svgStyleAttributesDataKey = 'svgStyleAttrs'; // Key that specifies the ID of an SVG element that contained a given node when the image // was first loaded. exports.svgLoaderAttributeContainerID = 'svgContainerID'; // If present in the exported SVG's class list, the image will be // autoresized when components are added/removed. exports.svgLoaderAutoresizeClassName = 'js-draw--autoresize'; // @internal var SVGLoaderLoadMethod; (function (SVGLoaderLoadMethod) { SVGLoaderLoadMethod["IFrame"] = "iframe"; SVGLoaderLoadMethod["DOMParser"] = "domparser"; })(SVGLoaderLoadMethod || (exports.SVGLoaderLoadMethod = SVGLoaderLoadMethod = {})); const supportedStrokeFillStyleAttrs = ['stroke', 'fill', 'stroke-width']; // Handles loading images from SVG. class SVGLoader { constructor(source, onFinish, options) { this.source = source; this.onFinish = onFinish; this.onAddComponent = null; this.onProgress = null; this.onDetermineExportRect = null; this.processedCount = 0; this.totalToProcess = 0; this.containerGroupIDs = []; this.encounteredIDs = []; this.plugins = options.plugins ?? []; this.storeUnknown = !(options.sanitize ?? false); this.disableUnknownObjectWarnings = !!options.disableUnknownObjectWarnings; } // If [computedStyles] is given, it is preferred to directly accessing node's style object. getStyle(node, computedStyles) { let fill = math_1.Color4.transparent; let stroke; // If possible, use computedStyles (allows property inheritance). // Chromium, however, sets .fill to a falsy, but not undefined value in some cases where // styles are available. As such, use || instead of ??. const fillAttribute = node.getAttribute('fill') ?? (computedStyles?.fill || node.style?.fill); if (fillAttribute) { try { fill = math_1.Color4.fromString(fillAttribute); } catch { console.error('Unknown fill color,', fillAttribute); } } const strokeAttribute = node.getAttribute('stroke') ?? computedStyles?.stroke ?? node.style?.stroke ?? ''; const strokeWidthAttr = node.getAttribute('stroke-width') ?? computedStyles?.strokeWidth ?? node.style?.strokeWidth ?? ''; if (strokeAttribute && strokeWidthAttr) { try { let width = parseFloat(strokeWidthAttr ?? '1'); if (!isFinite(width)) { width = 0; } const strokeColor = math_1.Color4.fromString(strokeAttribute); if (strokeColor.a > 0) { stroke = { width, color: strokeColor, }; } } catch (e) { console.error('Error parsing stroke data:', e); } } const style = { fill, stroke, }; return style; } strokeDataFromElem(node) { const result = []; const pathData = node.getAttribute('d') ?? ''; const style = this.getStyle(node); // Break the path into chunks at each moveTo ('M') command: const parts = pathData.split('M'); let isFirst = true; for (const part of parts) { // Skip effective no-ops -- moveTos without additional commands. const isNoOpMoveTo = /^[0-9., \t\n]+$/.exec(part); if (part !== '' && !isNoOpMoveTo) { // We split the path by moveTo commands, so add the 'M' back in // if it was present. const current = !isFirst ? `M${part}` : part; const path = math_1.Path.fromString(current); const spec = (0, RenderablePathSpec_1.pathToRenderable)(path, style); result.push(spec); } isFirst = false; } return result; } attachUnrecognisedAttrs(elem, node, supportedAttrs, supportedStyleAttrs) { if (!this.storeUnknown) { return; } for (const attr of node.getAttributeNames()) { if (supportedAttrs.has(attr) || (attr === 'style' && supportedStyleAttrs)) { continue; } elem.attachLoadSaveData(exports.svgAttributesDataKey, [ attr, node.getAttribute(attr), ]); } if (supportedStyleAttrs && node.style) { // Use a for loop instead of an iterator: js-dom seems to not // support using node.style as an iterator. for (let i = 0; i < node.style.length; i++) { const attr = node.style[i]; if (attr === '' || !attr) { continue; } if (supportedStyleAttrs.has(attr)) { continue; } // TODO: Do we need special logic for !important properties? elem.attachLoadSaveData(exports.svgStyleAttributesDataKey, { key: attr, value: node.style.getPropertyValue(attr), priority: node.style.getPropertyPriority(attr), }); } } } // Adds a stroke with a single path async addPath(node) { let elem; try { const strokeData = this.strokeDataFromElem(node); elem = new Stroke_1.default(strokeData); this.attachUnrecognisedAttrs(elem, node, new Set([...supportedStrokeFillStyleAttrs, 'd']), new Set(supportedStrokeFillStyleAttrs)); } catch (e) { console.error('Invalid path in node', node, '\nError:', e, '\nAdding as an unknown object.'); if (this.storeUnknown) { elem = new UnknownSVGObject_1.default(node); } else { return; } } await this.addComponent(elem); } async addBackground(node) { // If a grid background, if (node.classList.contains(BackgroundComponent_1.backgroundTypeToClassNameMap[BackgroundComponent_1.BackgroundType.Grid])) { let foregroundStr; let backgroundStr; let gridStrokeWidthStr; // If a group, if (node.tagName.toLowerCase() === 'g') { // We expect exactly two children. One of these is the solid // background of the grid if (node.children.length !== 2) { await this.addUnknownNode(node); return; } const background = node.children[0]; const grid = node.children[1]; backgroundStr = background.getAttribute('fill'); foregroundStr = grid.getAttribute('stroke'); gridStrokeWidthStr = grid.getAttribute('stroke-width'); } else { backgroundStr = node.getAttribute('fill'); foregroundStr = node.getAttribute('stroke'); gridStrokeWidthStr = node.getAttribute('stroke-width'); } // Default to a transparent background. backgroundStr ??= math_1.Color4.transparent.toHexString(); // A grid must have a foreground color specified. if (!foregroundStr) { await this.addUnknownNode(node); return; } // Extract the grid size from the class name let gridSize = undefined; for (const className of node.classList) { if (className.startsWith(BackgroundComponent_1.imageBackgroundGridSizeCSSPrefix)) { const sizeStr = className.substring(BackgroundComponent_1.imageBackgroundGridSizeCSSPrefix.length); gridSize = parseFloat(sizeStr.replace(/p/g, '.')); } } let gridStrokeWidth = undefined; if (gridStrokeWidthStr) { gridStrokeWidth = parseFloat(gridStrokeWidthStr); } const backgroundColor = math_1.Color4.fromString(backgroundStr); let foregroundColor = math_1.Color4.fromString(foregroundStr); // Should the foreground color be determined automatically? if (!node.classList.contains(BackgroundComponent_1.imageBackgroundNonAutomaticSecondaryColorCSSClassName)) { foregroundColor = undefined; } const elem = BackgroundComponent_1.default.ofGrid(backgroundColor, gridSize, foregroundColor, gridStrokeWidth); await this.addComponent(elem); } // Otherwise, if just a <path/>, it's a solid color background. else if (node.tagName.toLowerCase() === 'path') { const fill = math_1.Color4.fromString(node.getAttribute('fill') ?? node.style.fill ?? 'black'); const elem = new BackgroundComponent_1.default(BackgroundComponent_1.BackgroundType.SolidColor, fill); await this.addComponent(elem); } else { await this.addUnknownNode(node); } } getComputedStyle(element) { try { // getComputedStyle may fail in jsdom when using a DOMParser. return window.getComputedStyle(element); } catch (error) { console.warn('Error computing style', error); return undefined; } } // If given, 'supportedAttrs' will have x, y, etc. attributes that were used in computing the transform added to it, // to prevent storing duplicate transform information when saving the component. getTransform(elem, supportedAttrs, computedStyles) { // If possible, load the js-draw specific transform attribute const highpTransformAttribute = 'data-highp-transform'; const rawTransformData = elem.getAttribute(highpTransformAttribute); let transform; if (rawTransformData) { try { transform = math_1.Mat33.fromCSSMatrix(rawTransformData); supportedAttrs?.push(highpTransformAttribute); } catch (e) { console.warn(`Unable to parse raw transform data, ${rawTransformData}. Falling back to CSS data. Error:`, e); } } if (!transform) { computedStyles ??= this.getComputedStyle(elem); let transformProperty = computedStyles?.transform; if (!transformProperty || transformProperty === 'none') { transformProperty = elem.style?.transform || 'none'; } // Prefer the actual .style.transform // to the computed stylesheet -- in some browsers, the computedStyles version // can have lower precision. try { transform = math_1.Mat33.fromCSSMatrix(elem.style.transform); } catch (_e) { console.warn('matrix parse error', _e); transform = math_1.Mat33.fromCSSMatrix(transformProperty); } const elemX = elem.getAttribute('x'); const elemY = elem.getAttribute('y'); if (elemX || elemY) { const x = parseFloat(elemX ?? '0'); const y = parseFloat(elemY ?? '0'); if (!isNaN(x) && !isNaN(y)) { supportedAttrs?.push('x', 'y'); transform = transform.rightMul(math_1.Mat33.translation(math_1.Vec2.of(x, y))); } } } return transform; } makeText(elem) { const contentList = []; for (const child of elem.childNodes) { if (child.nodeType === Node.TEXT_NODE) { contentList.push(child.nodeValue ?? ''); } else if (child.nodeType === Node.ELEMENT_NODE) { const subElem = child; if (subElem.tagName.toLowerCase() === 'tspan') { // FIXME: tspan's (x, y) components are absolute, not relative to the parent. contentList.push(this.makeText(subElem)); } else { throw new Error(`Unrecognized text child element: ${subElem}`); } } else { throw new Error(`Unrecognized text child node: ${child}.`); } } // If no content, the content is an empty string. if (contentList.length === 0) { contentList.push(''); } // Compute styles. const computedStyles = this.getComputedStyle(elem); const supportedStyleAttrs = new Set([ 'fontFamily', 'transform', ...supportedStrokeFillStyleAttrs, ]); const style = { size: (0, determineFontSize_1.default)(elem, computedStyles, supportedStyleAttrs), fontFamily: computedStyles?.fontFamily || elem.style?.fontFamily || 'sans-serif', fontWeight: computedStyles?.fontWeight || elem.style?.fontWeight || undefined, fontStyle: computedStyles?.fontStyle || elem.style?.fontStyle || undefined, renderingStyle: this.getStyle(elem, computedStyles), }; const supportedAttrs = []; let transform = this.getTransform(elem, supportedAttrs, computedStyles); let transformMode = TextComponent_1.TextTransformMode.ABSOLUTE_XY; const elemDX = elem.getAttribute('dx'); if (elemDX) { transformMode = TextComponent_1.TextTransformMode.RELATIVE_X_ABSOLUTE_Y; transform = transform.rightMul(math_1.Mat33.translation(math_1.Vec2.of(parseFloat(elemDX), 0))); supportedAttrs.push('dx'); } const elemDY = elem.getAttribute('dy'); if (elemDY) { if (transformMode === TextComponent_1.TextTransformMode.RELATIVE_X_ABSOLUTE_Y) { transformMode = TextComponent_1.TextTransformMode.RELATIVE_XY; } else { transformMode = TextComponent_1.TextTransformMode.RELATIVE_Y_ABSOLUTE_X; } transform = transform.rightMul(math_1.Mat33.translation(math_1.Vec2.of(0, parseFloat(elemDY)))); supportedAttrs.push('dy'); } const result = new TextComponent_1.default(contentList, transform, style, transformMode); this.attachUnrecognisedAttrs(result, elem, new Set(supportedAttrs), new Set(supportedStyleAttrs)); return result; } async addText(elem) { try { const textElem = this.makeText(elem); await this.addComponent(textElem); } catch (e) { console.error('Invalid text object in node', elem, '. Continuing.... Error:', e); this.addUnknownNode(elem); } } async addImage(elem) { const image = new Image(); image.src = elem.getAttribute('xlink:href') ?? elem.href.baseVal; image.setAttribute('alt', elem.getAttribute('aria-label') ?? ''); try { const supportedAttrs = []; const transform = this.getTransform(elem, supportedAttrs); const imageElem = await ImageComponent_1.default.fromImage(image, transform); this.attachUnrecognisedAttrs(imageElem, elem, new Set(supportedAttrs), new Set(['transform'])); await this.addComponent(imageElem); } catch (e) { console.error('Error loading image:', e, '. Element: ', elem, '. Continuing...'); await this.addUnknownNode(elem); } } async addUnknownNode(node) { if (this.storeUnknown) { const component = new UnknownSVGObject_1.default(node); await this.addComponent(component); } } async startGroup(node) { node = node.cloneNode(false); // Select a unique ID based on the node's ID property (if it exists). // Use `||` and not `??` so that empty string IDs are also replaced. let id = node.id || `id-${this.encounteredIDs.length}`; // Make id unique. let idSuffixCounter = 0; let suffix = ''; while (this.encounteredIDs.includes(id + suffix)) { idSuffixCounter++; suffix = '--' + idSuffixCounter; } id += suffix; // Remove all children from the node -- children will be handled separately // (not removing children here could cause duplicates in the result, when rendered). node.replaceChildren(); node.id = id; const component = new UnknownSVGObject_1.default(node); this.addComponent(component); // Add to IDs after -- we don't want the <g> element to be marked // as its own container. this.containerGroupIDs.push(node.id); this.encounteredIDs.push(node.id); } // Ends the most recent group started by .startGroup async endGroup() { this.containerGroupIDs.pop(); } async addComponent(component) { // Attach the stack of container IDs if (this.containerGroupIDs.length > 0) { component.attachLoadSaveData(exports.svgLoaderAttributeContainerID, [...this.containerGroupIDs]); } await this.onAddComponent?.(component); } updateViewBox(node) { const viewBoxAttr = node.getAttribute('viewBox'); if (this.rootViewBox || !viewBoxAttr) { return; } const components = viewBoxAttr.split(/[ \t\n,]+/); const x = parseFloat(components[0]); const y = parseFloat(components[1]); const width = parseFloat(components[2]); const height = parseFloat(components[3]); if (isNaN(x) || isNaN(y) || isNaN(width) || isNaN(height)) { console.warn(`node ${node} has an unparsable viewbox. Viewbox: ${viewBoxAttr}. Match: ${components}.`); return; } const autoresize = node.classList.contains(exports.svgLoaderAutoresizeClassName); this.rootViewBox = new math_1.Rect2(x, y, width, height); this.onDetermineExportRect?.(this.rootViewBox, { autoresize }); } async updateSVGAttrs(node) { if (this.storeUnknown) { await this.onAddComponent?.(new SVGGlobalAttributesObject_1.default(this.getSourceAttrs(node))); } } async visit(node) { this.totalToProcess += node.childElementCount; let visitChildren = true; const visitPlugin = async () => { for (const plugin of this.plugins) { const processed = await plugin.visit(node, { addComponent: (component) => { return this.onAddComponent?.(component); }, }); if (processed) { visitChildren = false; return true; } } return false; }; const visitBuiltIn = async () => { switch (node.tagName.toLowerCase()) { case 'g': if (node.classList.contains(BackgroundComponent_1.imageBackgroundCSSClassName)) { await this.addBackground(node); visitChildren = false; } else { await this.startGroup(node); } // Otherwise, continue -- visit the node's children. break; case 'path': if (node.classList.contains(BackgroundComponent_1.imageBackgroundCSSClassName)) { await this.addBackground(node); } else { await this.addPath(node); } break; case 'text': await this.addText(node); visitChildren = false; break; case 'image': await this.addImage(node); // Images should not have children. visitChildren = false; break; case 'svg': this.updateViewBox(node); this.updateSVGAttrs(node); break; case 'style': // Keeping unnecessary style sheets can cause the browser to keep all // SVG elements *referenced* by the style sheet in some browsers. // // Only keep the style sheet if it won't be discarded on save. if (node.getAttribute('id') !== SVGRenderer_1.renderedStylesheetId) { await this.addUnknownNode(node); } break; default: if (!this.disableUnknownObjectWarnings) { console.warn('Unknown SVG element,', node, node.tagName); if (!(node instanceof SVGElement)) { console.warn('Element', node, 'is not an SVGElement!', this.storeUnknown ? 'Continuing anyway.' : 'Skipping.'); } } await this.addUnknownNode(node); return; } }; if (await visitPlugin()) { visitChildren = false; } else { await visitBuiltIn(); } if (visitChildren) { for (const child of node.children) { await this.visit(child); } if (node.tagName.toLowerCase() === 'g') { await this.endGroup(); } } this.processedCount++; await this.onProgress?.(this.processedCount, this.totalToProcess); } // Get SVG element attributes (e.g. xlink=...) getSourceAttrs(node) { return node.getAttributeNames().map((attr) => { return [attr, node.getAttribute(attr)]; }); } async start(onAddComponent, onProgress, onDetermineExportRect = null) { this.onAddComponent = onAddComponent; this.onProgress = onProgress; this.onDetermineExportRect = onDetermineExportRect; // Estimate the number of tags to process. this.totalToProcess = this.source.childElementCount; this.processedCount = 0; this.rootViewBox = null; await this.visit(this.source); const viewBox = this.rootViewBox; if (!viewBox) { this.onDetermineExportRect?.(exports.defaultSVGViewRect); } this.onFinish?.(); this.onFinish = null; } /** * Create an `SVGLoader` from the content of an SVG image. SVGs are loaded within a sandboxed * iframe with `sandbox="allow-same-origin"` * [thereby disabling JavaScript](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#sandbox). * * @see {@link Editor.loadFrom} * @param text - Textual representation of the SVG (e.g. `<svg viewbox='...'>...</svg>`). * @param options - if `true` or `false`, treated as the `sanitize` option -- don't store unknown attributes. */ static fromString(text, options = false) { const domParserLoad = typeof options !== 'boolean' && options?.loadMethod === SVGLoaderLoadMethod.DOMParser; const { svgElem, cleanUp } = (() => { // If the user requested an iframe load (the default) try to load with an iframe. // There are some cases (e.g. in a sandboxed iframe) where this doesn't work. // TODO(v2): Use domParserLoad by default. if (!domParserLoad) { try { const sandbox = document.createElement('iframe'); sandbox.src = 'about:blank'; // allow-same-origin is necessary for how we interact with the sandbox. As such, // DO NOT ENABLE ALLOW-SCRIPTS. sandbox.setAttribute('sandbox', 'allow-same-origin'); sandbox.setAttribute('csp', "default-src 'about:blank'"); sandbox.style.display = 'none'; // Required to access the frame's DOM. See https://stackoverflow.com/a/17777943/17055750 document.body.appendChild(sandbox); if (!sandbox.hasAttribute('sandbox')) { sandbox.remove(); throw new Error('SVG loading iframe is not sandboxed.'); } const sandboxDoc = sandbox.contentWindow?.document ?? sandbox.contentDocument; if (sandboxDoc == null) throw new Error('Unable to open a sandboxed iframe!'); sandboxDoc.open(); sandboxDoc.write(` <!DOCTYPE html> <html> <head> <title>SVG Loading Sandbox</title> <meta name='viewport' conent='width=device-width,initial-scale=1.0'/> <meta charset='utf-8'/> </head> <body style='font-size: 12px;'> <script> console.error('JavaScript should not be able to run here!'); throw new Error( 'The SVG sandbox is broken! Please double-check the sandboxing setting.' ); </script> </body> </html> `); sandboxDoc.close(); const svgElem = sandboxDoc.createElementNS('http://www.w3.org/2000/svg', 'svg'); // eslint-disable-next-line no-unsanitized/property -- setting innerHTML in a sandboxed document. svgElem.innerHTML = text; sandboxDoc.body.appendChild(svgElem); const cleanUp = () => { svgElem.remove(); sandbox.remove(); sandbox.src = ''; }; return { svgElem, cleanUp }; } catch (error) { console.warn('Failed loading SVG via a sandboxed iframe. Some styles may not be loaded correctly. Error: ', error); } } // Fall back to creating a DOMParser const parser = new DOMParser(); const doc = parser.parseFromString(`<svg xmlns="http://www.w3.org/2000/svg">${text}</svg>`, 'text/html'); const svgElem = doc.querySelector('svg'); // Handle error messages reported while parsing. See // https://developer.mozilla.org/en-US/docs/Web/Guide/Parsing_and_serializing_XML const errorReportNode = doc.querySelector('parsererror'); if (errorReportNode) { throw new Error('Parse error: ' + errorReportNode.textContent); } const cleanUp = () => { }; return { svgElem, cleanUp }; })(); // Handle options let sanitize; let disableUnknownObjectWarnings; let plugins; if (typeof options === 'boolean') { sanitize = options; disableUnknownObjectWarnings = false; plugins = []; } else { sanitize = options.sanitize ?? false; disableUnknownObjectWarnings = options.disableUnknownObjectWarnings ?? false; plugins = options.plugins; } return new SVGLoader(svgElem, cleanUp, { sanitize, disableUnknownObjectWarnings, plugins, }); } } exports.default = SVGLoader;