#include "StarTextPainter.hpp" #include "StarJsonExtra.hpp" #include namespace Star { namespace Text { static auto stripEscapeRegex = std::regex(strf("\\{:c}[^;]*{:c}", CmdEsc, EndEsc)); String stripEscapeCodes(String const& s) { return std::regex_replace(s.utf8(), stripEscapeRegex, ""); } inline bool isEscapeCode(char c) { return c == CmdEsc || c == StartEsc; } static std::string escapeChars = strf("{:c}{:c}", CmdEsc, StartEsc); typedef function TextCallback; typedef function CommandsCallback; void processText(StringView text, TextCallback textFunc, CommandsCallback commandsFunc = CommandsCallback(), bool includeCommandSides = false) { std::string_view escChars(escapeChars); std::string_view str = text.utf8(); while (true) { size_t escape = str.find_first_of(escChars); if (escape != NPos) { escape = str.find_first_not_of(escChars, escape) - 1; // jump to the last ^ size_t end = str.find_first_of(EndEsc, escape); if (end != NPos) { if (escape != end && !textFunc(str.substr(0, escape))) break; if (commandsFunc) { StringView commands = includeCommandSides ? str.substr(escape, end - escape + 1) : str.substr(escape + 1, end - escape - 1); if (!commands.empty() && !commandsFunc(commands)) break; } str = str.substr(end + 1); continue; } } if (!str.empty()) textFunc(str); break; } } // The below two functions aren't used anymore, not bothering with StringView for them String preprocessEscapeCodes(String const& s) { bool escape = false; std::string result = s.utf8(); size_t escapeStartIdx = 0; for (size_t i = 0; i < result.size(); i++) { auto& c = result[i]; if (isEscapeCode(c)) { escape = true; escapeStartIdx = i; } if ((c <= SpecialCharLimit) && !(c == StartEsc)) escape = false; if ((c == EndEsc) && escape) result[escapeStartIdx] = StartEsc; } return {result}; } String extractCodes(String const& s) { bool escape = false; StringList result; String escapeCode; for (auto c : preprocessEscapeCodes(s)) { if (c == StartEsc) escape = true; if (c == EndEsc) { escape = false; for (auto command : escapeCode.split(',')) result.append(command); escapeCode = ""; } if (escape && (c != StartEsc)) escapeCode.append(c); } if (!result.size()) return ""; return "^" + result.join(",") + ";"; } } TextPositioning::TextPositioning() { pos = Vec2F(); hAnchor = HorizontalAnchor::LeftAnchor; vAnchor = VerticalAnchor::BottomAnchor; } TextPositioning::TextPositioning(Vec2F pos, HorizontalAnchor hAnchor, VerticalAnchor vAnchor, Maybe wrapWidth, Maybe charLimit) : pos(pos), hAnchor(hAnchor), vAnchor(vAnchor), wrapWidth(wrapWidth), charLimit(charLimit) {} TextPositioning::TextPositioning(Json const& v) { pos = v.opt("position").apply(jsonToVec2F).value(); hAnchor = HorizontalAnchorNames.getLeft(v.getString("horizontalAnchor", "left")); vAnchor = VerticalAnchorNames.getLeft(v.getString("verticalAnchor", "top")); wrapWidth = v.optUInt("wrapWidth"); charLimit = v.optUInt("charLimit"); } Json TextPositioning::toJson() const { return JsonObject{ {"position", jsonFromVec2F(pos)}, {"horizontalAnchor", HorizontalAnchorNames.getRight(hAnchor)}, {"verticalAnchor", VerticalAnchorNames.getRight(vAnchor)}, {"wrapWidth", jsonFromMaybe(wrapWidth)} }; } TextPositioning TextPositioning::translated(Vec2F translation) const { return {pos + translation, hAnchor, vAnchor, wrapWidth, charLimit}; } TextPainter::TextPainter(RendererPtr renderer, TextureGroupPtr textureGroup) : m_renderer(renderer), m_fontTextureGroup(textureGroup), m_fontSize(8), m_lineSpacing(1.30f), m_renderSettings({FontMode::Normal, Vec4B::filled(255), "hobo", ""}), m_splitIgnore(" \t"), m_splitForce("\n\v"), m_nonRenderedCharacters("\n\v\r") { reloadFonts(); m_reloadTracker = make_shared(); Root::singleton().registerReloadListener(m_reloadTracker); } RectF TextPainter::renderText(StringView s, TextPositioning const& position) { if (position.charLimit) { unsigned charLimit = *position.charLimit; return doRenderText(s, position, true, &charLimit); } else { return doRenderText(s, position, true, nullptr); } } RectF TextPainter::renderLine(StringView s, TextPositioning const& position) { if (position.charLimit) { unsigned charLimit = *position.charLimit; return doRenderLine(s, position, true, &charLimit); } else { return doRenderLine(s, position, true, nullptr); } } RectF TextPainter::renderGlyph(String::Char c, TextPositioning const& position) { return doRenderGlyph(c, position, true); } RectF TextPainter::determineTextSize(StringView s, TextPositioning const& position) { return doRenderText(s, position, false, nullptr); } RectF TextPainter::determineLineSize(StringView s, TextPositioning const& position) { return doRenderLine(s, position, false, nullptr); } RectF TextPainter::determineGlyphSize(String::Char c, TextPositioning const& position) { return doRenderGlyph(c, position, false); } int TextPainter::glyphWidth(String::Char c) { return m_fontTextureGroup.glyphWidth(c, m_fontSize); } int TextPainter::stringWidth(StringView s) { if (s.empty()) return 0; String font = m_renderSettings.font, setFont = font; m_fontTextureGroup.switchFont(font); int width = 0; Text::CommandsCallback commandsCallback = [&](StringView commands) { commands.forEachSplitView(",", [&](StringView command, size_t, size_t) { if (command == "reset") m_fontTextureGroup.switchFont(font = setFont); else if (command == "set") setFont = font; else if (command.beginsWith("font=")) m_fontTextureGroup.switchFont(font = command.substr(5)); }); return true; }; Text::TextCallback textCallback = [&](StringView text) { for (String::Char c : text) width += glyphWidth(c); return true; }; Text::processText(s, textCallback, commandsCallback); return width; } void TextPainter::processWrapText(StringView s, Maybe wrapWidth, WrapTextCallback textFunc, WrapCommandsCallback commandsFunc, bool includeCommandSides) { String font = m_renderSettings.font, setFont = font; m_fontTextureGroup.switchFont(font); unsigned linePixelWidth = 0; // How wide is this line so far int lines = 0; StringView splitIgnore(m_splitIgnore); StringView splitForce(m_splitForce); Text::CommandsCallback commandsCallback = [&](StringView commands) { StringView inner = commands.utf8().substr(1, commands.utf8Size() - 1); inner.forEachSplitView(",", [&](StringView command, size_t, size_t) { if (command == "reset") m_fontTextureGroup.switchFont(font = setFont); else if (command == "set") setFont = font; else if (command.beginsWith("font=")) m_fontTextureGroup.switchFont(font = command.substr(5)); }); if (commandsFunc) if (!commandsFunc(includeCommandSides ? commands : inner)) return false; return true; }; Text::TextCallback textCallback = [&](StringView text) { unsigned lineCharSize = 0; // how many characters in this line ? unsigned lineStart = 0; // Where does this line start ? unsigned splitPos = 0; // Where did we last see a place to split the string ? unsigned splitWidth = 0; // How wide was the string there ? for (auto character : text) { lineCharSize++; // assume at least one character if we get here. // is this a linefeed / cr / whatever that forces a line split ? if (splitForce.find(character) != NPos) { // knock one off the end because we don't render the CR if (!textFunc(text.substr(lineStart, lineCharSize - 1), lines++)) return false; lineStart += lineCharSize; // next line starts after the CR lineCharSize = 0; // with no characters in it. linePixelWidth = 0; // No width splitPos = 0; // and no known splits. } else { int charWidth = glyphWidth(character); // is it a place where we might want to split the line ? if (splitIgnore.find(character) != NPos) { splitPos = lineStart + lineCharSize; // this is the character after the space. splitWidth = linePixelWidth + charWidth; // the width of the string at // the split point, i.e. after the space. } // would the line be too long if we render this next character ? if (wrapWidth && (linePixelWidth + charWidth) > *wrapWidth) { // did we find somewhere to split the line ? if (splitPos) { if (!textFunc(text.substr(lineStart, (splitPos - lineStart) - 1), lines++)) return false; unsigned stringEnd = lineStart + lineCharSize; lineCharSize = stringEnd - splitPos; // next line has the characters after the space. unsigned stringWidth = (linePixelWidth - splitWidth); linePixelWidth = stringWidth + charWidth; // and is as wide as the bit after the space. lineStart = splitPos; // next line starts after the space splitPos = 0; // split is used up. } else { if (!textFunc(text.substr(lineStart, lineCharSize - 1), lines++)) return false; lineStart += lineCharSize - 1; // skip back by one to include that // character on the next line. lineCharSize = 1; // next line has that character in linePixelWidth = charWidth; // and is as wide as that character } } else { linePixelWidth += charWidth; } } } // if we hit the end of the string before hitting the end of the line. if (lineCharSize > 0 && !textFunc(text.substr(lineStart, lineCharSize), lines)) return false; return true; }; Text::processText(s, textCallback, commandsCallback, true); } List TextPainter::wrapTextViews(StringView s, Maybe wrapWidth) { List views = {}; bool active = false; StringView current; int lastLine = 0; auto addText = [&active, ¤t](StringView text) { // Merge views if they are adjacent if (active && current.utf8Ptr() + current.utf8Size() == text.utf8Ptr()) current = StringView(current.utf8Ptr(), current.utf8Size() + text.utf8Size()); else current = text; active = true; }; TextPainter::WrapTextCallback textCallback = [&](StringView text, int line) { if (lastLine != line) { views.push_back(current); lastLine = line; active = false; } addText(text); return true; }; TextPainter::WrapCommandsCallback commandsCallback = [&](StringView commands) { addText(commands); return true; }; processWrapText(s, wrapWidth, textCallback, commandsCallback, true); if (active) views.push_back(current); return views; } StringList TextPainter::wrapText(StringView s, Maybe wrapWidth) { StringList result; String current; int lastLine = 0; TextPainter::WrapTextCallback textCallback = [&](StringView text, int line) { if (lastLine != line) { result.append(move(current)); lastLine = line; } current += text; return true; }; TextPainter::WrapCommandsCallback commandsCallback = [&](StringView commands) { current += commands; return true; }; processWrapText(s, wrapWidth, textCallback, commandsCallback, true); if (!current.empty()) result.append(move(current)); return result; }; unsigned TextPainter::fontSize() const { return m_fontSize; } void TextPainter::setFontSize(unsigned size) { m_fontSize = size; } void TextPainter::setLineSpacing(float lineSpacing) { m_lineSpacing = lineSpacing; } void TextPainter::setMode(FontMode mode) { m_renderSettings.mode = mode; } void TextPainter::setSplitIgnore(String const& splitIgnore) { m_splitIgnore = splitIgnore; } void TextPainter::setFontColor(Vec4B color) { m_renderSettings.color = move(color); } void TextPainter::setProcessingDirectives(String directives) { m_renderSettings.directives = move(directives); } void TextPainter::setFont(String const& font) { m_renderSettings.font = font; } void TextPainter::addFont(FontPtr const& font, String const& name) { m_fontTextureGroup.addFont(font, name); } void TextPainter::reloadFonts() { m_fontTextureGroup.clearFonts(); m_fontTextureGroup.cleanup(0); auto assets = Root::singleton().assets(); auto defaultFont = assets->font("/hobo.ttf"); for (auto& fontPath : assets->scanExtension("ttf")) { auto font = assets->font(fontPath); if (font == defaultFont) continue; auto fileName = AssetPath::filename(fontPath); addFont(font->clone(), fileName.substr(0, fileName.findLast("."))); } m_fontTextureGroup.addFont(defaultFont->clone(), "hobo", true); } void TextPainter::cleanup(int64_t timeout) { m_fontTextureGroup.cleanup(timeout); } void TextPainter::applyCommands(StringView unsplitCommands) { unsplitCommands.forEachSplitView(",", [&](StringView command, size_t, size_t) { try { if (command == "reset") { m_renderSettings = m_savedRenderSettings; } else if (command == "set") { m_savedRenderSettings = m_renderSettings; } else if (command == "shadow") { m_renderSettings.mode = (FontMode)((int)m_renderSettings.mode | (int)FontMode::Shadow); } else if (command == "noshadow") { m_renderSettings.mode = (FontMode)((int)m_renderSettings.mode & (-1 ^ (int)FontMode::Shadow)); } else if (command.beginsWith("font=")) { m_renderSettings.font = command.substr(5); } else if (command.beginsWith("directives=")) { // Honestly this is really stupid but I just couldn't help myself // Should probably limit in the future m_renderSettings.directives = command.substr(11); } else { // expects both #... sequences and plain old color names. Color c = Color(command); c.setAlphaF(c.alphaF() * ((float)m_savedRenderSettings.color[3]) / 255); m_renderSettings.color = c.toRgba(); } } catch (JsonException&) { } catch (ColorException&) { } }); } RectF TextPainter::doRenderText(StringView s, TextPositioning const& position, bool reallyRender, unsigned* charLimit) { Vec2F pos = position.pos; if (s.empty()) return RectF(pos, pos); List lines = wrapTextViews(s, position.wrapWidth); int height = (lines.size() - 1) * m_lineSpacing * m_fontSize + m_fontSize; RenderSettings backupRenderSettings = m_renderSettings; m_savedRenderSettings = m_renderSettings; if (position.vAnchor == VerticalAnchor::BottomAnchor) pos[1] += (height - m_fontSize); else if (position.vAnchor == VerticalAnchor::VMidAnchor) pos[1] += (height - m_fontSize) / 2; RectF bounds = RectF::withSize(pos, Vec2F()); for (auto& i : lines) { bounds.combine(doRenderLine(i, { pos, position.hAnchor, position.vAnchor }, reallyRender, charLimit)); pos[1] -= m_fontSize * m_lineSpacing; if (charLimit && *charLimit == 0) break; } m_renderSettings = move(backupRenderSettings); return bounds; } RectF TextPainter::doRenderLine(StringView text, TextPositioning const& position, bool reallyRender, unsigned* charLimit) { if (m_reloadTracker->pullTriggered()) reloadFonts(); TextPositioning pos = position; if (pos.hAnchor == HorizontalAnchor::RightAnchor) { StringView trimmedString = charLimit ? text.substr(0, *charLimit) : text; pos.pos[0] -= stringWidth(trimmedString); pos.hAnchor = HorizontalAnchor::LeftAnchor; } else if (pos.hAnchor == HorizontalAnchor::HMidAnchor) { StringView trimmedString = charLimit ? text.substr(0, *charLimit) : text; pos.pos[0] -= floor((float)stringWidth(trimmedString) / 2); pos.hAnchor = HorizontalAnchor::LeftAnchor; } bool escape = false; String escapeCode; RectF bounds = RectF::withSize(pos.pos, Vec2F()); Text::TextCallback textCallback = [&](StringView text) { for (String::Char c : text) { if (charLimit) { if (*charLimit == 0) return false; else --* charLimit; } RectF glyphBounds = doRenderGlyph(c, pos, reallyRender); bounds.combine(glyphBounds); pos.pos[0] += glyphBounds.width(); } return true; }; Text::CommandsCallback commandsCallback = [&](StringView commands) { applyCommands(commands); return true; }; m_fontTextureGroup.switchFont(m_renderSettings.font); Text::processText(text, textCallback, commandsCallback); return bounds; } RectF TextPainter::doRenderGlyph(String::Char c, TextPositioning const& position, bool reallyRender) { if (m_nonRenderedCharacters.find(String(c)) != NPos) return RectF(); m_fontTextureGroup.switchFont(m_renderSettings.font); int width = glyphWidth(c); // Offset left by width if right anchored. float hOffset = 0; if (position.hAnchor == HorizontalAnchor::RightAnchor) hOffset = -width; else if (position.hAnchor == HorizontalAnchor::HMidAnchor) hOffset = -floor((float)width / 2); float vOffset = 0; if (position.vAnchor == VerticalAnchor::VMidAnchor) vOffset = -floor((float)m_fontSize / 2); else if (position.vAnchor == VerticalAnchor::TopAnchor) vOffset = -(float)m_fontSize; if (reallyRender) { if ((int)m_renderSettings.mode & (int)FontMode::Shadow) { Color shadow = Color::Black; uint8_t alphaU = m_renderSettings.color[3]; if (alphaU != 255) { float alpha = byteToFloat(alphaU); shadow.setAlpha(floatToByte(alpha * (1.5f - 0.5f * alpha))); } else shadow.setAlpha(alphaU); //Kae: Draw only one shadow glyph instead of stacking two, alpha modified to appear perceptually the same as vanilla renderGlyph(c, position.pos + Vec2F(hOffset, vOffset - 2), m_fontSize, 1, shadow.toRgba(), m_renderSettings.directives); } renderGlyph(c, position.pos + Vec2F(hOffset, vOffset), m_fontSize, 1, m_renderSettings.color, m_renderSettings.directives); } return RectF::withSize(position.pos + Vec2F(hOffset, vOffset), {(float)width, (int)m_fontSize}); } void TextPainter::renderGlyph(String::Char c, Vec2F const& screenPos, unsigned fontSize, float scale, Vec4B const& color, String const& processingDirectives) { if (!fontSize) return; const FontTextureGroup::GlyphTexture& glyphTexture = m_fontTextureGroup.glyphTexture(c, fontSize, processingDirectives); Vec2F offset = glyphTexture.processingOffset * (scale * 0.5f); m_renderer->immediatePrimitives().emplace_back(std::in_place_type_t(), glyphTexture.texture, screenPos + offset, scale, color, 0.0f); } }