Files
2025-11-30 08:35:03 +02:00

396 lines
15 KiB
JavaScript

const utils = require('./lib/utils');
const reshaper = require('arabic-persian-reshaper').ArabicShaper;
const opentype = require('opentype.js');
const exec = require('child_process').exec;
const mapLimit = require('map-limit');
const MaxRectsPacker = require('maxrects-packer').MaxRectsPacker;
const path = require('path');
const ProgressBar = require('cli-progress');
const fs = require('fs');
const buffer = require('buffer').Buffer;
const Jimp = require('jimp');
const readline = require('readline');
const assert = require('assert');
const defaultCharset = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~".split('');
const controlChars = ['\n', '\r', '\t'];
const binaryLookup = {
darwin: 'msdfgen.osx',
win32: 'msdfgen.exe',
linux: 'msdfgen.linux'
};
module.exports = generateBMFont;
/**
* Creates a BMFont compatible bitmap font of signed distance fields from a font file
*
* @param {string|Buffer} fontPath - Path or Buffer for the input ttf/otf/woff font
* @param {Object} opt - Options object for generating bitmap font (Optional) :
* outputType : font file format Avaliable: xml(default), json, txt
* filename : filename of both font file and font textures
* fontSize : font size for generated textures (default 42)
* charset : charset in generated font, could be array or string (default is Western)
* textureWidth : Width of generated textures (default 512)
* textureHeight : Height of generated textures (default 512)
* distanceRange : distance range for computing signed distance field
* fieldType : "msdf"(default), "sdf", "psdf"
* roundDecimal : rounded digits of the output font file. (Defaut is null)
* smartSize : shrink atlas to the smallest possible square (Default: false)
* pot : atlas size shall be power of 2 (Default: false)
* square : atlas size shall be square (Default: false)
* rot : allow 90-degree rotation while packing (Default: false)
* rtl : use RTL charators fix (Default: false)
* @param {function(string, Array.<Object>, Object)} callback - Callback funtion(err, textures, font)
*
*/
function generateBMFont (fontPath, opt, callback) {
if (typeof opt === 'function') {
callback = opt;
opt = {};
}
const binName = binaryLookup[process.platform];
assert.ok(binName, `No msdfgen binary for platform ${process.platform}.`);
assert.ok(fontPath, 'must specify a font path');
assert.ok(typeof fontPath === 'string' || fontPath instanceof Buffer, 'font must be string path or Buffer');
assert.ok(opt.filename || !(fontPath instanceof Buffer), 'must specify filename if font is a Buffer');
assert.ok(callback, 'missing callback')
assert.ok(typeof callback === 'function', 'expected callback to be a function');
assert.ok(!opt.textureSize || opt.textureSize.length === 2, 'textureSize format shall be: width,height');
// Set fallback output path to font path
let fontDir = typeof fontPath === 'string' ? path.dirname(fontPath) : '';
const binaryPath = path.join(__dirname, 'bin', process.platform, binName);
// const reuse = (typeof opt.reuse === 'boolean' || typeof opt.reuse === 'undefined') ? {} : opt.reuse.opt;
let reuse, cfg = {};
if (typeof opt.reuse !== 'undefined' && typeof opt.reuse !== 'boolean') {
if (!fs.existsSync(opt.reuse)) {
console.log('Creating cfg file : ' + opt.reuse);
reuse = {};
} else {
console.log('Loading cfg file : ' + opt.reuse);
cfg = JSON.parse(fs.readFileSync(opt.reuse, 'utf8'));
reuse = cfg.opt;
}
} else reuse = {};
const outputType = opt.outputType = utils.valueQueue([opt.outputType, reuse.outputType, "xml"]);
let filename = utils.valueQueue([opt.filename, reuse.filename]);
const distanceRange = opt.distanceRange = utils.valueQueue([opt.distanceRange, reuse.distanceRange, 4]);
const fontSize = opt.fontSize = utils.valueQueue([opt.fontSize, reuse.fontSize, 42]);
const fontSpacing = opt.fontSpacing = utils.valueQueue([opt.fontSpacing, reuse.fontSpacing, [0, 0]]);
const pad = distanceRange >> 1;
const fontPadding = opt.fontPadding = utils.valueQueue([opt.fontPadding, reuse.fontPadding, [pad, pad, pad, pad]]);
const textureWidth = opt.textureWidth = utils.valueQueue([opt.textureSize || reuse.textureSize, [512, 512]])[0];
const textureHeight = opt.textureHeight = utils.valueQueue([opt.textureSize || reuse.textureSize, [512, 512]])[1];
const texturePadding = opt.texturePadding = utils.valueQueue([opt.texturePadding, reuse.texturePadding, 1]);
const border = opt.border = utils.valueQueue([opt.border, reuse.border, 0]);
const fieldType = opt.fieldType = utils.valueQueue([opt.fieldType, reuse.fieldType, 'msdf']);
const roundDecimal = opt.roundDecimal = utils.valueQueue([opt.roundDecimal, reuse.roundDecimal]); // if no roudDecimal option, left null as-is
const smartSize = opt.smartSize = utils.valueQueue([opt.smartSize, reuse.smartSize, false]);
const pot = opt.pot = utils.valueQueue([opt.pot, reuse.pot, false]);
const square = opt.square = utils.valueQueue([opt.square, reuse.square, false]);
const debug = opt.vector || false;
const tolerance = opt.tolerance = utils.valueQueue([opt.tolerance, reuse.tolerance, 0]);
const isRTL = opt.rtl = utils.valueQueue([opt.rtl, reuse.rtl, false]);
const allowRotation = opt.rot = utils.valueQueue([opt.rot, reuse.rot, false]);
if (isRTL) opt.charset = reshaper.convertArabic(opt.charset);
let charset = opt.charset = (typeof opt.charset === 'string' ? Array.from(opt.charset) : opt.charset) || reuse.charset || defaultCharset;
// TODO: Validate options
if (fieldType !== 'msdf' && fieldType !== 'sdf' && fieldType !== 'psdf') {
throw new TypeError('fieldType must be one of msdf, sdf, or psdf');
}
const font = typeof fontPath === 'string'
? opentype.loadSync(fontPath)
: opentype.parse(utils.bufferToArrayBuffer(fontPath));
if (font.outlinesFormat !== 'truetype' && font.outlinesFormat !== 'cff') {
throw new TypeError('must specify a truetype font (ttf, otf, woff)');
}
const packer = new MaxRectsPacker(textureWidth, textureHeight, texturePadding, {
smart: smartSize,
pot: pot,
square: square,
allowRotation: allowRotation,
tag: false,
border: border
});
const chars = [];
charset = charset.filter((e, i, self) => {
return (i == self.indexOf(e)) && (!controlChars.includes(e));
}); // Remove duplicate & control chars
const os2 = font.tables.os2;
const baseline = os2.sTypoAscender * (fontSize / font.unitsPerEm) + (distanceRange >> 1);
const fontface = typeof fontPath === 'string' ? path.basename(fontPath, path.extname(fontPath)) : filename;
if(!filename) {
filename = fontface;
console.log(`Use font-face as filename : ${filename}`);
} else {
if (opt.filename) fontDir = path.dirname(opt.filename);
filename = opt.filename = path.basename(filename, path.extname(filename));
}
// Initialize settings
let settings = {};
settings.opt = JSON.parse(JSON.stringify(opt));
delete settings.opt['reuse']; // prune previous settings
let pages = [];
if (cfg.packer !== undefined) {
pages = cfg.pages;
packer.load(cfg.packer.bins);
}
let bar;
bar = new ProgressBar.Bar({
format: "Generating {percentage}%|{bar}| ({value}/{total}) {duration}s",
clearOnComplete: true
}, ProgressBar.Presets.shades_classic);
bar.start(charset.length, 0);
mapLimit(charset, 15, (char, cb) => {
generateImage({
binaryPath,
font,
char,
fontSize,
fieldType,
distanceRange,
roundDecimal,
debug,
tolerance
}, (err, res) => {
if (err) return cb(err);
bar.increment();
cb(null, res);
});
}, async (err, results) => {
if (err) callback(err);
bar.stop();
packer.addArray(results);
const textures = packer.bins.map(async (bin, index) => {
let svg = "";
let texname = "";
let fillColor = fieldType === "msdf" ? 0x000000ff : 0x00000000;
let img = new Jimp(bin.width, bin.height, fillColor);
if (index > pages.length - 1) {
if (packer.bins.length > 1) texname = `${filename}.${index}`;
else texname = filename;
pages.push(`${texname}.png`);
} else {
texname = path.basename(pages[index], path.extname(pages[index]));
let imgPath = path.join(fontDir, `${texname}.png`);
// let imgPath = `${texname}.png`;
console.log('Loading previous image : ', imgPath);
const loader = Jimp.read(imgPath);
loader.catch(err => {
console.warn("File read error: ", err);
});
const prevImg = await loader;
img.composite(prevImg, 0, 0);
}
bin.rects.forEach(rect => {
if (rect.data.imageData) {
if (rect.rot) {
rect.data.imageData.rotate(90);
}
img.composite(rect.data.imageData, rect.x, rect.y);
if (debug) {
const x_woffset = rect.x - rect.data.fontData.xoffset + (distanceRange >> 1);
const y_woffset = rect.y - rect.data.fontData.yoffset + baseline + (distanceRange >> 1);
svg += font.charToGlyph(rect.data.fontData.char).getPath(x_woffset, y_woffset, fontSize).toSVG() + "\n";
}
}
const charData = rect.data.fontData;
charData.x = rect.x;
charData.y = rect.y;
charData.page = index;
chars.push(rect.data.fontData);
});
const buffer = await img.getBufferAsync(Jimp.MIME_PNG);
let tex = {
filename: path.join(fontDir, texname),
texture: buffer
}
if (debug) tex.svg = svg;
return tex;
});
const asyncTextures = await Promise.all(textures);
const kernings = [];
charset.forEach(first => {
charset.forEach(second => {
const amount = font.getKerningValue(font.charToGlyph(first), font.charToGlyph(second));
if (amount !== 0) {
kernings.push({
first: first.charCodeAt(0),
second: second.charCodeAt(0),
amount: amount * (fontSize / font.unitsPerEm)
});
}
});
});
const fontData = {
pages,
chars,
info: {
face: fontface,
size: fontSize,
bold: 0,
italic: 0,
charset,
unicode: 1,
stretchH: 100,
smooth: 1,
aa: 1,
padding: fontPadding,
spacing: fontSpacing,
outline: 0
},
common: {
lineHeight: (os2.sTypoAscender - os2.sTypoDescender + os2.sTypoLineGap) * (fontSize / font.unitsPerEm),
base: baseline,
scaleW: packer.bins[0].width,
scaleH: packer.bins[0].height,
pages: packer.bins.length,
packed: 0,
alphaChnl: 0,
redChnl: 0,
greenChnl: 0,
blueChnl: 0
},
distanceField: {
fieldType: fieldType,
distanceRange: distanceRange
},
kernings: kernings
};
if(roundDecimal !== null) utils.roundAllValue(fontData, roundDecimal, true);
let fontFile = {};
const ext = outputType === "json" ? `.json` : `.fnt`;
fontFile.filename = path.join(fontDir, fontface + ext);
fontFile.data = utils.stringify(fontData, outputType);
// Store pages name and available packer freeRects in settings
settings.pages = pages;
settings.packer = {};
settings.packer.bins = packer.save();
fontFile.settings = settings;
console.log("\nGeneration complete!\n");
callback(null, asyncTextures, fontFile);
});
}
function generateImage (opt, callback) {
const {binaryPath, font, char, fontSize, fieldType, distanceRange, roundDecimal, debug, tolerance} = opt;
const glyph = font.charToGlyph(char);
const commands = glyph.getPath(0, 0, fontSize).commands;
let contours = [];
let currentContour = [];
const bBox = glyph.getPath(0, 0, fontSize).getBoundingBox();
commands.forEach(command => {
if (command.type === 'M') { // new contour
if (currentContour.length > 0) {
contours.push(currentContour);
currentContour = [];
}
}
currentContour.push(command);
});
contours.push(currentContour);
if (tolerance != 0) {
utils.setTolerance(tolerance, tolerance * 10);
let numFiltered = utils.filterContours(contours);
if (numFiltered && debug)
console.log(`\n${char} removed ${numFiltered} small contour(s)`);
// let numReversed = utils.alignClockwise(contours, false);
// if (numReversed && debug)
// console.log(`${char} found ${numReversed}/${contours.length} reversed contour(s)`);
}
let shapeDesc = utils.stringifyContours(contours);
if (contours.some(cont => cont.length === 1)) console.log('length is 1, failed to normalize glyph');
const scale = fontSize / font.unitsPerEm;
const baseline = font.tables.os2.sTypoAscender * (fontSize / font.unitsPerEm);
const pad = distanceRange >> 1;
let width = Math.round(bBox.x2 - bBox.x1) + pad + pad;
let height = Math.round(bBox.y2 - bBox.y1) + pad + pad;
let xOffset = Math.round(-bBox.x1) + pad;
let yOffset = Math.round(-bBox.y1) + pad;
if (roundDecimal != null) {
xOffset = utils.roundNumber(xOffset, roundDecimal);
yOffset = utils.roundNumber(yOffset, roundDecimal);
}
let command = `"${binaryPath}" ${fieldType} -format text -stdout -size ${width} ${height} -translate ${xOffset} ${yOffset} -pxrange ${distanceRange} -stdin`;
let subproc = exec(command, (err, stdout, stderr) => {
if (err) return callback(err);
const rawImageData = stdout.match(/([0-9a-fA-F]+)/g).map(str => parseInt(str, 16)); // split on every number, parse from hex
const pixels = [];
const channelCount = rawImageData.length / width / height;
if (!isNaN(channelCount) && channelCount % 1 !== 0) {
console.error(command);
console.error(stdout);
return callback(new RangeError('msdfgen returned an image with an invalid length'));
}
if (fieldType === 'msdf') {
for (let i = 0; i < rawImageData.length; i += channelCount) {
pixels.push(...rawImageData.slice(i, i + channelCount), 255); // add 255 as alpha every 3 elements
}
} else {
for (let i = 0; i < rawImageData.length; i += channelCount) {
pixels.push(rawImageData[i], rawImageData[i], rawImageData[i], rawImageData[i]); // make monochrome w/ alpha
}
}
let imageData;
if (isNaN(channelCount) || !rawImageData.some(x => x !== 0)) { // if character is blank
readline.clearLine(process.stdout);
readline.cursorTo(process.stdout, 0);
console.log(`Warning: no bitmap for character '${char}' (${char.charCodeAt(0)}), adding to font as empty`);
width = 0;
height = 0;
} else {
const buffer = new Uint8ClampedArray(pixels);
imageData = new Jimp({data: buffer, width: width, height: height});
}
const container = {
data: {
imageData,
fontData: {
id: char.charCodeAt(0),
index: glyph.index,
char: String(char),
width: width,
height: height,
xoffset: Math.round(bBox.x1) - pad,
yoffset: Math.round(bBox.y1) + pad + baseline,
xadvance: glyph.advanceWidth * scale,
chnl: 15
}
},
width: width,
height: height
};
callback(null, container);
});
subproc.stdin.write(shapeDesc);
subproc.stdin.write('\n');
subproc.stdin.destroy();
}