Skip to content

Commit 050e630

Browse files
authored
Merge pull request #2918 from BentoBoxWorld/fix/2917-bold-leak-legacy-roundtrip
Fix bold leaking across legacy round-trip in panel lore
2 parents 35f216a + 2e5aaef commit 050e630

2 files changed

Lines changed: 152 additions & 1 deletion

File tree

src/main/java/world/bentobox/bentobox/util/Util.java

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@
4949
import org.eclipse.jdt.annotation.Nullable;
5050

5151
import net.kyori.adventure.text.Component;
52+
import net.kyori.adventure.text.TextComponent;
53+
import net.kyori.adventure.text.format.NamedTextColor;
54+
import net.kyori.adventure.text.format.Style;
55+
import net.kyori.adventure.text.format.TextColor;
56+
import net.kyori.adventure.text.format.TextDecoration;
5257
import net.kyori.adventure.text.minimessage.MiniMessage;
5358
import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver;
5459
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
@@ -1327,7 +1332,133 @@ public static String convertInlineCommandsToMiniMessage(@NonNull String message)
13271332
*/
13281333
@NonNull
13291334
public static String componentToLegacy(@NonNull Component component) {
1330-
return SECTION_SERIALIZER.serialize(component);
1335+
StringBuilder sb = new StringBuilder();
1336+
// EmittedState[0] holds the last-emitted style (color + decorations) so the walker
1337+
// can compute transitions and emit §r where Adventure's serializer would not.
1338+
EmittedState state = new EmittedState();
1339+
appendComponentLegacy(sb, component, Style.empty(), state);
1340+
return sb.toString();
1341+
}
1342+
1343+
/**
1344+
* Mutable state used by {@link #appendComponentLegacy(StringBuilder, Component, Style, EmittedState)}
1345+
* to track the most recently emitted color and decorations. Adventure's
1346+
* {@link LegacyComponentSerializer} silently drops decoration-off transitions because legacy
1347+
* color codes have no "turn this decoration off" code — only §r resets everything. We work
1348+
* around that by tracking what is currently active and emitting §r ourselves when needed.
1349+
*/
1350+
private static final class EmittedState {
1351+
TextColor color;
1352+
boolean bold;
1353+
boolean italic;
1354+
boolean underlined;
1355+
boolean strikethrough;
1356+
boolean obfuscated;
1357+
boolean isFresh = true;
1358+
}
1359+
1360+
private static void appendComponentLegacy(StringBuilder sb, Component component, Style inherited, EmittedState state) {
1361+
// merge() with default strategy lets the child component override inherited fields,
1362+
// and inherits the parent's fields where the child leaves them unset.
1363+
Style effective = inherited.merge(component.style());
1364+
if (component instanceof TextComponent text && !text.content().isEmpty()) {
1365+
emitStyleTransition(sb, effective, state);
1366+
sb.append(text.content());
1367+
}
1368+
for (Component child : component.children()) {
1369+
appendComponentLegacy(sb, child, effective, state);
1370+
}
1371+
}
1372+
1373+
private static void emitStyleTransition(StringBuilder sb, Style style, EmittedState state) {
1374+
boolean wantBold = style.decoration(TextDecoration.BOLD) == TextDecoration.State.TRUE;
1375+
boolean wantItalic = style.decoration(TextDecoration.ITALIC) == TextDecoration.State.TRUE;
1376+
boolean wantUnderlined = style.decoration(TextDecoration.UNDERLINED) == TextDecoration.State.TRUE;
1377+
boolean wantStrikethrough = style.decoration(TextDecoration.STRIKETHROUGH) == TextDecoration.State.TRUE;
1378+
boolean wantObfuscated = style.decoration(TextDecoration.OBFUSCATED) == TextDecoration.State.TRUE;
1379+
TextColor wantColor = style.color();
1380+
1381+
// Determine if we need a hard reset: any decoration that was on must turn off,
1382+
// or the color must change to "no color" while one was previously active.
1383+
boolean needReset = (state.bold && !wantBold)
1384+
|| (state.italic && !wantItalic)
1385+
|| (state.underlined && !wantUnderlined)
1386+
|| (state.strikethrough && !wantStrikethrough)
1387+
|| (state.obfuscated && !wantObfuscated)
1388+
|| (state.color != null && wantColor == null);
1389+
1390+
if (needReset) {
1391+
sb.append(COLOR_CHAR).append('r');
1392+
state.color = null;
1393+
state.bold = false;
1394+
state.italic = false;
1395+
state.underlined = false;
1396+
state.strikethrough = false;
1397+
state.obfuscated = false;
1398+
}
1399+
1400+
// Emit color if it changed (or after a reset)
1401+
if (wantColor != null && (state.isFresh || !wantColor.equals(state.color) || needReset)) {
1402+
sb.append(legacyColorCode(wantColor));
1403+
state.color = wantColor;
1404+
}
1405+
1406+
// Emit decorations that should now be on but aren't yet
1407+
if (wantBold && !state.bold) {
1408+
sb.append(COLOR_CHAR).append('l');
1409+
state.bold = true;
1410+
}
1411+
if (wantItalic && !state.italic) {
1412+
sb.append(COLOR_CHAR).append('o');
1413+
state.italic = true;
1414+
}
1415+
if (wantUnderlined && !state.underlined) {
1416+
sb.append(COLOR_CHAR).append('n');
1417+
state.underlined = true;
1418+
}
1419+
if (wantStrikethrough && !state.strikethrough) {
1420+
sb.append(COLOR_CHAR).append('m');
1421+
state.strikethrough = true;
1422+
}
1423+
if (wantObfuscated && !state.obfuscated) {
1424+
sb.append(COLOR_CHAR).append('k');
1425+
state.obfuscated = true;
1426+
}
1427+
state.isFresh = false;
1428+
}
1429+
1430+
private static String legacyColorCode(TextColor color) {
1431+
// For named colors, use the standard single-character legacy code.
1432+
NamedTextColor named = NamedTextColor.nearestTo(color);
1433+
char code;
1434+
if (named == NamedTextColor.BLACK) code = '0';
1435+
else if (named == NamedTextColor.DARK_BLUE) code = '1';
1436+
else if (named == NamedTextColor.DARK_GREEN) code = '2';
1437+
else if (named == NamedTextColor.DARK_AQUA) code = '3';
1438+
else if (named == NamedTextColor.DARK_RED) code = '4';
1439+
else if (named == NamedTextColor.DARK_PURPLE) code = '5';
1440+
else if (named == NamedTextColor.GOLD) code = '6';
1441+
else if (named == NamedTextColor.GRAY) code = '7';
1442+
else if (named == NamedTextColor.DARK_GRAY) code = '8';
1443+
else if (named == NamedTextColor.BLUE) code = '9';
1444+
else if (named == NamedTextColor.GREEN) code = 'a';
1445+
else if (named == NamedTextColor.AQUA) code = 'b';
1446+
else if (named == NamedTextColor.RED) code = 'c';
1447+
else if (named == NamedTextColor.LIGHT_PURPLE) code = 'd';
1448+
else if (named == NamedTextColor.YELLOW) code = 'e';
1449+
else code = 'f';
1450+
// If the original color was a true hex (not a named color), emit the §x§R§R... form
1451+
// so it round-trips. Otherwise just emit the named code.
1452+
if (!(color instanceof NamedTextColor)) {
1453+
String hex = String.format("%06X", color.value());
1454+
StringBuilder out = new StringBuilder();
1455+
out.append(COLOR_CHAR).append('x');
1456+
for (int i = 0; i < 6; i++) {
1457+
out.append(COLOR_CHAR).append(hex.charAt(i));
1458+
}
1459+
return out.toString();
1460+
}
1461+
return COLOR_CHAR + Character.toString(code);
13311462
}
13321463

