Initial commit
This commit is contained in:
212
Docker Backend/src/lib/svg-font-engine.js
Normal file
212
Docker Backend/src/lib/svg-font-engine.js
Normal file
@@ -0,0 +1,212 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const config = require('../config');
|
||||
|
||||
// Cache je Fontdatei
|
||||
const FONT_CACHE = {};
|
||||
|
||||
// Attribut aus einem Tag lesen (mit Wortgrenze, um z.B. 'd' vs 'id' zu unterscheiden)
|
||||
function getAttr(tag, name) {
|
||||
const re = new RegExp(`\\b${name}="([^"]*)"`);
|
||||
const m = tag.match(re);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
// SVG-Font parsen und Metriken plus Glyphen extrahieren
|
||||
function parseSvgFont(fontFileName) {
|
||||
if (FONT_CACHE[fontFileName]) return FONT_CACHE[fontFileName];
|
||||
|
||||
const fontPath = path.join(config.paths.fonts, fontFileName);
|
||||
const svg = fs.readFileSync(fontPath, 'utf8');
|
||||
|
||||
const fontFaceMatch = svg.match(/<font-face[\s\S]*?>/);
|
||||
if (!fontFaceMatch) {
|
||||
throw new Error(`Kein <font-face> in ${fontFileName} gefunden`);
|
||||
}
|
||||
|
||||
const fontFaceTag = fontFaceMatch[0];
|
||||
const unitsPerEm = parseFloat(getAttr(fontFaceTag, 'units-per-em') || '1000');
|
||||
const ascent = parseFloat(getAttr(fontFaceTag, 'ascent') || '0');
|
||||
const descent = parseFloat(getAttr(fontFaceTag, 'descent') || '0');
|
||||
|
||||
const glyphRegex = /<glyph[\s\S]*?\/>/g;
|
||||
const glyphs = {};
|
||||
let m;
|
||||
|
||||
while ((m = glyphRegex.exec(svg)) !== null) {
|
||||
const tag = m[0];
|
||||
const unicode = getAttr(tag, 'unicode');
|
||||
const d = getAttr(tag, 'd');
|
||||
const adv = parseFloat(getAttr(tag, 'horiz-adv-x') || '0');
|
||||
|
||||
if (!unicode || !d) continue;
|
||||
|
||||
glyphs[unicode] = {
|
||||
d,
|
||||
adv,
|
||||
};
|
||||
}
|
||||
|
||||
const fontData = {
|
||||
unitsPerEm,
|
||||
ascent,
|
||||
descent,
|
||||
glyphs,
|
||||
};
|
||||
|
||||
FONT_CACHE[fontFileName] = fontData;
|
||||
return fontData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Breite einer Textzeile in Pixeln messen.
|
||||
*/
|
||||
function measureTextWidthPx({ text, fontFileName, fontSizePx }) {
|
||||
const { unitsPerEm, glyphs } = parseSvgFont(fontFileName);
|
||||
const scale = fontSizePx / unitsPerEm;
|
||||
|
||||
let x = 0;
|
||||
|
||||
for (const ch of String(text || '')) {
|
||||
const glyph = glyphs[ch];
|
||||
|
||||
if (!glyph) {
|
||||
// Fallback für Leerzeichen / unbekannte Zeichen
|
||||
const spacePx = fontSizePx * 0.4;
|
||||
x += spacePx;
|
||||
continue;
|
||||
}
|
||||
|
||||
const advPx = (glyph.adv || unitsPerEm * 0.5) * scale;
|
||||
x += advPx;
|
||||
}
|
||||
|
||||
return x;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text nach verfügbarer Breite umbrechen.
|
||||
* Garantiert: keine Zeile wird breiter als maxWidthPx.
|
||||
*/
|
||||
function wrapTextToLinesByWidth({
|
||||
text,
|
||||
maxWidthPx,
|
||||
fontFileName,
|
||||
fontSizePx,
|
||||
}) {
|
||||
const paragraphs = String(text || '').split(/\r?\n/);
|
||||
const allLines = [];
|
||||
|
||||
const measure = (t) =>
|
||||
measureTextWidthPx({ text: t, fontFileName, fontSizePx });
|
||||
|
||||
for (const para of paragraphs) {
|
||||
const words = para.split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) {
|
||||
allLines.push('');
|
||||
continue;
|
||||
}
|
||||
|
||||
let currentLine = '';
|
||||
|
||||
for (const word of words) {
|
||||
// Wort alleine zu breit → Zeichenweise brechen
|
||||
if (measure(word) > maxWidthPx) {
|
||||
if (currentLine) {
|
||||
allLines.push(currentLine);
|
||||
currentLine = '';
|
||||
}
|
||||
|
||||
let buf = '';
|
||||
for (const ch of word) {
|
||||
const candidate = buf + ch;
|
||||
if (measure(candidate) > maxWidthPx && buf) {
|
||||
allLines.push(buf);
|
||||
buf = ch;
|
||||
} else {
|
||||
buf = candidate;
|
||||
}
|
||||
}
|
||||
if (buf) currentLine = buf;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!currentLine) {
|
||||
currentLine = word;
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = `${currentLine} ${word}`;
|
||||
if (measure(candidate) <= maxWidthPx) {
|
||||
currentLine = candidate;
|
||||
} else {
|
||||
allLines.push(currentLine);
|
||||
currentLine = word;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
allLines.push(currentLine);
|
||||
}
|
||||
}
|
||||
|
||||
return allLines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Textzeilen in Pfade mit Transform-Matrix umwandeln.
|
||||
*/
|
||||
function layoutTextToPaths({
|
||||
lines,
|
||||
fontFileName,
|
||||
fontSizePx,
|
||||
startX,
|
||||
startY,
|
||||
lineHeightPx,
|
||||
}) {
|
||||
const { unitsPerEm, glyphs } = parseSvgFont(fontFileName);
|
||||
|
||||
const scale = fontSizePx / unitsPerEm;
|
||||
|
||||
const resultPaths = [];
|
||||
let baselineY = startY;
|
||||
|
||||
for (const line of lines) {
|
||||
let x = startX;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
const glyph = glyphs[ch];
|
||||
|
||||
if (!glyph) {
|
||||
// Leerzeichen mit festem Abstand
|
||||
const spacePx = fontSizePx * 0.4;
|
||||
x += spacePx;
|
||||
continue;
|
||||
}
|
||||
|
||||
const d = glyph.d;
|
||||
const advPx = (glyph.adv || unitsPerEm * 0.5) * scale;
|
||||
|
||||
// Einfache Transform-Matrix ohne Rotation
|
||||
const transform = `matrix(${scale},0,0,${-scale},${x},${baselineY})`;
|
||||
|
||||
resultPaths.push({
|
||||
d,
|
||||
transform,
|
||||
});
|
||||
|
||||
x += advPx;
|
||||
}
|
||||
|
||||
baselineY += lineHeightPx;
|
||||
}
|
||||
|
||||
return resultPaths;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
measureTextWidthPx,
|
||||
wrapTextToLinesByWidth,
|
||||
layoutTextToPaths
|
||||
};
|
||||
Reference in New Issue
Block a user