UNPKG

svgdom

Version:

Straightforward DOM implementation for SVG, HTML and XML

299 lines (249 loc) 10.8 kB
import * as pathUtils from './pathUtils.js' import * as regex from './regex.js' import * as textUtils from './textUtils.js' import { NoBox } from '../other/Box.js' import { NodeIterator } from './NodeIterator.js' import { NodeFilter } from '../dom/NodeFilter.js' const applyTransformation = (segments, node, applyTransformations) => { if (node.matrixify && applyTransformations) { return segments.transform(node.matrixify()) } return segments } export const getSegments = (node, applyTransformations, rbox = false) => { const segments = getPathSegments(node, rbox) return applyTransformation(segments, node, applyTransformations) } const getPathSegments = (node, rbox) => { if (node.nodeType !== 1) return new pathUtils.PathSegmentArray() switch (node.nodeName) { case 'rect': case 'image': case 'pattern': case 'mask': case 'foreignObject': // Create Path from rect and create PointCloud from Path return pathUtils.getPathSegments(pathUtils.pathFrom.rect(node)) case 'svg': case 'symbol': // return pathUtils.getPathSegments(pathUtils.pathFrom.rect(node)) if (rbox) { return pathUtils.getPathSegments(pathUtils.pathFrom.rect(node)) } // ATTENTION: FALL THROUGH // Because normal bbox is calculated by the content of the element and not its width and height // eslint-disable-next-line case 'g': case 'clipPath': case 'a': case 'marker': // Iterate trough all children and get the point cloud of each // Then transform it with viewbox matrix if needed return node.childNodes.reduce((segments, child) => { if (!child.matrixify) return segments return segments.merge(getSegments(child, true).transform(child.generateViewBoxMatrix())) }, new pathUtils.PathSegmentArray()) case 'circle': return pathUtils.getPathSegments(pathUtils.pathFrom.circle(node)) case 'ellipse': return pathUtils.getPathSegments(pathUtils.pathFrom.ellipse(node)) case 'line': return pathUtils.getPathSegments(pathUtils.pathFrom.line(node)) case 'polyline': case 'polygon': return pathUtils.getPathSegments(pathUtils.pathFrom.polyline(node)) case 'path': case 'glyph': case 'missing-glyph': return pathUtils.getPathSegments(node.getAttribute('d')) case 'use': { // Get reference from element const ref = node.getAttribute('href') || node.getAttribute('xlink:href') // Get the actual referenced Node const refNode = node.getRootNode().querySelector(ref) // Get the BBox of the referenced element and apply the viewbox of <use> // TODO: Do we need to apply the transformations of the element? // Check bbox of transformed element which is reused with <use> return getSegments(refNode).transform(node.generateViewBoxMatrix()) } case 'tspan': case 'text': case 'altGlyph': { const box = getTextBBox(node) if (box instanceof NoBox) { return new pathUtils.PathSegmentArray() } return pathUtils.getPathSegments(pathUtils.pathFrom.box(box)) } default: return new pathUtils.PathSegmentArray() } } const getTextBBox = (node) => { const textRoot = findTextRoot(node) const boxes = getTextBBoxes(node, textRoot) return boxes.filter(isNotEmptyBox).reduce((last, curr) => last.merge(curr), new NoBox()) } const findTextRoot = (node) => { while (node.parentNode) { if ((node.nodeName === 'text' && node.parentNode.nodeName === 'text') || ((node.nodeName === 'tspan' || node.nodeName === 'textPath') && [ 'tspan', 'text', 'textPath' ].includes(node.parentNode.nodeName))) { node = node.parentNode } else { break } } return node } // This function takes a node of which the bbox needs to be calculated // In order to position the box correctly, we need to know were the parent and were the siblings *before* our node are // Thats why a textRoot is passed which is the most outer textElement needed to calculate all boxes // When the iterator hits the element we need the bbox of, it is terminated and this function is called again // only for the substree of our node and without textRoor but instead pos, dx and dy are known const getTextBBoxes = function (target, textRoot = target, pos = { x: 0, y: 0 }, dx = [ 0 ], dy = [ 0 ], boxes = []) { // Create NodeIterator. Only show elemnts and text and skip descriptive elements // TODO: make an instanceof check for DescriptiveElement instead of testing one by one // Only title is skipped atm const iter = new NodeIterator(textRoot, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, (node) => { if (node.nodeName === 'title') return NodeFilter.FILTER_IGNORE return NodeFilter.FILTER_ACCEPT }) // Iterate trough all nodes top to bottom, left to right for (const node of iter) { // If we hit our target, we gathered all positional information we need to move the bbox to the correct spot if (node === target && node !== textRoot) { return getTextBBoxes(node, node, pos, dx, dy) } // Traverse trough this node updating positions and add boxes getPositionDetailsFor(node, pos, dx, dy, boxes) } return boxes } const isNotEmptyBox = box => box.x !== 0 || box.y !== 0 || box.width !== 0 || box.height !== 0 // This function either updates pos, dx and dy (when its an element) or calculates the boxes for text with the passed arguments // All arguments are passed by reference so dont overwrite them (treat them as const!) // TODO: Break this into two functions? const getPositionDetailsFor = (node, pos, dx, dy, boxes) => { if (node.nodeType === node.ELEMENT_NODE) { const x = parseFloat(node.getAttribute('x')) const y = parseFloat(node.getAttribute('y')) pos.x = isNaN(x) ? pos.x : x pos.y = isNaN(y) ? pos.y : y const dx0 = (node.getAttribute('dx') || '').split(regex.delimiter).filter(num => num !== '').map(parseFloat) const dy0 = (node.getAttribute('dy') || '').split(regex.delimiter).filter(num => num !== '').map(parseFloat) // TODO: eventually replace only as much values as we have text chars (node.textContent.length) because we could end up adding to much // replace initial values with node values if present dx.splice(0, dx0.length, ...dx0) dy.splice(0, dy0.length, ...dy0) } else { // get text data const data = node.data let j = 0 const jl = data.length const details = getFontDetails(node) // if it is more than one dx/dy single letters are moved by the amount (https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/dx) if (dy.length || dx.length) { for (;j < jl; j++) { // Calculate a box for a single letter boxes.push(textUtils.textBBox(data.substr(j, 1), pos.x, pos.y, details)) // Add the next position to current one pos.x += dx.shift() || 0 pos.y += dy.shift() || 0 if (!dy.length && !dx.length) break } } // in case it was only one dx/dy or no more dx/dy move the rest of the text boxes.push(textUtils.textBBox(data.substr(j), pos.x, pos.y, details)) pos.x += boxes[boxes.length - 1].width } } /* // this function is passing dx and dy values by references. Dont assign new values to it const textIterator = function (node, pos = { x: 0, y: 0 }, dx = [ 0 ], dy = [ 0 ]) { var x = parseFloat(node.getAttribute('x')) var y = parseFloat(node.getAttribute('y')) pos.x = isNaN(x) ? pos.x : x pos.y = isNaN(y) ? pos.y : y var dx0 = (node.getAttribute('dx') || '').split(regex.delimiter).filter(num => num !== '').map(parseFloat) var dy0 = (node.getAttribute('dy') || '').split(regex.delimiter).filter(num => num !== '').map(parseFloat) var boxes = [] var data = '' // TODO: eventually replace only as much values as we have text chars (node.textContent.length) because we could end up adding to much // replace initial values with node values if present dx.splice(0, dx0.length, ...dx0) dy.splice(0, dy0.length, ...dy0) var i = 0 var il = node.childNodes.length // iterate through all children for (; i < il; ++i) { // shift next child pos.x += dx.shift() || 0 pos.y += dy.shift() || 0 // text if (node.childNodes[i].nodeType === node.TEXT_NODE) { // get text data data = node.childNodes[i].data let j = 0 const jl = data.length // if it is more than one dx/dy single letters are moved by the amount (https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/dx) if (dy.length || dx.length) { for (;j < jl; j++) { boxes.push(textUtils.textBBox(data.substr(j, 1), pos.x, pos.y, getFontDetails(node))) pos.x += dx.shift() || 0 pos.y += dy.shift() || 0 if (!dy.length && !dx.length) break } } // in case it was only one dx/dy or no more dx/dy move the rest of the text boxes.push(textUtils.textBBox(data.substr(j), pos.x, pos.y, getFontDetails(node))) pos.x += boxes[boxes.length - 1].width // element } else { // in case of element, recursively call function again with new start values boxes = boxes.concat(textIterator(node.childNodes[i], pos, dx, dy)) } } return boxes } */ const getFontDetails = (node) => { if (node.nodeType === node.TEXT_NODE) node = node.parentNode let fontSize = null let fontFamily = null let textAnchor = null let dominantBaseline = null const textContentElements = [ 'text', 'tspan', 'tref', 'textPath', 'altGlyph', 'g' ] do { // TODO: stop on if (!fontSize) { fontSize = node.style.fontSize || node.getAttribute('font-size') } if (!fontFamily) { fontFamily = node.style.fontFamily || node.getAttribute('font-family') } if (!textAnchor) { textAnchor = node.style.textAnchor || node.getAttribute('text-anchor') } if (!dominantBaseline) { dominantBaseline = node.style.dominantBaseline || node.getAttribute('dominant-baseline') } // TODO: check for alignment-baseline in tspan, tref, textPath, altGlyph // TODO: alignment-adjust, baseline-shift /* if(!alignmentBaseline) alignmentBaseline = this.style.alignmentBaseline || this.getAttribute('alignment-baseline') */ } while ( (node = node.parentNode) && node.nodeType === node.ELEMENT_NODE && (textContentElements.includes(node.nodeName)) ) return { fontFamily, fontSize, textAnchor: textAnchor || 'start', // TODO: use central for writing-mode === horizontal https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/dominant-baseline dominantBaseline: dominantBaseline || 'alphabetical' // fontFamilyMappings: this.ownerDocument.fontFamilyMappings, // fontDir: this.ownerDocument.fontDir, // preloaded: this.ownerDocument._preloaded } }