import { DOMParser, XMLSerializer, type Document as XmlDomDocument, type Element as XmlElement, } from "@xmldom/xmldom"; import { optimize } from "svgo"; import { calculateFontSizeForText } from "./calculateFontSizeForText"; export class SvgProcessor { private doc: XmlDomDocument; private svgElement: XmlElement; constructor( svgText: string, private optimizeOutput: boolean = false, ) { const parser = new DOMParser(); this.doc = parser.parseFromString(svgText, "image/svg+xml"); const svgElement = this.doc.getElementsByTagName("svg").item(0); if (!svgElement) { throw new Error("SVG не найден"); } this.svgElement = svgElement; } addPadding(padding: number, backgroundColor = "transparent"): SvgProcessor { const viewBox = this.svgElement .getAttribute("viewBox") ?.split(" ") .map(Number); if (!viewBox) { throw new Error("viewBox не найден"); } const [x, y, width, height] = viewBox; // Увеличиваем размеры viewBox с учётом отступов const newWidth = width + padding * 2; const newHeight = height + padding * 2; const newViewBox = [x, y, newWidth, newHeight].join(" "); this.svgElement.setAttribute("viewBox", newViewBox); // Добавляем прямоугольник для фона, если требуется if (backgroundColor !== "transparent") { const rect = this.doc.createElementNS( "http://www.w3.org/2000/svg", "rect", ); rect.setAttribute("x", x.toString()); rect.setAttribute("y", y.toString()); rect.setAttribute("width", newWidth.toString()); rect.setAttribute("height", newHeight.toString()); rect.setAttribute("fill", backgroundColor); this.svgElement.insertBefore(rect, this.svgElement.firstChild); } // Сдвигаем все элементы const elements = Array.from(this.svgElement.childNodes).filter( (node) => node.nodeType === 1 && node !== this.svgElement.firstChild, ) as XmlElement[]; elements.forEach((el) => { const isText = el.tagName === "text"; // Для текста добавляем дополнительное смещение if (isText) { const dy = el.getAttribute("dy") || "0"; if (!dy.includes("%")) { el.setAttribute("dy", (parseFloat(dy) - padding).toString()); } const dx = el.getAttribute("dx") || "0"; if (!dx.includes("%") && !el.getAttribute("x")?.includes("%")) { el.setAttribute("dx", padding.toString()); } } else { const transform = el.getAttribute("transform") || ""; el.setAttribute( "transform", `translate(${padding}, ${padding}) ${transform}`.trim(), ); } }); // Удаляем атрибуты width и height, чтобы размеры SVG задавались только через viewBox this.svgElement.removeAttribute("width"); this.svgElement.removeAttribute("height"); return this; } addLabel( label: string, color: string, marginMultiplier: number = 0.1, ): SvgProcessor { if (!label) { return this; } const viewBoxAttr = this.svgElement.getAttribute("viewBox"); if (!viewBoxAttr) { throw new Error("viewBox не найден"); } const viewBox: readonly number[] = viewBoxAttr.split(" ").map(Number); const [x, y, width, height] = viewBox; if (viewBox.length === 4) { // Рассчитываем размер шрифта const fontSize = calculateFontSizeForText(label, width); const textMargin = Math.min(fontSize, height * marginMultiplier); const extraBottomPadding = fontSize * 0.3; // 30% дополнительного пространства для висячих символов // Увеличиваем высоту viewBox const newHeight = height + textMargin + fontSize; this.svgElement.setAttribute( "viewBox", [x, y, width, newHeight].join(" "), ); // Создаем элемент текста const textElement = this.doc.createElementNS( "http://www.w3.org/2000/svg", "text", ); textElement.setAttribute("x", "50%"); textElement.setAttribute("y", "100%"); textElement.setAttribute("dy", `-${extraBottomPadding}px`); textElement.setAttribute("text-anchor", "middle"); textElement.setAttribute("alignment-baseline", "baseline"); textElement.setAttribute("dominant-baseline", "alphabetic"); textElement.setAttribute("fill", color); textElement.setAttribute("font-size", fontSize.toString()); textElement.setAttribute("font-family", "Arial, sans-serif"); textElement.textContent = label; this.svgElement.appendChild(textElement); } // Удаляем атрибуты width и height, чтобы размеры SVG задавались только через viewBox this.svgElement.removeAttribute("width"); this.svgElement.removeAttribute("height"); return this; } replaceFill(fillColor: string): SvgProcessor { const elements = this.doc.getElementsByTagName("*"); for (let i = 0; i < elements.length; i++) { const el = elements.item(i); if (el && el.hasAttribute("fill")) { el.setAttribute("fill", fillColor); } } return this; } /** * Оптимизация SVG с использованием SVGO. * @param svgText string изображения SVG. * @returns Строка оптимизированного SVG. */ private optimizeSvg(svgText: string): string { const optimizedSvg = optimize(svgText, { plugins: [ { name: "removeDoctype" }, { name: "removeComments" }, { name: "removeDimensions" }, { name: "convertShapeToPath" }, { name: "convertPathData" }, { name: "mergePaths" }, ], }); if (!optimizedSvg.data) { throw new Error("Ошибка оптимизации SVG"); } return optimizedSvg.data; } toString(): string { const serializer = new XMLSerializer(); const result = serializer.serializeToString(this.doc); // console.log(result); if (this.optimizeOutput) { return this.optimizeSvg(result); } return result; } }