@osbjs/txtgen-tiny-osbjs
Version:
A text-to-image generator wrapper for tiny-osbjs.
281 lines (271 loc) • 12.1 kB
JavaScript
import { outputFile, emptyDirSync } from 'fs-extra';
import { createSprite } from '@osbjs/tiny-osbjs';
import { createCanvas, registerFont } from 'canvas';
import path, { join } from 'path';
const TO_BE_EJECTED = [];
function setToBeEjected(textImageBuffer) {
TO_BE_EJECTED.push(textImageBuffer);
}
/**
* Eject all text images into osu storyboard folder.
*/
function ejectAllTextImages() {
const ejectPromises = TO_BE_EJECTED.map((textImageBuffer) => outputFile(textImageBuffer.path, textImageBuffer.buffer));
return Promise.all(ejectPromises);
}
function isValidPadding(padding) {
return (typeof padding === 'object' &&
typeof padding.top === 'number' &&
typeof padding.bottom === 'number' &&
typeof padding.left === 'number' &&
typeof padding.right === 'number');
}
function isValidFontProps(fontProps) {
return (typeof fontProps == 'object' &&
typeof fontProps.size == 'number' &&
typeof fontProps.name == 'string' &&
typeof fontProps.isItalic == 'boolean' &&
isValidPadding(fontProps.padding));
}
function isValidTxtGenContext(context) {
return (isValidFontProps(context.fontProps) &&
typeof context.beatmapFolderPath === 'string' &&
typeof context.osbFolderPath === 'string' &&
Array.isArray(context.createdTextImages) &&
Array.isArray(context.memoizedMetrics));
}
let CANVAS_INSTANCE = createCanvas(1, 1);
let CANVAS_CONTEXT = CANVAS_INSTANCE.getContext('2d');
let TEXT_GENERATOR_CONTEXT = null;
/**
* Create a new text generator context.
*
* @param osbFolderPath Subfolder where the images will be generated into.
* @param beatmapFolderPath Full path to beatmap folder.
* @param fontProps Font properties used to generate text.
*/
function createTxtGenContext(osbFolderPath, beatmapFolderPath, fontProps) {
return {
fontProps,
osbFolderPath,
beatmapFolderPath,
createdTextImages: [],
memoizedMetrics: [],
};
}
/**
* Specify the text generator context to use.
*
* @param context Text generator context.
*/
function useTxtGenContext(context) {
if (!context || !isValidTxtGenContext(context))
throw new TypeError('You must use the context returned from `createTxtGenContext()`.');
TEXT_GENERATOR_CONTEXT = context;
}
function getTxtGenContext() {
if (!TEXT_GENERATOR_CONTEXT)
throw new Error('Text generator context is not set.');
return TEXT_GENERATOR_CONTEXT;
}
function getCanvasInstance() {
return CANVAS_INSTANCE;
}
function getCanvasContext() {
return CANVAS_CONTEXT;
}
function resizeCanvas(width, height) {
getCanvasInstance().width = width;
getCanvasInstance().height = height;
}
/**
* Specify a non-system font to use.
*
* @param path Full path to the font file. Relative path will not work as intended.
* @param family Font family name.
*/
function useFont(path, family) {
registerFont(path, { family });
CANVAS_INSTANCE = createCanvas(1, 1);
CANVAS_CONTEXT = CANVAS_INSTANCE.getContext('2d');
}
/**
* Clear output folder of this current context. This should be called once for each text generator context right after it is created.
* @param context Context to be selected.
*/
function clearOutputFolder(context) {
const { beatmapFolderPath, osbFolderPath } = context;
const outputFolder = path.join(beatmapFolderPath, osbFolderPath);
emptyDirSync(outputFolder);
console.log(`Output folder "${outputFolder}" is cleared.`);
}
function measureText(text) {
const { memoizedMetrics } = getTxtGenContext();
const memoizedMetricIndex = memoizedMetrics.findIndex(({ text: _text }) => _text == text);
if (memoizedMetricIndex != -1) {
const { width, height } = memoizedMetrics[memoizedMetricIndex];
return { width, height };
}
const { name, size, padding } = getTxtGenContext().fontProps;
const { top, bottom, left, right } = padding;
const canvas = getCanvasInstance();
const context = getCanvasContext();
// 2000 is big enough for most cases
canvas.width = 2000;
canvas.height = 2000;
context.font = `${size}px "${name}"`;
context.textBaseline = 'top';
const measure = context.measureText(text);
const height = Math.abs(measure.actualBoundingBoxAscent) + measure.actualBoundingBoxDescent + top + bottom;
const width = Math.abs(measure.actualBoundingBoxLeft) + measure.actualBoundingBoxRight + left + right;
memoizedMetrics.push({ text, width, height });
return { width, height };
}
function rgbToHex(color) {
const [r, g, b] = color;
if (typeof r !== 'number' || typeof g !== 'number' || typeof b !== 'number' || r > 255 || g > 255 || b > 255 || r < 0 || g < 0 || b < 0) {
throw new TypeError('Color range can only go from 0 to 255');
}
return '#' + (b | (g << 8) | (r << 16) | (1 << 24)).toString(16).slice(1);
}
/**
* Get top left position of the image.
*
* @param position Texture position.
* @param origin Texture's origin.
* @param width Text image's width.
* @param height Text image's height.
*/
function computeTopLeftPosition(position, origin, width, height, scale = 1) {
const [x, y] = position;
switch (origin) {
case 'TopLeft':
return [x, y];
case 'TopCentre':
return [x + width * 0.5 * scale, y];
case 'TopRight':
return [x + width * scale, y];
case 'CentreLeft':
return [x, y + height * 0.5 * scale];
case 'Centre':
return [x + width * 0.5 * scale, y + height * 0.5 * scale];
case 'CentreRight':
return [x + width * scale, y + height * 0.5 * scale];
case 'BottomLeft':
return [x, y + height * scale];
case 'BottomCentre':
return [x + width * 0.5 * scale, y + height * scale];
case 'BottomRight':
return [x + width * scale, y + height * scale];
default:
throw new Error(origin + ' is not a valid origin.');
}
}
/**
* Create a new sprite for a given text.
* Note that this won't generate the image and you need to call `ejectAllTextImages` after you have created all the sprites,
* usually after `generateOsbString`.
*
* @param text Text to generate
* @param layer The layer the object appears on.
* @param origin The sprite's origin
* @param initialPosition Where the sprite should be by default.
* @param invokeFunction The commands that should be run when the sprite is created.
*/
function createText(text, layer, origin, initialPosition, invokeFunction) {
const { createdTextImages } = getTxtGenContext();
let textImage = createdTextImages.find((textImage) => textImage.text === text && !textImage.isOutline) || generateTextImage(text, false);
const getTopLeftPosition = (position, scale = 1) => computeTopLeftPosition(position, origin, textImage.width, textImage.height, scale);
createSprite(textImage.osbPath, layer, origin, initialPosition, () => invokeFunction(textImage, getTopLeftPosition));
}
/**
* Create a new sprite for a given text but with only outline.
* It's recommended to use a seperate context for this.
* Note that this won't generate the image and you need to call `ejectAllTextImages` after you have created all the sprites,
* usually after `generateOsbString`.
*
* @param text Text to generate
* @param layer The layer the object appears on.
* @param origin The sprite's origin
* @param initialPosition Where the sprite should be by default.
* @param invokeFunction The commands that should be run when the sprite is created.
*/
function createOutlineText(text, layer, origin, initialPosition, invokeFunction) {
const { createdTextImages } = getTxtGenContext();
let textImage = createdTextImages.find((textImage) => textImage.text === text && textImage.isOutline) || generateTextImage(text, true);
const getTopLeftPosition = (position, scale = 1) => computeTopLeftPosition(position, origin, textImage.width, textImage.height, scale);
createSprite(textImage.osbPath, layer, origin, initialPosition, () => invokeFunction(textImage, getTopLeftPosition));
}
function generateTextImage(text, isOutline) {
const { fontProps, osbFolderPath, beatmapFolderPath, createdTextImages } = getTxtGenContext();
const { name, size, padding, isItalic } = fontProps;
const { top, left } = padding;
const { width, height } = measureText(text);
const canvas = getCanvasInstance();
const ctx = getCanvasContext();
resizeCanvas(width, height);
ctx.font = `${isItalic ? 'italic' : ''} ${size}px "${name}"`;
ctx.textBaseline = 'top';
// generate white text and then set color through command is better
if (!isOutline) {
ctx.fillStyle = rgbToHex([255, 255, 255]);
ctx.fillText(text, left, top);
}
else {
ctx.strokeStyle = rgbToHex([255, 255, 255]);
ctx.strokeText(text, left, top);
}
// eject
const osbPath = join(osbFolderPath, `_${createdTextImages.length}.png`);
const path = join(beatmapFolderPath, osbPath);
const buffer = Buffer.from(canvas.toDataURL('image/png').replace('data:image/png;base64,', ''), 'base64');
setToBeEjected({ path, osbFolderPath, text, fontProps, isOutline, buffer });
const textImage = { width, height, text, path, osbPath, isOutline };
createdTextImages.push(textImage);
return textImage;
}
/**
* Get total line width by calling reducer on each character/word in the line of text.
*
* @param line Line of text to measure.
* @param mode Whether you want to measure character or word.
* Will default to character mode if specified anything but 'char' or 'word'.
* @param reducer How the calculation should process.
*/
function measureLineWidth(line, mode = 'char', reducer = (prevWidth, currentWidth) => prevWidth + currentWidth) {
const textArr = line.split(mode === 'word' ? ' ' : '');
const textWidthArr = textArr.map((text) => measureText(text).width);
return textWidthArr.reduce(reducer, 0) + (mode === 'word' ? (textArr.length - 1) * measureText(' ').width : 0);
}
/**
* Get total line height by calling reducer on each letter in the line of text.
*
* @param line Line of text to measure.
* @param mode Whether you want to measure character or word.
* Will default to character mode if specified anything but 'char' or 'word'.
* @param reducer How the calculation should process.
*/
function measureLineHeight(line, mode = 'char', reducer = (prevHeight, currentHeight) => prevHeight + currentHeight) {
const textArr = line.split(mode === 'word' ? ' ' : '');
const textHeightArr = textArr.map((text) => measureText(text).height);
return textHeightArr.reduce(reducer, 0) + (mode === 'word' ? (textArr.length - 1) * measureText(' ').width : 0);
}
/**
* Get the maximum width of each character/word of the line of text.
* @param line Line of text to measure
* @param mode Whether you want to measure character or word.
* Will default to character mode if specified anything but 'char' or 'word'.
*/
function maxLineWidth(line, mode = 'char') {
return measureLineWidth(line, mode, (pr, cr) => Math.max(pr, cr));
}
/**
* Get the maximum height of each character/word of the line of text.
* @param line Line of text to measure
* @param mode Whether you want to measure character or word.
* Will default to character mode if specified anything but 'char' or 'word'.
*/
function maxLineHeight(line, mode = 'char') {
return measureLineHeight(line, mode, (pr, cr) => Math.max(pr, cr));
}
export { clearOutputFolder, createOutlineText, createText, createTxtGenContext, ejectAllTextImages, isValidFontProps, isValidPadding, isValidTxtGenContext, maxLineHeight, maxLineWidth, measureLineHeight, measureLineWidth, measureText, useFont, useTxtGenContext };