13331464
/**

src/test/java/world/bentobox/bentobox/util/LegacyToMiniMessageTest.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import static org.junit.jupiter.api.Assertions.assertTrue;
66

77
import net.kyori.adventure.text.Component;
8+
import net.kyori.adventure.text.format.TextDecoration;
89
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
910
import org.junit.jupiter.api.AfterEach;
1011
import org.junit.jupiter.api.BeforeEach;
@@ -93,6 +94,25 @@ void testRoundTripDoesNotProduceLiteralTags() {
9394
assertFalse(plainText.contains("<bold>"),
9495
"Round-trip should not produce literal <bold>: " + plainText);
9596
assertEquals("Resets ALL the settings to their", plainText);
97+
98+
// Inspect the children of the round-tripped component: only the "ALL " segment
99+
// may be bold. Bold must NOT leak into "the settings to their".
100+
StringBuilder boldText = new StringBuilder();
101+
collectBoldText(finalComp, false, boldText);
102+
assertEquals("ALL ", boldText.toString(),
103+
"Bold should only apply to 'ALL ', not leak into following segments");
104+
}
105+
106+
private static void collectBoldText(Component component, boolean inheritedBold, StringBuilder out) {
107+
TextDecoration.State state = component.decoration(TextDecoration.BOLD);
108+
boolean effective = state == TextDecoration.State.TRUE
109+
|| (state == TextDecoration.State.NOT_SET && inheritedBold);
110+
if (component instanceof net.kyori.adventure.text.TextComponent text && effective) {
111+
out.append(text.content());
112+
}
113+
for (Component child : component.children()) {
114+
collectBoldText(child, effective, out);
115+
}
96116
}
97117

98118
/**

0 commit comments

Comments
 (0)