text-to-svg-hb
Version:
Convert text to SVG paths with HarfBuzz support for complex text shaping
187 lines (186 loc) • 7.99 kB
JavaScript
;
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
};
}