UNPKG

text-to-svg-hb

Version:

Convert text to SVG paths with HarfBuzz support for complex text shaping

187 lines (186 loc) 7.99 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.textToSVG = textToSVG; const fs_1 = __importDefault(require("fs")); const fontkit_1 = __importDefault(require("fontkit")); const svgo_1 = require("svgo"); const xmldom_1 = require("xmldom"); // Split text into lines with HarfBuzz shaping function splitTextIntoLines(hb, hbFont, font, text, fontSize, maxWidth, direction = 'ltr', script = 'latn', language = 'en') { const scale = fontSize / font.unitsPerEm; const paragraphs = text.split(/\r?\n/); const lines = []; for (const para of paragraphs) { const words = para.split(' '); let currentLine = ''; for (let i = 0; i < words.length; i++) { const testLine = currentLine ? currentLine + ' ' + words[i] : words[i]; const buffer = hb.createBuffer(); buffer.addText(testLine); buffer.setDirection(direction); buffer.setScript(script); buffer.setLanguage(language); hb.shape(hbFont, buffer); const shaped = buffer.json(hbFont); const lineWidth = shaped.reduce((sum, g) => sum + g.ax, 0) * scale; buffer.destroy(); if (lineWidth <= maxWidth || !currentLine) { currentLine = testLine; } else { lines.push(currentLine); currentLine = words[i]; } } if (currentLine) lines.push(currentLine); } return lines; } // Merge SVG paths from string content async function mergeSvgPathsFromContent(svgContent) { const parser = new xmldom_1.DOMParser(); const doc = parser.parseFromString(svgContent, 'image/svg+xml'); const { SVGPathData } = await Promise.resolve().then(() => __importStar(require('svg-pathdata'))); const paths = doc.getElementsByTagName('path'); let combinedPathData = ''; for (let i = 0; i < paths.length; i++) { const d = paths[i].getAttribute('d'); if (!d) continue; const path = new SVGPathData(d); if (path.commands.length && path.commands[0].type !== SVGPathData.MOVE_TO) { combinedPathData += 'M0,0'; } combinedPathData += path.encode(); } return combinedPathData; } async function textToSVG(text, options) { const hb = await require('../hbmain.js'); const font = fontkit_1.default.openSync(options.fontPath); const fontData = fs_1.default.readFileSync(options.fontPath); const blob = hb.createBlob(new Uint8Array(fontData)); const face = hb.createFace(blob, 0); const hbFont = hb.createFont(face); hbFont.setScale(font.unitsPerEm, font.unitsPerEm); const fontSize = options.fontSize; const maxWidth = options.maxWidth; const padding = 10; const scale = fontSize / font.unitsPerEm; const effectiveWidth = maxWidth - 2 * padding; const lineHeight = options.lineHeight || 1.2; const direction = options.direction || 'ltr'; const script = options.script || 'latn'; const language = options.language || 'en'; const lines = splitTextIntoLines(hb, hbFont, font, text, fontSize, effectiveWidth, direction, script, language); const lineHeightPx = fontSize * lineHeight; let currentY = padding + font.ascent * scale; const { SVGPathData, encodeSVGPath } = await Promise.resolve().then(() => __importStar(require('svg-pathdata'))); const svgPaths = await Promise.all(lines.map(async (line) => { const buffer = hb.createBuffer(); buffer.addText(line); buffer.setDirection(direction); buffer.setScript(script); buffer.setLanguage(language); hb.shape(hbFont, buffer); const shaped = buffer.json(hbFont); const lineWidth = shaped.reduce((sum, g) => sum + g.ax * scale, 0); let x = options.textAlign === 'right' ? maxWidth - padding - lineWidth : options.textAlign === 'center' ? (maxWidth - lineWidth) / 2 : padding; const paths = []; for (const g of shaped) { const glyph = font.getGlyph(g.g); if (!(glyph === null || glyph === void 0 ? void 0 : glyph.path)) continue; const rawPath = glyph.path.toSVG(); const pathData = new SVGPathData(rawPath).toAbs(); const transformed = pathData.transform((cmd) => { if ('x' in cmd) cmd.x = x + g.dx * scale + cmd.x * scale; if ('y' in cmd) cmd.y = currentY - g.dy * scale - cmd.y * scale; if ('x1' in cmd) cmd.x1 = x + g.dx * scale + cmd.x1 * scale; if ('y1' in cmd) cmd.y1 = currentY - g.dy * scale - cmd.y1 * scale; if ('x2' in cmd) cmd.x2 = x + g.dx * scale + cmd.x2 * scale; if ('y2' in cmd) cmd.y2 = currentY - g.dy * scale - cmd.y2 * scale; return cmd; }); const encoded = encodeSVGPath(transformed.commands); paths.push(`<path d="${encoded}" fill="${options.color || '#000000'}" />`); x += g.ax * scale; } buffer.destroy(); currentY += lineHeightPx; return paths.join('\n'); })); // Calculate canvas dimensions const ascent = font.ascent * scale; const descent = Math.abs(font.descent * scale); const textHeight = ascent + descent + (lines.length - 1) * lineHeightPx; const canvasWidth = maxWidth + 2 * padding; const canvasHeight = padding * 2 + textHeight; // Final SVG content const rawSvg = `<?xml version="1.0"?> <svg xmlns="http://www.w3.org/2000/svg" width="${canvasWidth}" height="${canvasHeight}" viewBox="0 0 ${canvasWidth} ${canvasHeight}"> ${svgPaths.join('\n')} </svg>`; const optimized = (0, svgo_1.optimize)(rawSvg, { multipass: true }); let svg = optimized.data; if (options.combinePaths) { const d_value = await mergeSvgPathsFromContent(svg); svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${canvasWidth}" height="${canvasHeight}" viewBox="0 0 ${canvasWidth} ${canvasHeight}"><path d="${d_value}" fill="${options.color || '#000000'}" /></svg>`; } // Cleanup HarfBuzz hbFont.destroy(); face.destroy(); blob.destroy(); return { canvasWidth, canvasHeight, svg }; }