From 5a14cc57f3daafa82c2d90d3fa940151bd7497cb Mon Sep 17 00:00:00 2001 From: Gleb Mazovetskiy Date: Sun, 2 Mar 2025 11:29:57 +0900 Subject: [PATCH] text renderer: colors layout fix + improved tests 1. Fixes DrawStringwithColors kerning fit handling and newline handling. 2. Splits tests into one file per function call, making it easier to compare diffs and add new tests. Also adds `--update_expected` argument to update the expected files with actual results. --- .gitignore | 2 +- Source/engine/render/text_render.cpp | 155 ++++++---- Source/engine/render/text_render.hpp | 4 +- Source/plrmsg.cpp | 9 +- test/CMakeLists.txt | 7 +- .../basic-colors.png | Bin 0 -> 1707 bytes .../text_render_integration_test/basic.png | Bin 0 -> 1285 bytes .../text_render_integration_test/expected.png | Bin 4230 -> 0 bytes .../kerning_fit_spacing-colors.png | Bin 0 -> 1450 bytes .../kerning_fit_spacing.png | Bin 0 -> 1354 bytes ...rning_fit_spacing__align_center-colors.png | Bin 0 -> 1558 bytes .../kerning_fit_spacing__align_center.png | Bin 0 -> 1510 bytes ...ng_fit_spacing__align_center__newlines.png | Bin 0 -> 2303 bytes ...__align_center__newlines_in_fmt-colors.png | Bin 0 -> 2442 bytes ...align_center__newlines_in_value-colors.png | Bin 0 -> 2428 bytes ...erning_fit_spacing__align_right-colors.png | Bin 0 -> 1520 bytes .../kerning_fit_spacing__align_right.png | Bin 0 -> 1497 bytes test/text_render_integration_test.cpp | 277 ++++++++++++++---- 18 files changed, 332 insertions(+), 122 deletions(-) create mode 100644 test/fixtures/text_render_integration_test/basic-colors.png create mode 100644 test/fixtures/text_render_integration_test/basic.png delete mode 100644 test/fixtures/text_render_integration_test/expected.png create mode 100644 test/fixtures/text_render_integration_test/kerning_fit_spacing-colors.png create mode 100644 test/fixtures/text_render_integration_test/kerning_fit_spacing.png create mode 100644 test/fixtures/text_render_integration_test/kerning_fit_spacing__align_center-colors.png create mode 100644 test/fixtures/text_render_integration_test/kerning_fit_spacing__align_center.png create mode 100644 test/fixtures/text_render_integration_test/kerning_fit_spacing__align_center__newlines.png create mode 100644 test/fixtures/text_render_integration_test/kerning_fit_spacing__align_center__newlines_in_fmt-colors.png create mode 100644 test/fixtures/text_render_integration_test/kerning_fit_spacing__align_center__newlines_in_value-colors.png create mode 100644 test/fixtures/text_render_integration_test/kerning_fit_spacing__align_right-colors.png create mode 100644 test/fixtures/text_render_integration_test/kerning_fit_spacing__align_right.png diff --git a/.gitignore b/.gitignore index 1c90cb068..44deb49c4 100644 --- a/.gitignore +++ b/.gitignore @@ -467,4 +467,4 @@ uwp-project/Assets/ui_art /.s390x-ccache/ # Test fixtures -/test/fixtures/text_render_integration_test/actual.png +/test/fixtures/text_render_integration_test/*-Actual.png diff --git a/Source/engine/render/text_render.cpp b/Source/engine/render/text_render.cpp index 19a530f8b..963246b64 100644 --- a/Source/engine/render/text_render.cpp +++ b/Source/engine/render/text_render.cpp @@ -556,7 +556,10 @@ int GetLineWidth(std::string_view text, GameFontTables size, int spacing, int *c return lineWidth != 0 ? (lineWidth - spacing) : 0; } -int GetLineWidth(std::string_view fmt, DrawStringFormatArg *args, std::size_t argsLen, size_t argsOffset, GameFontTables size, int spacing, int *charactersInLine) +bool IsConsumed(std::string_view s) { return s.empty() || s[0] == '\0'; }; + +int GetLineWidth(std::string_view fmt, DrawStringFormatArg *args, std::size_t argsLen, size_t argsOffset, GameFontTables size, int spacing, int *charactersInLine, + std::optional firstArgOffset) { int lineWidth = 0; CurrentFont currentFont; @@ -564,32 +567,48 @@ int GetLineWidth(std::string_view fmt, DrawStringFormatArg *args, std::size_t ar uint32_t codepoints = 0; char32_t prev = U'\0'; char32_t next; - + std::string_view remaining = fmt; FmtArgParser fmtArgParser { fmt, args, argsLen, argsOffset }; - std::string_view rest = fmt; - while (!rest.empty()) { - if ((prev == U'{' || prev == U'}') && static_cast(prev) == rest[0]) { - rest.remove_prefix(1); - continue; - } - const std::optional fmtArgPos = fmtArgParser(rest); - if (fmtArgPos) { - int argCodePoints; - lineWidth += GetLineWidth(args[*fmtArgPos].GetFormatted(), size, spacing, &argCodePoints); - codepoints += argCodePoints; - prev = U'\0'; - continue; - } + size_t cpLen; - next = ConsumeFirstUtf8CodePoint(&rest); - if (next == Utf8DecodeError) - break; - if (next == ZWSP) { - prev = next; - continue; + // The current formatted argument value being processed. + std::string_view curFormatted; + + // The string that we're currently processing: either `remaining` or `curFormatted`. + std::string_view *str; + + if (firstArgOffset.has_value()) { + curFormatted = args[argsOffset - 1].GetFormatted().substr(*firstArgOffset); + } + + for (; !(IsConsumed(curFormatted) && IsConsumed(remaining)); + str->remove_prefix(cpLen), prev = next) { + const bool isProcessingFormatArgValue = !IsConsumed(curFormatted); + str = isProcessingFormatArgValue ? &curFormatted : &remaining; + next = DecodeFirstUtf8CodePoint(*str, &cpLen); + if (next == Utf8DecodeError) break; + + // {{ and }} escapes in fmt. + if (!isProcessingFormatArgValue && (prev == U'{' || prev == U'}') && prev == next) continue; + // ZWSP are line-breaking opportunities that can otherwise be skipped for rendering as they have 0-width. + if (next == ZWSP) continue; + if (next == U'\n') break; + + if (!isProcessingFormatArgValue) { + const std::optional fmtArgPos = fmtArgParser(*str); + if (fmtArgPos.has_value()) { + // `fmtArgParser` has already consumed `*str`. Ensure the loop doesn't consume any more. + cpLen = 0; + // The loop assigns `prev = next`. + // We reset it to U'\0' to ensure that {{ and }} escapes are not processed accross + // the boundary of the format string and a formatted value. + next = U'\0'; + currentFont.clear(); + const DrawStringFormatArg &arg = args[*fmtArgPos]; + curFormatted = arg.GetFormatted(); + continue; + } } - if (next == U'\n') - break; if (!currentFont.load(size, text_color::ColorDialogWhite, next)) { next = U'?'; @@ -600,8 +619,7 @@ int GetLineWidth(std::string_view fmt, DrawStringFormatArg *args, std::size_t ar const uint8_t frame = next & 0xFF; lineWidth += (*currentFont.sprite)[frame].width() + spacing; - codepoints++; - prev = next; + ++codepoints; } if (charactersInLine != nullptr) *charactersInLine = codepoints; @@ -781,11 +799,11 @@ void DrawStringWithColors(const Surface &out, std::string_view fmt, DrawStringFo const Surface clippedOut = ClipSurface(out, rect); CurrentFont currentFont; - int curSpacing = opts.spacing; + const int originalSpacing = opts.spacing; if (HasAnyOf(opts.flags, UiFlags::KerningFitSpacing)) { - curSpacing = AdjustSpacingToFitHorizontally(lineWidth, opts.spacing, charactersInLine, rect.size.width); - if (curSpacing != opts.spacing && HasAnyOf(opts.flags, UiFlags::AlignCenter | UiFlags::AlignRight)) { - const int adjustedLineWidth = GetLineWidth(fmt, args, argsLen, 0, size, curSpacing, &charactersInLine); + opts.spacing = AdjustSpacingToFitHorizontally(lineWidth, originalSpacing, charactersInLine, rect.size.width); + if (opts.spacing != originalSpacing && HasAnyOf(opts.flags, UiFlags::AlignCenter | UiFlags::AlignRight)) { + const int adjustedLineWidth = GetLineWidth(fmt, args, argsLen, 0, size, opts.spacing, &charactersInLine); characterPosition.x = GetLineStartX(opts.flags, rect, adjustedLineWidth); } } @@ -795,28 +813,47 @@ void DrawStringWithColors(const Surface &out, std::string_view fmt, DrawStringFo std::string_view remaining = fmt; FmtArgParser fmtArgParser { fmt, args, argsLen }; size_t cpLen; - for (; !remaining.empty() && remaining[0] != '\0' - && (next = DecodeFirstUtf8CodePoint(remaining, &cpLen)) != Utf8DecodeError; - remaining.remove_prefix(cpLen), prev = next) { - if (((prev == U'{' || prev == U'}') && prev == next) - || next == ZWSP) - continue; - const std::optional fmtArgPos = fmtArgParser(remaining); - if (fmtArgPos) { - DoDrawString(clippedOut, args[*fmtArgPos].GetFormatted(), rect, characterPosition, lineWidth, charactersInLine, rightMargin, bottomMargin, size, - GetColorFromFlags(args[*fmtArgPos].GetFlags()), outlined, opts); - // `fmtArgParser` has already consumed `remaining`. Ensure the loop doesn't consume any more. - cpLen = 0; - // The loop assigns `prev = next`. We want `prev` to be `\0` after this. - next = U'\0'; - currentFont.clear(); - continue; + // The current formatted argument value being processed. + std::string_view curFormatted; + text_color curFormattedColor; + + // The string that we're currently processing: either `remaining` or `curFormatted`. + std::string_view *str; + + for (; !(IsConsumed(curFormatted) && IsConsumed(remaining)); + str->remove_prefix(cpLen), prev = next) { + const bool isProcessingFormatArgValue = !IsConsumed(curFormatted); + str = isProcessingFormatArgValue ? &curFormatted : &remaining; + next = DecodeFirstUtf8CodePoint(*str, &cpLen); + if (next == Utf8DecodeError) break; + + // {{ and }} escapes in fmt. + if (!isProcessingFormatArgValue && (prev == U'{' || prev == U'}') && prev == next) continue; + // ZWSP are line-breaking opportunities that can otherwise be skipped for rendering as they have 0-width. + if (next == ZWSP) continue; + + if (!isProcessingFormatArgValue) { + const std::optional fmtArgPos = fmtArgParser(*str); + if (fmtArgPos.has_value()) { + // `fmtArgParser` has already consumed `*str`. Ensure the loop doesn't consume any more. + cpLen = 0; + // The loop assigns `prev = next`. + // We reset it to U'\0' to ensure that {{ and }} escapes are not processed accross + // the boundary of the format string and a formatted value. + next = U'\0'; + currentFont.clear(); + const DrawStringFormatArg &arg = args[*fmtArgPos]; + curFormatted = arg.GetFormatted(); + curFormattedColor = GetColorFromFlags(arg.GetFlags()); + continue; + } } - if (!currentFont.load(size, color, next)) { + const text_color curColor = isProcessingFormatArgValue ? curFormattedColor : color; + if (!currentFont.load(size, curColor, next)) { next = U'?'; - if (!currentFont.load(size, color, next)) { + if (!currentFont.load(size, curColor, next)) { app_fatal("Missing fonts"); } } @@ -830,14 +867,22 @@ void DrawStringWithColors(const Surface &out, std::string_view fmt, DrawStringFo characterPosition.y = nextLineY; if (HasAnyOf(opts.flags, UiFlags::KerningFitSpacing)) { - int nextLineWidth = GetLineWidth(remaining.substr(cpLen), args, argsLen, fmtArgParser.offset(), size, opts.spacing, &charactersInLine); - curSpacing = AdjustSpacingToFitHorizontally(nextLineWidth, opts.spacing, charactersInLine, rect.size.width); + int nextLineWidth = isProcessingFormatArgValue + ? GetLineWidth(remaining, args, argsLen, fmtArgParser.offset(), size, originalSpacing, &charactersInLine, + /*firstArgOffset=*/args[fmtArgParser.offset() - 1].GetFormatted().size() - (curFormatted.size() - cpLen)) + : GetLineWidth(remaining.substr(cpLen), args, argsLen, fmtArgParser.offset(), size, originalSpacing, &charactersInLine); + opts.spacing = AdjustSpacingToFitHorizontally(nextLineWidth, originalSpacing, charactersInLine, rect.size.width); } - if (HasAnyOf(opts.flags, (UiFlags::AlignCenter | UiFlags::AlignRight))) { + if (HasAnyOf(opts.flags, UiFlags::AlignCenter | UiFlags::AlignRight)) { lineWidth = width; - if (remaining.size() > cpLen) - lineWidth += curSpacing + GetLineWidth(remaining.substr(cpLen), args, argsLen, fmtArgParser.offset(), size, curSpacing); + if (str->size() > cpLen) { + lineWidth += opts.spacing + + (isProcessingFormatArgValue + ? GetLineWidth(remaining, args, argsLen, fmtArgParser.offset(), size, opts.spacing, &charactersInLine, + /*firstArgOffset=*/args[fmtArgParser.offset() - 1].GetFormatted().size() - (curFormatted.size() - cpLen)) + : GetLineWidth(remaining.substr(cpLen), args, argsLen, fmtArgParser.offset(), size, opts.spacing, &charactersInLine)); + } } characterPosition.x = GetLineStartX(opts.flags, rect, lineWidth); @@ -845,8 +890,8 @@ void DrawStringWithColors(const Surface &out, std::string_view fmt, DrawStringFo continue; } - DrawFont(clippedOut, characterPosition, (*currentFont.sprite)[frame], color, outlined); - characterPosition.x += width + curSpacing; + DrawFont(clippedOut, characterPosition, (*currentFont.sprite)[frame], curColor, outlined); + characterPosition.x += width + opts.spacing; } if (HasAnyOf(opts.flags, UiFlags::PentaCursor)) { diff --git a/Source/engine/render/text_render.hpp b/Source/engine/render/text_render.hpp index c60a4e394..ef05c77c2 100644 --- a/Source/engine/render/text_render.hpp +++ b/Source/engine/render/text_render.hpp @@ -184,9 +184,11 @@ int GetLineWidth(std::string_view text, GameFontTables size = GameFont12, int sp * @param size Font size to use * @param spacing Extra spacing to add per character * @param charactersInLine Receives characters read until newline or terminator + * @param firstArgOffset If given, starts counting at `args[argsOffset - 1].GetFormatted().substr(*firstArgOffset)`. * @return Line width in pixels */ -int GetLineWidth(std::string_view fmt, DrawStringFormatArg *args, size_t argsLen, size_t argsOffset, GameFontTables size, int spacing, int *charactersInLine = nullptr); +int GetLineWidth(std::string_view fmt, DrawStringFormatArg *args, size_t argsLen, size_t argsOffset, GameFontTables size, int spacing, int *charactersInLine = nullptr, + std::optional firstArgOffset = std::nullopt); int GetLineHeight(std::string_view text, GameFontTables fontIndex); diff --git a/Source/plrmsg.cpp b/Source/plrmsg.cpp index 51b708218..08293902d 100644 --- a/Source/plrmsg.cpp +++ b/Source/plrmsg.cpp @@ -6,6 +6,7 @@ #include "plrmsg.h" #include +#include #include #include @@ -123,11 +124,11 @@ void DrawPlrMsg(const Surface &out) DrawHalfTransparentRectTo(out, x - 3, y, width + 6, message.lineHeight * chatlines); - std::vector args { - { std::string_view(text.data(), message.prefixLength), UiFlags::ColorWhitegold }, - { std::string_view(text.data() + message.prefixLength, text.size() - message.prefixLength), message.style } + std::array args { + DrawStringFormatArg { std::string_view(text.data(), message.prefixLength), UiFlags::ColorWhitegold }, + DrawStringFormatArg { std::string_view(text.data() + message.prefixLength, text.size() - message.prefixLength), message.style } }; - DrawStringWithColors(out, "{:s}{:s}", args, { { x, y }, { width, 0 } }, + DrawStringWithColors(out, "{:s}{:s}", args.data(), args.size(), { { x, y }, { width, 0 } }, { .flags = UiFlags::None, .lineHeight = message.lineHeight }); } } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 17d1a430e..35d003fc2 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -37,9 +37,6 @@ set(tests timedemo_test writehero_test ) -if(NOT USE_SDL1) - list(APPEND tests text_render_integration_test) -endif() set(standalone_tests codec_test crawl_test @@ -52,6 +49,9 @@ set(standalone_tests str_cat_test utf8_test ) +if(NOT USE_SDL1) + list(APPEND standalone_tests text_render_integration_test) +endif() set(benchmarks clx_render_benchmark crawl_benchmark @@ -106,6 +106,7 @@ target_link_dependencies(parse_int_test PRIVATE libdevilutionx_parse_int) target_link_dependencies(path_test PRIVATE libdevilutionx_pathfinding libdevilutionx_direction app_fatal_for_testing) target_link_dependencies(path_benchmark PRIVATE libdevilutionx_pathfinding app_fatal_for_testing) target_link_dependencies(str_cat_test PRIVATE libdevilutionx_strings) +target_link_dependencies(text_render_integration_test PRIVATE libdevilutionx_so GTest::gtest GTest::gmock) target_link_dependencies(utf8_test PRIVATE libdevilutionx_utf8) target_include_directories(writehero_test PRIVATE ../3rdParty/PicoSHA2) diff --git a/test/fixtures/text_render_integration_test/basic-colors.png b/test/fixtures/text_render_integration_test/basic-colors.png new file mode 100644 index 0000000000000000000000000000000000000000..dbbf19a540fb5aa0f5e0b5fd8c7af33443de3b04 GIT binary patch literal 1707 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 diff --git a/test/fixtures/text_render_integration_test/kerning_fit_spacing-colors.png b/test/fixtures/text_render_integration_test/kerning_fit_spacing-colors.png new file mode 100644 index 0000000000000000000000000000000000000000..2baed8422d8bee1848e11aef7050ae6beafad71a GIT binary patch literal 1450 zcmW-fc~DbV6vi(Sx2}?~h&_q+&N!To^)c647^#JGtA8CLmY&#dSbD?y?uCr4E?!%E8&a03nf31i?@Q z#}GP>vgsIyjgt@(g2D+2fr5Mu6eXc3R20Qf3_~ys#UVI?;TVoXgVE{mI)>Asf#6I! z#$+N)I!7zNKOic zDSWh>R4VoG@NjZ+VzF2>8VxS8rYL#n zDi~^aA81leUIzoWYIL7xDk>_~YPAlIbwIaJV1*7w{}kp5z@i@{B4L0$<89#Ef~RbB z^26tiEaRB*Oo?4?(?@i~^ixEG|I7X7{s>Q($M5`LEe~=q?ux2Dm-HlR`;UL!=uM+e z6;gExPdoKgcT}wOS1sv_s#XS`IjHCh&Dz9#5X!0V%^YK@?}r4rTVHx$=1V(d6=1Qy zbZmHJG3n zCWq$xwGNDs+Z0|`_}%@=8lLOr zF*;ewYwPoGu?b;$U;FT-)YUSj-Nv5U5V2ez>1rFlm2Yvmj9FiwVyt%*-A~E;-Nho( z$n9~0ZGtl?-z6?MNtS=Zuz6j#_}t|4!!=hN7-*cEVCT%{#W}02mWe8EQ=cb3&eh+{%D|5_^Qit z@gQQHaipP++@VzMRrPIf`Xx?<>kXp+mNZu{N>rYYepqU5YmjqxI%!Zo{d11@LEU>z xm({k{vu+b<^}}y2QZ!$@bx#)dq&l^3j$}lwdUXBB)IV?$fy8sGs9~ex@c%fLA=Cf> literal 0 HcmV?d00001 diff --git a/test/fixtures/text_render_integration_test/kerning_fit_spacing.png b/test/fixtures/text_render_integration_test/kerning_fit_spacing.png new file mode 100644 index 0000000000000000000000000000000000000000..e26efe87a8c05cf3a0386696586a4733b2fc49e7 GIT binary patch literal 1354 zcmW-fdr(tX5XP61zy&XOBNs^^4HvkUfQSJz5MCp}h)In!Vld%VgCL-T3gQb98ipMr1(NJvl+kVJzb5;7^+D}R%E@U=NH{H%m5RlAAt9Lo0h@h% zBpx2CI2<=eN8Hx7@7GsX+a4Mke>qumqonLY-dD!->>71yS&X`1jeK{Ac+1Kl#j@pM z4sV46%Z+J^TgDrvf73tdIWpL`ucImJ{CBCWWzNg>Xn;$!vvZ{Bz`;|3rwP9>spwn(2BEb`1?Sd_Hv|h{v zOK0cXs;ZX-1<$gwMiLW;WU^k7sLR{C+0*kRk9U~ENq2Vsg2f7PaPYFT!MpgLXc8F!e%qeepzjzv0VyCjp0J&Y4!34nEAD zkM0vXJLdcCDGAM?t77SO|~_KjW_ zxopd1b!gt$XYtb)Gd!)X|9G_WzR|HofA*+N+VT{B*~T&b_e6%qmuk^X`MRw4)z3NJ zx*vS%ia1ZSqjF!AmH76>pFNh4HLWhXQ}3J8B8o*eUgum^hH5tUN}FCPnqN%EZVgwC z?5_=stK@$^X&)Hn>F2(s@_AJW;jJ-^|DGL@*bx)uGg0@zcN8;;cN4;5{UyqRGyQuOy()XfK9rjik?-11eJFOmqpnOTS2!lSv67#% z`gBiPoyO)GOWq!Hcq+J1WV&m5-5pRGpt|HAGQPIw`uW01ylu1ZH literal 0 HcmV?d00001 diff --git a/test/fixtures/text_render_integration_test/kerning_fit_spacing__align_center-colors.png b/test/fixtures/text_render_integration_test/kerning_fit_spacing__align_center-colors.png new file mode 100644 index 0000000000000000000000000000000000000000..6a1cdead792a1ce42dcb5e3c95b4e93e6e452a15 GIT binary patch literal 1558 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 ChENUw literal 0 HcmV?d00001 diff --git a/test/fixtures/text_render_integration_test/kerning_fit_spacing__align_center__newlines.png b/test/fixtures/text_render_integration_test/kerning_fit_spacing__align_center__newlines.png new file mode 100644 index 0000000000000000000000000000000000000000..aba0ee3d30eb86624cfdaa572ff7dbf6e9d725f5 GIT binary patch literal 2303 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 Nz_4|q)g58Q{U5FoI`RMj literal 0 HcmV?d00001 diff --git a/test/fixtures/text_render_integration_test/kerning_fit_spacing__align_center__newlines_in_value-colors.png b/test/fixtures/text_render_integration_test/kerning_fit_spacing__align_center__newlines_in_value-colors.png new file mode 100644 index 0000000000000000000000000000000000000000..8cee148bcd670ab502ed0b482f08d7c5e8485072 GIT binary patch literal 2428 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 S>w65>8Su7va&EeXCjSSS_+?uF literal 0 HcmV?d00001 diff --git a/test/fixtures/text_render_integration_test/kerning_fit_spacing__align_right.png b/test/fixtures/text_render_integration_test/kerning_fit_spacing__align_right.png new file mode 100644 index 0000000000000000000000000000000000000000..e9bc45201adfc1f6ee964dfb8c7a448aed449276 GIT binary patch literal 1497 zcmXApdo+}J7{?!xdF36wlef$;##C=>Fv)F;BDb;4WYW;M>qKc>(#k4FtFdEqsVH4$ zZM9Am6)6>kwp`k>E=^m?iCRl-NH#?*C*s{-_Bo&D`+T3@bAEsQp7T2>^I6B7XFLx8 zm=dwb57j!9OEGPvrhaaA0NNmv%DqrqKb!Tlr5<(l^$jSx8ZNXB--v{UIA~Tub2|UA z8rqA21R!w`0toovtOFtvWCVm!AajFgKJ0dYbZ1aSf?5p4L6E;2FdZEn!w4KB35-qR zd^REAlRB6VhNFoNh6MW(IIe@^NO7FN2?8SsoJ5cqK@ud148dlj>jcS020?P!1ec3( z*(94wa=FNpd@jM~P2udj63y}&D0vm)4Lg-*4 za6sySuyI5-~Y zNK@2|mevP=T7W|UfdCwOb}&Ox!?UNy07B4n0MOF~^yk2tDQfr~+`9*vnGom?91hIX z71B9@GcnY#6q?*2*BSzifyaP>*4B=SiqGqHj){Q*&R{e@9IUABC^-N7iBq?-4qx8;L!n~Vsi19#eS(yp(s(ELEh|>a zmRY%Tc*~hAler9>eqH>&E$6TMsha9NcS_>QPH#Jzy*VpYo*XZU33m6m z&z}o)ZS7cI-hfj1GA^z&H1v@~(&Xq^Z*E??WXTC0Zy$%FSh#Q_i{->*F4fb+=gu7+ z9qsP!)@U>}H8n*=MQXJ=BO@a*F;SsV1P2F8rBW9c7i(*49*@UjvCtwnsFOQyKxd6} z`?cuaJJ5c=gq|8MEG$;3R5TjXK+hIbkwekiPpk%*qn3!=^Pa(n2TA&EI>=T^p7$qg^xRV@#$h-^8&%)2fub)U(n{28N*4_>qw(etNs z@Osy2R!{FjyV26D>bEIRccn^p_oqJs}34n=STzk1(E?07!5 ze0;6){3H2;2LCj^m94HYJf_q1(Lg39^k^?+$P*cQFvU9ZB-X3mD%wBishzK7Vb8a# z*OUAjHfzF%c^;?}XFrc&6xRDQDA?3n|Vyr#j|_qIAJ!+2z6agk8F zvE*i-zxj*9zHJjzH@8@?3LwTO{Zdq_t$1%$_WqdbyB6uM-(0i(5U;%4?TXpUfeuUF zRh4jeR9~B&4AZ}TbW_v6&s+MYbd1*ty?5FxlWTni(=?j%(tuvCz!il>9bKJS}PDb>xJqHC;)q72yGW=G>Y5CQn@{hnc zV4kZ##Dps$V+^u&;A7#oSNWP$`6X7`@Bv6TGw9e9%Xi8Xdyl_b8B2Ot9l=+{i%nbm zSVx9HSu)~sw@P;?Brk1Vz>XWGWhd5!ep);F{I>;{qh8;AV438cm8+Fib8|KKz@O&~ zwU}4h?wo77{k=+i|1)M;Zm2^|QbC?a<7Y+Ko}iy&C)ZA2TwHDRgYFsS)Ya-9QQvq# rUD9;6^I3Xl?}~e2|F2fwXU|W1R$e2UpDbsh9|J(* #include #include #include +#include +#include +#include +#include #include #include @@ -17,13 +22,157 @@ #include "engine/surface.hpp" #include "utils/paths.h" #include "utils/sdl_wrap.h" +#include "utils/str_cat.hpp" #include "utils/surface_to_png.hpp" +// Invoke with --update_expected to update the expected files with actual results. +static bool UpdateExpected; + namespace devilution { namespace { constexpr char FixturesPath[] = "../test/fixtures/text_render_integration_test/"; +struct TestFixture { + std::string name; + int width; + int height; + std::string_view fmt; + std::vector args {}; + TextRenderOptions opts { .flags = UiFlags::ColorUiGold }; + + friend void PrintTo(const TestFixture &f, std::ostream *os) + { + *os << f.name; + } +}; + +const TestFixture Fixtures[] { + TestFixture { + .name = "basic", + .width = 96, + .height = 15, + .fmt = "DrawString", + }, + TestFixture { + .name = "basic-colors", + .width = 186, + .height = 15, + .fmt = "{}{}{}{}", + .args = { + { "Draw", UiFlags::ColorUiSilver }, + { "String", UiFlags::ColorUiGold }, + { "With", UiFlags::ColorUiSilverDark }, + { "Colors", UiFlags::ColorUiGoldDark }, + }, + }, + TestFixture { + .name = "kerning_fit_spacing", + .width = 120, + .height = 15, + .fmt = "KerningFitSpacing", + .opts = { + .flags = UiFlags::KerningFitSpacing | UiFlags::ColorUiSilver, + }, + }, + TestFixture { + .name = "kerning_fit_spacing-colors", + .width = 120, + .height = 15, + .fmt = "{}{}{}", + .args = { + { "Kerning", UiFlags::ColorUiSilver }, + { "Fit", UiFlags::ColorUiGold }, + { "Spacing", UiFlags::ColorUiSilverDark }, + }, + .opts = { + .flags = UiFlags::KerningFitSpacing, + }, + }, + TestFixture { + .name = "kerning_fit_spacing__align_center", + .width = 170, + .height = 15, + .fmt = "KerningFitSpacing | AlignCenter", + .opts = { + .flags = UiFlags::KerningFitSpacing | UiFlags::AlignCenter | UiFlags::ColorUiSilver, + }, + }, + TestFixture { + .name = "kerning_fit_spacing__align_center-colors", + .width = 170, + .height = 15, + .fmt = "{}{}{}", + .args = { + { "KerningFitSpacing", UiFlags::ColorUiSilver }, + { " | ", UiFlags::ColorUiGold }, + { "AlignCenter", UiFlags::ColorUiSilverDark }, + }, + .opts = { + .flags = UiFlags::KerningFitSpacing | UiFlags::AlignCenter, + }, + }, + TestFixture { + .name = "kerning_fit_spacing__align_center__newlines", + .width = 170, + .height = 42, + .fmt = "KerningFitSpacing | AlignCenter\nShort line\nAnother overly long line", + .opts = { + .flags = UiFlags::KerningFitSpacing | UiFlags::AlignCenter | UiFlags::ColorUiSilver, + }, + }, + TestFixture { + .name = "kerning_fit_spacing__align_center__newlines_in_fmt-colors", + .width = 170, + .height = 42, + .fmt = "{}\n{}\n{}", + .args = { + { "KerningFitSpacing | AlignCenter", UiFlags::ColorUiSilver }, + { "Short line", UiFlags::ColorUiGold }, + { "Another overly long line", UiFlags::ColorUiSilverDark }, + }, + .opts = { + .flags = UiFlags::KerningFitSpacing | UiFlags::AlignCenter, + }, + }, + TestFixture { + .name = "kerning_fit_spacing__align_center__newlines_in_value-colors", + .width = 170, + .height = 42, + .fmt = "{}{}", + .args = { + { "KerningFitSpacing | AlignCenter\nShort line\nAnother overly ", UiFlags::ColorUiSilver }, + { "long line", UiFlags::ColorUiGold }, + }, + .opts = { + .flags = UiFlags::KerningFitSpacing | UiFlags::AlignCenter, + }, + }, + TestFixture { + .name = "kerning_fit_spacing__align_right", + .width = 170, + .height = 15, + .fmt = "KerningFitSpacing | AlignRight", + .opts = { + .flags = UiFlags::KerningFitSpacing | UiFlags::AlignRight | UiFlags::ColorUiSilver, + }, + }, + TestFixture { + .name = "kerning_fit_spacing__align_right-colors", + .width = 170, + .height = 15, + .fmt = "{}{}{}", + .args = { + { "KerningFitSpacing", UiFlags::ColorUiSilver }, + { " | ", UiFlags::ColorUiGold }, + { "AlignRight", UiFlags::ColorUiSilverDark }, + }, + .opts = { + .flags = UiFlags::KerningFitSpacing | UiFlags::AlignRight, + }, + }, +}; + SDLPaletteUniquePtr LoadPalette() { struct Color { @@ -31,7 +180,7 @@ SDLPaletteUniquePtr LoadPalette() }; std::array palData; LoadFileInMem("ui_art\\diablo.pal", palData); - SDLPaletteUniquePtr palette = SDLWrap::AllocPalette(256); + SDLPaletteUniquePtr palette = SDLWrap::AllocPalette(palData.size()); for (unsigned i = 0; i < palData.size(); i++) { palette->colors[i] = SDL_Color { palData[i].r, palData[i].g, palData[i].b, SDL_ALPHA_OPAQUE @@ -64,73 +213,85 @@ void DrawWithBorder(const Surface &out, const Rectangle &area, tl::function_ref< Size { area.size.width - 2, area.size.height - 2 } }); } -TEST(TextRenderIntegrationTest, GoldenTest) +MATCHER_P(FileContentsEq, expectedPath, + StrCat(negation ? "doesn't have" : "has", " the same contents as ", ::testing::PrintToString(expectedPath))) +{ + if (ReadFile(arg) != ReadFile(expectedPath)) { + if (UpdateExpected) { + CopyFileOverwrite(arg.c_str(), expectedPath.c_str()); + std::clog << "⬆️ Updated expected file at " << expectedPath << std::endl; + return true; + } + return false; + } + return true; +} + +class TextRenderIntegrationTest : public ::testing::TestWithParam { +public: + static void SetUpTestSuite() + { + palette = LoadPalette(); + } + static void TearDownTestSuite() + { + palette = nullptr; + } + +protected: + static SDLPaletteUniquePtr palette; +}; + +SDLPaletteUniquePtr TextRenderIntegrationTest::palette; + +TEST_P(TextRenderIntegrationTest, RenderAndCompareTest) { - SDLPaletteUniquePtr palette = LoadPalette(); - OwnedSurface out { Size { 200, 140 } }; + const TestFixture &fixture = GetParam(); + + OwnedSurface out = OwnedSurface { fixture.width + 20, fixture.height + 20 }; SDL_SetSurfacePalette(out.surface, palette.get()); ASSERT_NE(out.surface, nullptr); - int y = -15; - DrawWithBorder(out, Rectangle { Point { 0, y += 15 }, Size { 96, 15 } }, [&](const Rectangle &rect) { - DrawString(out, "DrawString", rect, - TextRenderOptions { .flags = UiFlags::ColorUiGold }); - }); - DrawWithBorder(out, Rectangle { Point { 0, y += 15 }, Size { 120, 15 } }, [&](const Rectangle &rect) { - DrawString(out, "KerningFitSpacing", rect, - TextRenderOptions { .flags = UiFlags::KerningFitSpacing | UiFlags::ColorUiSilver }); - }); - DrawWithBorder(out, Rectangle { Point { 0, y += 15 }, Size { 170, 15 } }, [&](const Rectangle &rect) { - DrawString(out, "KerningFitSpacing | AlignCenter", rect, - TextRenderOptions { .flags = UiFlags::KerningFitSpacing | UiFlags::AlignCenter | UiFlags::ColorUiSilver }); - }); - DrawWithBorder(out, Rectangle { Point { 0, y += 15 }, Size { 170, 15 } }, [&](const Rectangle &rect) { - DrawString(out, "KerningFitSpacing | AlignRight", rect, - TextRenderOptions { .flags = UiFlags::KerningFitSpacing | UiFlags::AlignRight | UiFlags::ColorUiSilver }); - }); - y += 4; - DrawWithBorder(out, Rectangle { Point { 0, y += 15 }, Size { 186, 15 } }, [&](const Rectangle &rect) { - DrawStringWithColors(out, "{}{}{}{}", - { { "Draw", UiFlags::ColorUiSilver }, - { "String", UiFlags::ColorUiGold }, - { "With", UiFlags::ColorUiSilverDark }, - { "Colors", UiFlags::ColorUiGoldDark } }, - rect); - }); - DrawWithBorder(out, Rectangle { Point { 0, y += 15 }, Size { 120, 15 } }, [&](const Rectangle &rect) { - DrawStringWithColors(out, "{}{}{}", - { { "Kerning", UiFlags::ColorUiSilver }, - { "Fit", UiFlags::ColorUiGold }, - { "Spacing", UiFlags::ColorUiSilverDark } }, - rect, - TextRenderOptions { .flags = UiFlags::KerningFitSpacing }); - }); - DrawWithBorder(out, Rectangle { Point { 0, y += 15 }, Size { 170, 15 } }, [&](const Rectangle &rect) { - DrawStringWithColors(out, "{}{}{}", - { { "KerningFitSpacing", UiFlags::ColorUiSilver }, - { " | ", UiFlags::ColorUiGold }, - { "AlignCenter", UiFlags::ColorUiSilverDark } }, - rect, - TextRenderOptions { .flags = UiFlags::KerningFitSpacing | UiFlags::AlignCenter | UiFlags::ColorUiSilver }); - }); - DrawWithBorder(out, Rectangle { Point { 0, y += 15 }, Size { 170, 15 } }, [&](const Rectangle &rect) { - DrawStringWithColors(out, "{}{}{}", - { { "KerningFitSpacing", UiFlags::ColorUiSilver }, - { " | ", UiFlags::ColorUiGold }, - { "AlignRight", UiFlags::ColorUiSilverDark } }, - rect, - TextRenderOptions { .flags = UiFlags::KerningFitSpacing | UiFlags::AlignRight | UiFlags::ColorUiSilver }); + DrawWithBorder(out, Rectangle { Point { 10, 10 }, Size { fixture.width, fixture.height } }, [&](const Rectangle &rect) { + if (fixture.args.empty()) { + DrawString(out, fixture.fmt, rect, fixture.opts); + } else { + DrawStringWithColors(out, fixture.fmt, fixture.args, rect, fixture.opts); + } }); - const std::string actualPath = paths::BasePath() + FixturesPath + "actual.png"; - const std::string expectedPath = paths::BasePath() + FixturesPath + "expected.png"; + const std::string actualPath = StrCat(paths::BasePath(), FixturesPath, GetParam().name, "-Actual.png"); + const std::string expectedPath = StrCat(paths::BasePath(), FixturesPath, GetParam().name, ".png"); SDL_RWops *actual = SDL_RWFromFile(actualPath.c_str(), "wb"); ASSERT_NE(actual, nullptr) << SDL_GetError(); ASSERT_TRUE(WriteSurfaceToFilePng(out, actual).has_value()); - EXPECT_EQ(ReadFile(actualPath), ReadFile(expectedPath)) << "\n" - << expectedPath << "\n" - << actualPath; + + EXPECT_THAT(actualPath, FileContentsEq(expectedPath)); } +INSTANTIATE_TEST_SUITE_P(GoldenTests, TextRenderIntegrationTest, + testing::ValuesIn(Fixtures), + [](const testing::TestParamInfo &info) { + std::string name = info.param.name; + std::replace(name.begin(), name.end(), '-', '_'); + return name; + }); + } // namespace } // namespace devilution + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + if (argc >= 2) { + for (int i = 1; i < argc; ++i) { + if (argv[i] != std::string_view("--update_expected")) { + std::cerr << "unknown argument: " << argv[i] << "\nUsage: " + << argv[0] << " [--update_expected]" << "\n"; + return 64; + } + } + UpdateExpected = true; + } + return RUN_ALL_TESTS(); +}