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)} 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(); }