UNPKG

@osbjs/txtgen-tiny-osbjs

Version:

A text-to-image generator wrapper for tiny-osbjs.

303 lines (289 loc) 12.8 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var fsExtra = require('fs-extra'); var tinyOsbjs = require('@osbjs/tiny-osbjs'); var canvas = require('canvas'); var path = require('path'); function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } var path__default = /*#__PURE__*/_interopDefaultLegacy(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) => fsExtra.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 = canvas.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) { canvas.registerFont(path, { family }); CANVAS_INSTANCE = canvas.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__default["default"].join(beatmapFolderPath, osbFolderPath); fsExtra.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); tinyOsbjs.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); tinyOsbjs.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 = path.join(osbFolderPath, `_${createdTextImages.length}.png`); const path$1 = path.join(beatmapFolderPath, osbPath); const buffer = Buffer.from(canvas.toDataURL('image/png').replace('data:image/png;base64,', ''), 'base64'); setToBeEjected({ path: path$1, osbFolderPath, text, fontProps, isOutline, buffer }); const textImage = { width, height, text, path: path$1, 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)); } exports.clearOutputFolder = clearOutputFolder; exports.createOutlineText = createOutlineText; exports.createText = createText; exports.createTxtGenContext = createTxtGenContext; exports.ejectAllTextImages = ejectAllTextImages; exports.isValidFontProps = isValidFontProps; exports.isValidPadding = isValidPadding; exports.isValidTxtGenContext = isValidTxtGenContext; exports.maxLineHeight = maxLineHeight; exports.maxLineWidth = maxLineWidth; exports.measureLineHeight = measureLineHeight; exports.measureLineWidth = measureLineWidth; exports.measureText = measureText; exports.useFont = useFont; exports.useTxtGenContext = useTxtGenContext;