local enums = { ---@enum TextAlign TextAlign = { START = "start", CENTER = "center", END = "end", JUSTIFY = "justify" }, ---@enum Positioning Positioning = { ABSOLUTE = "absolute", RELATIVE = "relative", FLEX = "flex", GRID = "grid" }, ---@enum FlexDirection FlexDirection = { HORIZONTAL = "horizontal", VERTICAL = "vertical", ROW = "row", COLUMN = "column" }, ---@enum JustifyContent JustifyContent = { FLEX_START = "flex-start", CENTER = "center", SPACE_AROUND = "space-around", FLEX_END = "flex-end", SPACE_EVENLY = "space-evenly", SPACE_BETWEEN = "space-between", }, ---@enum JustifySelf JustifySelf = { AUTO = "auto", FLEX_START = "flex-start", CENTER = "center", FLEX_END = "flex-end", SPACE_AROUND = "space-around", SPACE_EVENLY = "space-evenly", SPACE_BETWEEN = "space-between", }, ---@enum AlignItems AlignItems = { STRETCH = "stretch", FLEX_START = "flex-start", FLEX_END = "flex-end", CENTER = "center", BASELINE = "baseline", }, ---@enum AlignSelf AlignSelf = { AUTO = "auto", STRETCH = "stretch", FLEX_START = "flex-start", FLEX_END = "flex-end", CENTER = "center", BASELINE = "baseline", }, ---@enum AlignContent AlignContent = { STRETCH = "stretch", FLEX_START = "flex-start", FLEX_END = "flex-end", CENTER = "center", SPACE_BETWEEN = "space-between", SPACE_AROUND = "space-around", }, ---@enum FlexWrap FlexWrap = { NOWRAP = "nowrap", WRAP = "wrap", WRAP_REVERSE = "wrap-reverse" }, ---@enum TextSize TextSize = { XXS = "xxs", XS = "xs", SM = "sm", MD = "md", LG = "lg", XL = "xl", XXL = "xxl", XL3 = "3xl", XL4 = "4xl", }, ---@enum ImageRepeat ImageRepeat = { NO_REPEAT = "no-repeat", REPEAT = "repeat", REPEAT_X = "repeat-x", REPEAT_Y = "repeat-y", SPACE = "space", ROUND = "round", }, } --- Get current keyboard modifiers state ---@return {shift:boolean, ctrl:boolean, alt:boolean, super:boolean} local function getModifiers() return { shift = love.keyboard.isDown("lshift", "rshift"), ctrl = love.keyboard.isDown("lctrl", "rctrl"), alt = love.keyboard.isDown("lalt", "ralt"), ---@diagnostic disable-next-line super = love.keyboard.isDown("lgui", "rgui"), -- cmd/windows key } end local TEXT_SIZE_PRESETS = { ["2xs"] = 0.75, xxs = 0.75, xs = 1.25, sm = 1.75, md = 2.25, lg = 2.75, xl = 3.5, xxl = 4.5, ["2xl"] = 4.5, ["3xl"] = 5.0, ["4xl"] = 7.0, } --- Resolve text size preset to viewport units ---@param sizeValue string|number ---@return number?, string? local function resolveTextSizePreset(sizeValue) if type(sizeValue) == "string" then local preset = TEXT_SIZE_PRESETS[sizeValue] if preset then return preset, "vh" end end return nil, nil end --- Auto-detect the base path where FlexLove is located ---@return string filesystemPath local function getFlexLoveBasePath() local info = debug.getinfo(1, "S") if info and info.source then local source = info.source if source:sub(1, 1) == "@" then source = source:sub(2) end local filesystemPath = source:match("(.*/)") if filesystemPath then local fsPath = filesystemPath fsPath = fsPath:gsub("^%./", "") fsPath = fsPath:gsub("/$", "") fsPath = fsPath:gsub("/modules$", "") return fsPath end end return "libs" end local FLEXLOVE_FILESYSTEM_PATH = getFlexLoveBasePath() --- Helper function to resolve paths relative to FlexLove ---@param path string ---@return string local function resolveImagePath(path) if path:match("^/") or path:match("^[A-Z]:") then return path end return FLEXLOVE_FILESYSTEM_PATH .. "/" .. path end -- Font cache with LRU eviction local FONT_CACHE = {} local FONT_CACHE_MAX_SIZE = 50 local FONT_CACHE_STATS = { hits = 0, misses = 0, evictions = 0, size = 0, } -- LRU tracking: each entry has {font, lastUsed, accessCount} local function updateCacheAccess(cacheKey) local entry = FONT_CACHE[cacheKey] if entry then entry.lastUsed = love.timer.getTime() entry.accessCount = entry.accessCount + 1 end end local function evictLRU() local oldestKey = nil local oldestTime = math.huge for key, entry in pairs(FONT_CACHE) do -- Skip methods (get, getFont) - only evict cache entries (tables with lastUsed) if type(entry) == "table" and entry.lastUsed then if entry.lastUsed < oldestTime then oldestTime = entry.lastUsed oldestKey = key end end end if oldestKey then FONT_CACHE[oldestKey] = nil FONT_CACHE_STATS.evictions = FONT_CACHE_STATS.evictions + 1 FONT_CACHE_STATS.size = FONT_CACHE_STATS.size - 1 end end --- Create or get a font from cache ---@param size number ---@param fontPath string? ---@return love.Font function FONT_CACHE.get(size, fontPath) -- Round size to reduce cache entries (e.g., 14.5 -> 15, 14.7 -> 15) size = math.floor(size + 0.5) local cacheKey = fontPath and (fontPath .. ":" .. tostring(size)) or ("default:" .. tostring(size)) if FONT_CACHE[cacheKey] then -- Cache hit FONT_CACHE_STATS.hits = FONT_CACHE_STATS.hits + 1 updateCacheAccess(cacheKey) return FONT_CACHE[cacheKey].font end -- Cache miss FONT_CACHE_STATS.misses = FONT_CACHE_STATS.misses + 1 local font if fontPath then local resolvedPath = resolveImagePath(fontPath) local success, result = pcall(love.graphics.newFont, resolvedPath, size) if success then font = result else print("[FlexLove] Failed to load font: " .. fontPath .. " - using default font") font = love.graphics.newFont(size) end else font = love.graphics.newFont(size) end -- Add to cache with LRU metadata FONT_CACHE[cacheKey] = { font = font, lastUsed = love.timer.getTime(), accessCount = 1, } FONT_CACHE_STATS.size = FONT_CACHE_STATS.size + 1 -- Evict if cache is full if FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE then evictLRU() end return font end --- Get font for text size (cached) ---@param textSize number? ---@param fontPath string? ---@return love.Font function FONT_CACHE.getFont(textSize, fontPath) if textSize then return FONT_CACHE.get(textSize, fontPath) else return love.graphics.getFont() end end -- Font resolution utilities --- Resolve font path from fontFamily and theme ---@param fontFamily string? Font family name or direct path ---@param themeComponent string? Theme component name ---@param themeManager table? ThemeManager instance ---@return string? Resolved font path or nil local function resolveFontPath(fontFamily, themeComponent, themeManager) if fontFamily then -- Check if fontFamily is a theme font name local themeToUse = themeManager and themeManager:getTheme() if themeToUse and themeToUse.fonts and themeToUse.fonts[fontFamily] then return themeToUse.fonts[fontFamily] else -- Treat as direct path to font file return fontFamily end elseif themeComponent and themeManager then -- If using themeComponent but no fontFamily specified, check for default font in theme return themeManager:getDefaultFontFamily() end return nil end --- Get font for element (resolves from theme or fontFamily) ---@param textSize number? Text size in pixels ---@param fontFamily string? Font family name or direct path ---@param themeComponent string? Theme component name ---@param themeManager table? ThemeManager instance ---@return love.Font local function getFont(textSize, fontFamily, themeComponent, themeManager) local fontPath = resolveFontPath(fontFamily, themeComponent, themeManager) return FONT_CACHE.getFont(textSize, fontPath) end --- Apply content auto-sizing multiplier to a dimension ---@param value number The dimension value ---@param multiplier table? The contentAutoSizingMultiplier table {width:number?, height:number?} ---@param axis "width"|"height" Which axis to apply ---@return number The multiplied value local function applyContentMultiplier(value, multiplier, axis) if multiplier and multiplier[axis] then return value * multiplier[axis] end return value end -- Validation utilities local ErrorHandler = nil --- Initialize dependencies ---@param deps table Dependencies: { ErrorHandler = ErrorHandler } local function init(deps) if type(deps) == "table" then ErrorHandler = deps.ErrorHandler end end --- Validate that a value is in an enum table ---@param value any Value to validate ---@param enumTable table Enum table with valid values ---@param propName string Property name for error messages ---@param moduleName string? Module name for error messages (default: "Element") ---@return boolean True if valid local function validateEnum(value, enumTable, propName, moduleName) if value == nil then return true end for _, validValue in pairs(enumTable) do if value == validValue then return true end end -- Build list of valid options local validOptions = {} for _, v in pairs(enumTable) do table.insert(validOptions, "'" .. v .. "'") end table.sort(validOptions) if ErrorHandler then ErrorHandler:error(moduleName or "Element", string.format("%s must be one of: %s. Got: '%s'", propName, table.concat(validOptions, ", "), tostring(value))) else error(string.format("%s must be one of: %s. Got: '%s'", propName, table.concat(validOptions, ", "), tostring(value))) end end --- Validate that a numeric value is within a range ---@param value any Value to validate ---@param min number Minimum allowed value ---@param max number Maximum allowed value ---@param propName string Property name for error messages ---@param moduleName string? Module name for error messages (default: "Element") ---@return boolean True if valid local function validateRange(value, min, max, propName, moduleName) if value == nil then return true end if type(value) ~= "number" then if ErrorHandler then ErrorHandler:error(moduleName or "Element", string.format("%s must be a number, got %s", propName, type(value))) else error(string.format("%s must be a number, got %s", propName, type(value))) end end if value < min or value > max then if ErrorHandler then ErrorHandler:error( moduleName or "Element", string.format("%s must be between %s and %s, got %s", propName, tostring(min), tostring(max), tostring(value)) ) else error(string.format("%s must be between %s and %s, got %s", propName, tostring(min), tostring(max), tostring(value))) end end return true end --- Validate that a value is of the expected type ---@param value any Value to validate ---@param expectedType string Expected type name ---@param propName string Property name for error messages ---@param moduleName string? Module name for error messages (default: "Element") ---@return boolean True if valid local function validateType(value, expectedType, propName, moduleName) if value == nil then return true end local actualType = type(value) if actualType ~= expectedType then if ErrorHandler then ErrorHandler:error(moduleName or "Element", string.format("%s must be %s, got %s", propName, expectedType, actualType)) else error(string.format("%s must be %s, got %s", propName, expectedType, actualType)) end end return true end -- Math utilities --- Clamp a value between min and max ---@param value number Value to clamp ---@param min number Minimum value ---@param max number Maximum value ---@return number Clamped value local function clamp(value, min, max) return math.max(min, math.min(value, max)) end --- Linear interpolation between two values ---@param a number Start value ---@param b number End value ---@param t number Interpolation factor (0-1) ---@return number Interpolated value local function lerp(a, b, t) return a + (b - a) * t end --- Round a number to the nearest integer ---@param value number Value to round ---@return number Rounded value local function round(value) return math.floor(value + 0.5) end -- Path and Image utilities --- Normalize a file path for consistent cache keys ---@param path string File path to normalize ---@return string Normalized path local function normalizePath(path) path = path:match("^%s*(.-)%s*$") path = path:gsub("\\", "/") path = path:gsub("/+", "/") return path end --- Safely load an image with error handling --- Returns both Image and ImageData to avoid deprecated getData() API ---@param imagePath string Path to image file ---@return love.Image?, love.ImageData?, string? Returns image, imageData, or nil with error message local function safeLoadImage(imagePath) local success, imageData = pcall(function() return love.image.newImageData(imagePath) end) if not success then local errorMsg = string.format("[FlexLove] Failed to load image data: %s - %s", imagePath, tostring(imageData)) print(errorMsg) return nil, nil, errorMsg end local imageSuccess, image = pcall(function() return love.graphics.newImage(imageData) end) if imageSuccess then return image, imageData, nil else local errorMsg = string.format("[FlexLove] Failed to create image: %s - %s", imagePath, tostring(image)) print(errorMsg) return nil, nil, errorMsg end end -- Color manipulation utilities --- Brighten a color by a factor ---@param r number Red component (0-1) ---@param g number Green component (0-1) ---@param b number Blue component (0-1) ---@param a number Alpha component (0-1) ---@param factor number Brightness factor (e.g., 1.2 for 20% brighter) ---@return number, number, number, number Brightened color components local function brightenColor(r, g, b, a, factor) return math.min(1, r * factor), math.min(1, g * factor), math.min(1, b * factor), a end -- Property normalization utilities --- Normalize a boolean or table property with vertical/horizontal fields ---@param value boolean|table|nil Input value (boolean applies to both, table for individual control) ---@param defaultValue boolean Default value if nil (default: false) ---@return table Normalized table with vertical and horizontal fields local function normalizeBooleanTable(value, defaultValue) defaultValue = defaultValue or false if value == nil then return { vertical = defaultValue, horizontal = defaultValue } end if type(value) == "boolean" then return { vertical = value, horizontal = value } end if type(value) == "table" then return { vertical = value.vertical ~= nil and value.vertical or defaultValue, horizontal = value.horizontal ~= nil and value.horizontal or defaultValue, } end return { vertical = defaultValue, horizontal = defaultValue } end -- Text sanitization utilities --- Sanitize text to prevent security vulnerabilities --- @param text string? Text to sanitize --- @param options table? Sanitization options --- @return string Sanitized text local function sanitizeText(text, options) -- Handle nil or non-string inputs if text == nil then return "" end if type(text) ~= "string" then text = tostring(text) end -- Default options options = options or {} local maxLength = options.maxLength or 10000 local allowNewlines = options.allowNewlines ~= false -- default true local allowTabs = options.allowTabs ~= false -- default true local stripControls = options.stripControls ~= false -- default true local trimWhitespace = options.trimWhitespace ~= false -- default true -- Remove null bytes (critical security risk) text = text:gsub("%z", "") -- Strip control characters except allowed ones if stripControls then local pattern = "[\1-\31\127]" -- All control characters if allowNewlines and allowTabs then pattern = "[\1-\8\11\12\14-\31\127]" -- Exclude \t (9), \n (10), \r (13) elseif allowNewlines then pattern = "[\1-\9\11\12\14-\31\127]" -- Exclude \n (10), \r (13) elseif allowTabs then pattern = "[\1-\8\10\12-\31\127]" -- Exclude \t (9) end text = text:gsub(pattern, "") end -- Trim leading/trailing whitespace if trimWhitespace then text = text:match("^%s*(.-)%s*$") or "" end -- Limit string length if #text > maxLength then text = text:sub(1, maxLength) if ErrorHandler then ErrorHandler:warn("utils", string.format("Text truncated from %d to %d characters", #text, maxLength)) end end return text end --- Validate text input against rules --- @param text string Text to validate --- @param rules table Validation rules --- @return boolean, string? Returns true if valid, or false with error message local function validateTextInput(text, rules) rules = rules or {} -- Check minimum length if rules.minLength and #text < rules.minLength then return false, string.format("Text must be at least %d characters", rules.minLength) end -- Check maximum length if rules.maxLength and #text > rules.maxLength then return false, string.format("Text must be at most %d characters", rules.maxLength) end -- Check pattern match if rules.pattern and not text:match(rules.pattern) then return false, rules.patternError or "Text does not match required pattern" end -- Check character whitelist if rules.allowedChars then local pattern = "[^" .. rules.allowedChars .. "]" if text:match(pattern) then return false, "Text contains invalid characters" end end -- Check character blacklist if rules.forbiddenChars then local pattern = "[" .. rules.forbiddenChars .. "]" if text:match(pattern) then return false, "Text contains forbidden characters" end end return true, nil end --- Escape HTML special characters --- @param text string Text to escape --- @return string Escaped text local function escapeHtml(text) if text == nil then return "" end text = tostring(text) text = text:gsub("&", "&") text = text:gsub("<", "<") text = text:gsub(">", ">") text = text:gsub('"', """) text = text:gsub("'", "'") return text end --- Escape Lua pattern special characters --- @param text string Text to escape --- @return string Escaped text local function escapeLuaPattern(text) if text == nil then return "" end text = tostring(text) -- Escape all Lua pattern special characters text = text:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1") return text end --- Strip all non-printable characters from text --- @param text string Text to clean --- @return string Cleaned text local function stripNonPrintable(text) if text == nil then return "" end text = tostring(text) -- Keep printable ASCII (32-126), newline (10), tab (9), and carriage return (13) text = text:gsub("[^\9\10\13\32-\126]", "") return text end -- Path validation utilities --- Sanitize a file path --- @param path string Path to sanitize --- @return string Sanitized path local function sanitizePath(path) if path == nil then return "" end path = tostring(path) -- Trim whitespace path = path:match("^%s*(.-)%s*$") or "" -- Normalize separators to forward slash path = path:gsub("\\", "/") -- Remove duplicate slashes path = path:gsub("/+", "/") -- Remove trailing slash (except for root) if #path > 1 and path:sub(-1) == "/" then path = path:sub(1, -2) end return path end --- Check if a path is safe (no traversal attacks) --- @param path string Path to check --- @param baseDir string? Base directory to check against (optional) --- @return boolean, string? Returns true if safe, or false with reason local function isPathSafe(path, baseDir) if path == nil or path == "" then return false, "Path is empty" end -- Sanitize the path path = sanitizePath(path) -- Check for suspicious patterns if path:match("%.%.") then return false, "Path contains '..' (parent directory reference)" end -- Check for null bytes if path:match("%z") then return false, "Path contains null bytes" end -- Check for encoded traversal attempts (including double-encoding) local lowerPath = path:lower() if lowerPath:match("%%2e") or lowerPath:match("%%2f") or lowerPath:match("%%5c") or lowerPath:match("%%252e") or lowerPath:match("%%252f") or lowerPath:match("%%255c") then return false, "Path contains URL-encoded directory separators" end -- If baseDir is provided, ensure path is within it if baseDir then baseDir = sanitizePath(baseDir) -- For relative paths, prepend baseDir local fullPath = path if not path:match("^/") and not path:match("^%a:") then fullPath = baseDir .. "/" .. path end fullPath = sanitizePath(fullPath) -- Check if fullPath starts with baseDir if not fullPath:match("^" .. baseDir:gsub("[%(%)%.%%%+%-%*%?%[%]%^%$]", "%%%1")) then return false, "Path is outside allowed directory" end end return true, nil end --- Validate a file path with comprehensive checks --- @param path string Path to validate --- @param options table? Validation options --- @return boolean, string? Returns true if valid, or false with error message local function validatePath(path, options) options = options or {} -- Check path is not nil/empty if path == nil or path == "" then return false, "Path is empty" end path = tostring(path) -- Check maximum length local maxLength = options.maxLength or 4096 if #path > maxLength then return false, string.format("Path exceeds maximum length of %d characters", maxLength) end -- Sanitize path path = sanitizePath(path) -- Check for safety (traversal attacks) local safe, reason = isPathSafe(path, options.baseDir) if not safe then return false, reason end -- Check allowed extensions if options.allowedExtensions then local ext = path:match("%.([^%.]+)$") if not ext then return false, "Path has no file extension" end ext = ext:lower() local allowed = false for _, allowedExt in ipairs(options.allowedExtensions) do if ext == allowedExt:lower() then allowed = true break end end if not allowed then return false, string.format("File extension '%s' is not allowed", ext) end end -- Check if file must exist if options.mustExist and love and love.filesystem then local info = love.filesystem.getInfo(path) if not info then return false, "File does not exist" end end return true, nil end --- Get file extension from path --- @param path string File path --- @return string? extension File extension (lowercase) or nil local function getFileExtension(path) if not path then return nil end local ext = path:match("%.([^%.]+)$") return ext and ext:lower() or nil end --- Check if path has allowed extension --- @param path string File path --- @param allowedExtensions table Array of allowed extensions --- @return boolean local function hasAllowedExtension(path, allowedExtensions) local ext = getFileExtension(path) if not ext then return false end for _, allowedExt in ipairs(allowedExtensions) do if ext == allowedExt:lower() then return true end end return false end -- Numeric validation utilities --- Check if a value is NaN (not-a-number) --- @param value any Value to check --- @return boolean local function isNaN(value) return type(value) == "number" and value ~= value end --- Check if a value is Infinity --- @param value any Value to check --- @return boolean local function isInfinity(value) return type(value) == "number" and (value == math.huge or value == -math.huge) end --- Validate a numeric value with comprehensive checks --- @param value any Value to validate --- @param options table? Validation options --- @return boolean, string?, number? Returns valid, errorMessage, sanitizedValue local function validateNumber(value, options) options = options or {} -- Check if value is a number type if type(value) ~= "number" then if options.default ~= nil then return true, nil, options.default end return false, string.format("Value must be a number, got %s", type(value)), nil end -- Check for NaN if isNaN(value) then if not options.allowNaN then if options.default ~= nil then return true, nil, options.default end return false, "Value is NaN (not-a-number)", nil end end -- Check for Infinity if isInfinity(value) then if not options.allowInfinity then if options.default ~= nil then return true, nil, options.default end return false, "Value is Infinity", nil end end -- Check for integer requirement if options.integer and math.floor(value) ~= value then return false, string.format("Value must be an integer, got %s", value), nil end -- Check for positive requirement if options.positive and value <= 0 then return false, string.format("Value must be positive, got %s", value), nil end -- Check bounds if options.min and value < options.min then return false, string.format("Value %s is below minimum %s", value, options.min), nil end if options.max and value > options.max then return false, string.format("Value %s is above maximum %s", value, options.max), nil end return true, nil, value end --- Sanitize a numeric value (never errors, always returns valid number) --- @param value any Value to sanitize --- @param min number? Minimum value --- @param max number? Maximum value --- @param default number? Default value for invalid inputs --- @return number Sanitized value local function sanitizeNumber(value, min, max, default) default = default or 0 min = min or -math.huge max = max or math.huge -- Convert to number if possible if type(value) == "string" then value = tonumber(value) end -- Handle non-numeric if type(value) ~= "number" then return default end -- Handle NaN if isNaN(value) then return default end -- Handle Infinity if value == math.huge then return max end if value == -math.huge then return min end -- Clamp to range return clamp(value, min, max) end --- Validate and convert to integer --- @param value any Value to validate --- @param min number? Minimum value --- @param max number? Maximum value --- @return boolean, string?, number? Returns valid, errorMessage, integerValue local function validateInteger(value, min, max) local valid, err, sanitized = validateNumber(value, { min = min, max = max, integer = true, }) if not valid then return false, err, nil end return true, nil, math.floor(sanitized or value) end --- Validate and normalize percentage value --- @param value any Value to validate (can be "50%", 0.5, or 50) --- @return boolean, string?, number? Returns valid, errorMessage, normalizedValue (0-1) local function validatePercentage(value) -- Handle string percentage if type(value) == "string" then local num = value:match("^(%d+%.?%d*)%%$") if num then value = tonumber(num) if value then value = value / 100 end else value = tonumber(value) end end if type(value) ~= "number" then return false, "Percentage must be a number", nil end if isNaN(value) or isInfinity(value) then return false, "Percentage cannot be NaN or Infinity", nil end -- If value is > 1, assume it's 0-100 range if value > 1 then value = value / 100 end -- Clamp to 0-1 value = clamp(value, 0, 1) return true, nil, value end --- Validate opacity value (0-1) --- @param value any Value to validate --- @return boolean, string?, number? Returns valid, errorMessage, opacityValue local function validateOpacity(value) return validateNumber(value, { min = 0, max = 1, default = 1 }) end --- Validate degree value (0-360) --- @param value any Value to validate --- @return boolean, string?, number? Returns valid, errorMessage, degreeValue local function validateDegrees(value) local valid, err, sanitized = validateNumber(value) if not valid then return false, err, nil end -- Normalize to 0-360 range local degrees = sanitized or value degrees = degrees % 360 if degrees < 0 then degrees = degrees + 360 end return true, nil, degrees end --- Validate coordinate value (pixel position) --- @param value any Value to validate --- @return boolean, string?, number? Returns valid, errorMessage, coordinateValue local function validateCoordinate(value) return validateNumber(value, { allowNaN = false, allowInfinity = false, }) end --- Validate dimension value (width/height, must be non-negative) --- @param value any Value to validate --- @return boolean, string?, number? Returns valid, errorMessage, dimensionValue local function validateDimension(value) return validateNumber(value, { min = 0, allowNaN = false, allowInfinity = false, }) end -- Font cache management --- Get font cache statistics ---@return table stats {hits, misses, evictions, size, hitRate} local function getFontCacheStats() local total = FONT_CACHE_STATS.hits + FONT_CACHE_STATS.misses local hitRate = total > 0 and (FONT_CACHE_STATS.hits / total) or 0 return { hits = FONT_CACHE_STATS.hits, misses = FONT_CACHE_STATS.misses, evictions = FONT_CACHE_STATS.evictions, size = FONT_CACHE_STATS.size, hitRate = hitRate, } end --- Set maximum font cache size ---@param maxSize number Maximum number of fonts to cache local function setFontCacheSize(maxSize) FONT_CACHE_MAX_SIZE = math.max(1, maxSize) -- Evict entries if cache is now over limit while FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE do evictLRU() end end --- Clear font cache local function clearFontCache() -- Clear cache entries but preserve methods (get, getFont) for key, entry in pairs(FONT_CACHE) do if type(entry) == "table" and entry.lastUsed then FONT_CACHE[key] = nil end end FONT_CACHE_STATS.size = 0 FONT_CACHE_STATS.evictions = 0 end --- Preload font at multiple sizes ---@param fontPath string? Path to font file (nil for default font) ---@param sizes table Array of font sizes to preload local function preloadFont(fontPath, sizes) for _, size in ipairs(sizes) do -- Round size to reduce cache entries size = math.floor(size + 0.5) local cacheKey = fontPath and (fontPath .. ":" .. tostring(size)) or ("default:" .. tostring(size)) if not FONT_CACHE[cacheKey] then local font if fontPath then local resolvedPath = resolveImagePath(fontPath) local success, result = pcall(love.graphics.newFont, resolvedPath, size) if success then font = result else font = love.graphics.newFont(size) end else font = love.graphics.newFont(size) end FONT_CACHE[cacheKey] = { font = font, lastUsed = love.timer.getTime(), accessCount = 1, } FONT_CACHE_STATS.size = FONT_CACHE_STATS.size + 1 FONT_CACHE_STATS.misses = FONT_CACHE_STATS.misses + 1 -- Evict if cache is full if FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE then evictLRU() end end end end --- Reset font cache statistics local function resetFontCacheStats() FONT_CACHE_STATS.hits = 0 FONT_CACHE_STATS.misses = 0 FONT_CACHE_STATS.evictions = 0 end return { enums = enums, FONT_CACHE = FONT_CACHE, resolveTextSizePreset = resolveTextSizePreset, getModifiers = getModifiers, TEXT_SIZE_PRESETS = TEXT_SIZE_PRESETS, init = init, validateEnum = validateEnum, validateRange = validateRange, validateType = validateType, clamp = clamp, lerp = lerp, round = round, normalizePath = normalizePath, safeLoadImage = safeLoadImage, brightenColor = brightenColor, resolveImagePath = resolveImagePath, normalizeBooleanTable = normalizeBooleanTable, resolveFontPath = resolveFontPath, getFont = getFont, applyContentMultiplier = applyContentMultiplier, -- Text sanitization sanitizeText = sanitizeText, validateTextInput = validateTextInput, escapeHtml = escapeHtml, escapeLuaPattern = escapeLuaPattern, stripNonPrintable = stripNonPrintable, -- Path validation sanitizePath = sanitizePath, isPathSafe = isPathSafe, validatePath = validatePath, getFileExtension = getFileExtension, hasAllowedExtension = hasAllowedExtension, -- Font cache management getFontCacheStats = getFontCacheStats, setFontCacheSize = setFontCacheSize, clearFontCache = clearFontCache, preloadFont = preloadFont, resetFontCacheStats = resetFontCacheStats, -- Numeric validation isNaN = isNaN, isInfinity = isInfinity, validateNumber = validateNumber, sanitizeNumber = sanitizeNumber, validateInteger = validateInteger, validatePercentage = validatePercentage, validateOpacity = validateOpacity, validateDegrees = validateDegrees, validateCoordinate = validateCoordinate, validateDimension = validateDimension, }