UNPKG

albert-svg

Version:

Dynamic SVG generation using Cassowary constraints

242 lines (207 loc) 6.94 kB
// Copyright 2019 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import { Expression } from "cassowary"; import { createElement, omit, withTemporarySvg } from "./utils"; import { variable } from "./helpers"; function createSlice(start, end, attributes) { return { start, end, attributes }; } export default class Text { constructor(text, attributes = {}) { this.text_ = text; this.attributes_ = omit(attributes, ["x", "y", "font-size"]); this.slices_ = []; // By default there will be no spans const idPrefix = attributes.id ? attributes.id + ":" : ""; this.x = variable(idPrefix + "text.x", attributes.x); this.y = variable(idPrefix + "text.y", attributes.y); this.fontSize = variable( idPrefix + "text.fontSize", attributes["font-size"] || 16 ); this.ratios = { width: 0, height: 0, left: 0, top: 0, bottom: 0 }; this.width = null; this.height = null; this.leftEdge = null; this.topEdge = null; this.rightEdge = null; this.bottomEdge = null; this.centerX = null; this.centerY = null; this.baseline = new Expression(this.y); this.adjustDimensions_(); } setAttributes(attributes) { Object.assign(this.attributes_, omit(attributes, ["x", "y", "font-size"])); if (Object.keys(attributes).some(attr => attr.startsWith("font"))) { this.adjustDimensions_(); } return this; } setText(text) { this.text_ = text; this.slices_ = []; this.adjustDimensions_(); return this; } lineHeight(multiplier = 1) { return new Expression(this.fontSize).times(multiplier); } format(start, end, attributes = {}) { if (start >= end) { throw new Error( `Invalid start or end passed to Text.format(): (${start}, ${end})` ); } if ("font-family" in attributes) { throw new Error( "Changing the font-family for parts of Text is not supported" ); } if ( "font-size" in attributes && !/(%|em|ex|ch)$/.test(attributes["font-size"]) ) { throw new Error( "Changing the font-size is only supported for relative units (%, em, ex, ch)" ); } for (let i = 0; i < this.slices_.length; i++) { const slice = this.slices_[i]; if (end <= slice.start) { // Does not overlap from the left side: // Add before the existing slice. this.slices_.splice(i, 0, createSlice(start, end, attributes)); return this; } if (start >= slice.end) { // Does not overlap on the right side: // Check with the next slice. continue; } // Overlaps: // Split into max three and replace the current. const newSlices = []; if (start < slice.start) { newSlices.push(createSlice(start, slice.start, attributes)); } else if (start > slice.start) { newSlices.push(createSlice(slice.start, start, slice.attributes)); } newSlices.push( createSlice( Math.max(start, slice.start), Math.min(end, slice.end), Object.assign({}, slice.attributes, attributes) ) ); if (end < slice.end) { newSlices.push(createSlice(end, slice.end, slice.attributes)); } this.slices_.splice(i, 1, ...newSlices); if (end > slice.end) { start = slice.end; i += newSlices.length - 1; continue; } return this; } // Didn't find a slice to merge with: // Add a new one at the end. this.slices_.push(createSlice(start, end, attributes)); if (Object.keys(attributes).some(attr => attr.startsWith("font"))) { this.adjustDimensions_(); } return this; } formatRegexp(regexp, attributes = {}) { if (typeof regexp === "string") { regexp = new RegExp(regexp); } let matches; let stop = false; while (!stop && (matches = regexp.exec(this.text_)) !== null) { this.format(matches.index, matches.index + matches[0].length, attributes); stop = !regexp.global; } return this; } render() { const el = createElement("text", this.attributes_); if (this.slices_.length) { this.renderSlices_(el); } else { el.appendChild(document.createTextNode(this.text_)); } el.setAttributeNS(null, "x", this.x.value); el.setAttributeNS(null, "y", this.y.value); el.setAttributeNS(null, "font-size", this.fontSize.value); return el; } renderSlices_(el) { let position = 0; for (const { start, end, attributes } of this.slices_) { if (position < start) { el.appendChild( document.createTextNode(this.text_.slice(position, start)) ); position = start; } const span = createElement("tspan", attributes); span.textContent = this.text_.slice(position, end); el.appendChild(span); position = end; } if (position < this.text_.length) { el.appendChild(document.createTextNode(this.text_.slice(position))); } } adjustDimensions_() { const el = this.render(); withTemporarySvg(el, text => { const bbox = text.getBBox(); const widthRatio = bbox.width / this.fontSize.value; const heightRatio = bbox.height / this.fontSize.value; const leftEdgeRatio = (this.x.value - bbox.x) / this.fontSize.value; const topEdgeRatio = (this.y.value - bbox.y) / this.fontSize.value; this.width = new Expression(this.fontSize).times( new Expression(widthRatio) ); this.height = new Expression(this.fontSize).times( new Expression(heightRatio) ); this.leftEdge = new Expression(this.x).minus( new Expression(this.fontSize).times(new Expression(leftEdgeRatio)) ); this.topEdge = new Expression(this.y).minus( new Expression(this.fontSize).times(new Expression(topEdgeRatio)) ); this.rightEdge = this.leftEdge.plus(this.width); this.bottomEdge = this.topEdge.plus(this.height); this.centerX = this.leftEdge.plus(this.width.divide(2)); this.centerY = this.topEdge.plus(this.height.divide(2)); this.ratios = { width: widthRatio, height: heightRatio, left: leftEdgeRatio, top: topEdgeRatio, bottom: (bbox.y + bbox.height - this.y.value) / this.fontSize.value }; }); } }