diff --git a/.commitlintrc.json b/.commitlintrc.json
new file mode 100644
index 0000000..dd53f16
--- /dev/null
+++ b/.commitlintrc.json
@@ -0,0 +1,13 @@
+{
+ "extends": ["@commitlint/config-conventional"],
+ "rules": {
+ "type-enum": [2, "always", [
+ "feat", "fix", "refactor", "build", "deps",
+ "chore", "docs", "test", "style", "ci", "perf"
+ ]],
+ "scope-enum": [1, "always", [
+ "core", "helpers", "deps", "release", "ci"
+ ]],
+ "subject-max-length": [1, "always", 100]
+ }
+}
diff --git a/.gitignore b/.gitignore
index b2583d0..236002f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -53,6 +53,7 @@ tests/exhaustive/
scripts/
data/
gameData.json
+.pythonrc.py
# Unit test / coverage reports
htmlcov/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 842d655..b88f039 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -20,6 +20,7 @@ repos:
hooks:
- id: ruff
args: [--fix]
+ exclude: ^examples/
- id: ruff-format
# ── Mypy type checking (mirrors CI type-check job) ────────────────────
diff --git a/docs/api/helpers.md b/docs/api/helpers.md
index 0e6d072..3996a8b 100644
--- a/docs/api/helpers.md
+++ b/docs/api/helpers.md
@@ -321,6 +321,56 @@ Functions for querying omicron skill data from game data collections.
---
+## Localization Helpers
+
+Utilities for working with the SWGOH client's BBCode-style markup that appears
+throughout localization bundles (ability descriptions, mod descriptions, event
+banners, etc.).
+
+### parse_swgoh_string
+
+Parse a raw localization string and convert it to plain text, ANSI-colored
+terminal output, Discord markdown, or HTML. The parser follows
+`NGUIText.ParseSymbol()` semantics, so it handles the same tag family the game
+engine itself supports.
+
+```python
+from swgoh_comlink.helpers import parse_swgoh_string
+
+raw = "[c][FF0000][b]Boss[/b][-] deals [u]2x[/u] damage[/c]"
+
+parse_swgoh_string(raw) # 'Boss deals 2x damage'
+parse_swgoh_string(raw, output="discord") # '**Boss** deals __2x__ damage'
+parse_swgoh_string(raw, output="web") # HTML with , ,
+parse_swgoh_string(raw, output="terminal") # ANSI truecolor escapes
+```
+
+Supported markup:
+
+| Tag(s) | Purpose |
+|--------|---------|
+| `[c] [/c] [-c]` | Optional color block wrapper |
+| `[-]` | Reset the active color |
+| `[RGB]` / `[RGBA]` / `[RRGGBB]` / `[RRGGBBAA]` | Hex color literal (short forms duplicate each nibble) |
+| `[A]` | 1-digit hex alpha (reuses the previous RGB or white) |
+| `[b] [/b]` / `[i] [/i]` | Bold / italic |
+| `[u] [/u]` / `[s] [/s]` | Underline / strikethrough |
+| `[t] [/t]` | Sprite color marker (stripped in text output) |
+| `[sub] [sub=X] [/sub]` / `[sup] [sup=X] [/sup]` | Subscript / superscript with optional scale |
+| `[y=X] [/y]` | Font scaling (web output uses inline `font-size`) |
+| `\n` | Literal backslash-n escape -> newline |
+
+The `[c]...[/c]` wrapper is optional — bare `[FF0000]` takes effect on its
+own, and `[-]` clears the active color whether or not you're inside a `[c]`
+block.
+
+::: swgoh_comlink.helpers._localization.parse_swgoh_string
+ options:
+ show_root_heading: true
+ show_root_full_path: false
+
+---
+
## Decorators
### func_timer
diff --git a/examples/Async/get_location_bundle.py b/examples/Async/get_location_bundle.py
index fa6496d..0bd957d 100644
--- a/examples/Async/get_location_bundle.py
+++ b/examples/Async/get_location_bundle.py
@@ -25,10 +25,10 @@ async def main():
# Get the language bundle using the latest game data language version
# By default, Comlink compresses the data and encodes it into a BASE64 string for smaller payload and
# faster delivery. The result is a string value that must be decoded before using.
- location_bundle = await comlink.get_localization_bundle(id=game_data_versions['language'])
+ location_bundle = await comlink.get_localization_bundle(localization_id=game_data_versions["language"])
# Decode the Base64 result
- loc_bundle_decoded = base64.b64decode(location_bundle['localizationBundle'])
+ loc_bundle_decoded = base64.b64decode(location_bundle["localizationBundle"])
# Create a zipfile object to access the compressed content
zip_obj = zipfile.ZipFile(io.BytesIO(loc_bundle_decoded))
@@ -48,10 +48,10 @@ async def main():
# it as a string. You could also use the various other zipfile methods to extract all files to disk
# (see https://docs.python.org/3/library/zipfile.html for more details), or loop through the namelist()
# output and select only specific languages you are interested in.
- eng_obj = zip_obj.read('Loc_ENG_US.txt')
+ eng_obj = zip_obj.read("Loc_ENG_US.txt")
# Decode to string then split into individual lines
- eng_obj_decoded = eng_obj.decode('utf-8')
+ eng_obj_decoded = eng_obj.decode("utf-8")
eng_obj_lines = eng_obj_decoded.splitlines()
"""
@@ -60,7 +60,7 @@ async def main():
above.
"""
location_bundle_unzipped = await comlink.get_localization_bundle(
- id=game_data_versions['language'], unzip=True
+ localization_id=game_data_versions["language"], unzip=True
)
"""
diff --git a/examples/Sync/get_location_bundle.py b/examples/Sync/get_location_bundle.py
index e5412eb..db8f7af 100644
--- a/examples/Sync/get_location_bundle.py
+++ b/examples/Sync/get_location_bundle.py
@@ -2,11 +2,13 @@
get_location_bundle.py
Script to illustrate the basic usage of the swgoh_comlink wrapper library
"""
+
# import the SwgohComlink class from the swgoh_comlink module
-from swgoh_comlink import SwgohComlink
import base64
-import zipfile
import io
+import zipfile
+
+from swgoh_comlink import SwgohComlink
# create an instance of a SwgohComlink object
comlink = SwgohComlink()
@@ -20,10 +22,10 @@
# Get the language bundle using the latest game data language version
# By default, Comlink compresses the data and encodes it into a BASE64 string for smaller payload and faster delivery.
# The result is a string value that must be decoded before using.
-location_bundle = comlink.get_localization_bundle(localization_id=game_data_versions['language'])
+location_bundle = comlink.get_localization_bundle(localization_id=game_data_versions["language"])
# Decode the Base64 result
-loc_bundle_decoded = base64.b64decode(location_bundle['localizationBundle'])
+loc_bundle_decoded = base64.b64decode(location_bundle["localizationBundle"])
# Create a zipfile object to access the compressed content
zip_obj = zipfile.ZipFile(io.BytesIO(loc_bundle_decoded))
@@ -32,8 +34,8 @@
"""
Sample output:
-['Loc_CHS_CN.txt', 'Loc_CHT_CN.txt', 'Loc_ENG_US.txt', 'Loc_FRE_FR.txt', 'Loc_GER_DE.txt', 'Loc_IND_ID.txt',
- 'Loc_ITA_IT.txt', 'Loc_JPN_JP.txt', 'Loc_Key_Mapping.txt', 'Loc_KOR_KR.txt', 'Loc_POR_BR.txt', 'Loc_RUS_RU.txt',
+['Loc_CHS_CN.txt', 'Loc_CHT_CN.txt', 'Loc_ENG_US.txt', 'Loc_FRE_FR.txt', 'Loc_GER_DE.txt', 'Loc_IND_ID.txt',
+ 'Loc_ITA_IT.txt', 'Loc_JPN_JP.txt', 'Loc_Key_Mapping.txt', 'Loc_KOR_KR.txt', 'Loc_POR_BR.txt', 'Loc_RUS_RU.txt',
'Loc_SPA_XM.txt', 'Loc_THA_TH.txt', 'Loc_TUR_TR.txt']
"""
@@ -43,24 +45,24 @@
# You could also use the various other zipfile methods to extra all files to disk
# (see https://docs.python.org/3/library/zipfile.html for more details), or loop through the namelist()
# output and select only specific languages you are interested in.
-eng_obj = zip_obj.read('Loc_ENG_US.txt')
+eng_obj = zip_obj.read("Loc_ENG_US.txt")
# Decode to string then split into individual lines
-eng_obj_decoded = eng_obj.decode('utf-8')
+eng_obj_decoded = eng_obj.decode("utf-8")
eng_obj_lines = eng_obj_decoded.splitlines()
"""
-Alternatively, if you elected to have Comlink send an unzipped response, the result is a dictionary containing keys
+Alternatively, if you elected to have Comlink send an unzipped response, the result is a dictionary containing keys
for all of the language files (similar to the namelist() output from the zipfile method above.
"""
-location_bundle_unzipped = comlink.get_localization_bundle(id=game_data_versions['language'], unzip=True)
+location_bundle_unzipped = comlink.get_localization_bundle(localization_id=game_data_versions["language"], unzip=True)
"""
Each key of the result dictionary is a string that can be split into individual lines, or written to files.
>>> location_bundle_unzipped.keys()
-dict_keys(['Loc_CHS_CN.txt', 'Loc_CHT_CN.txt', 'Loc_ENG_US.txt', 'Loc_FRE_FR.txt', 'Loc_GER_DE.txt', 'Loc_IND_ID.txt',
-'Loc_ITA_IT.txt', 'Loc_JPN_JP.txt', 'Loc_Key_Mapping.txt', 'Loc_KOR_KR.txt', 'Loc_POR_BR.txt', 'Loc_RUS_RU.txt',
+dict_keys(['Loc_CHS_CN.txt', 'Loc_CHT_CN.txt', 'Loc_ENG_US.txt', 'Loc_FRE_FR.txt', 'Loc_GER_DE.txt', 'Loc_IND_ID.txt',
+'Loc_ITA_IT.txt', 'Loc_JPN_JP.txt', 'Loc_Key_Mapping.txt', 'Loc_KOR_KR.txt', 'Loc_POR_BR.txt', 'Loc_RUS_RU.txt',
'Loc_SPA_XM.txt', 'Loc_THA_TH.txt', 'Loc_TUR_TR.txt'])
Depending on the speed of your connection and other resource factors, the time needed to retrieve the localization
diff --git a/examples/Sync/get_location_bundle_adv.py b/examples/Sync/get_location_bundle_adv.py
index aba480f..76cfe98 100644
--- a/examples/Sync/get_location_bundle_adv.py
+++ b/examples/Sync/get_location_bundle_adv.py
@@ -3,11 +3,12 @@
Script to illustrate a more advanced and efficient usage of the swgoh_comlink
library to collect and parse the localization bundle.
"""
+
import base64
-import zipfile
import io
-import os
import json
+import os
+import zipfile
from pathlib import Path
from swgoh_comlink import SwgohComlink
@@ -30,11 +31,11 @@
def parse_loc_zip(
- zf: zipfile.ZipFile,
- output_dir: str | Path = ".",
- delimiter: str = "|",
- encoding: str = "utf-8",
- ) -> None:
+ zf: zipfile.ZipFile,
+ output_dir: str | Path = ".",
+ delimiter: str = "|",
+ encoding: str = "utf-8",
+) -> None:
"""
Parses the content of a zip file containing delimited text files, transforms the data into a
key-value JSON structure, and saves the result to specified output files.
@@ -65,18 +66,19 @@ def parse_loc_zip(
result = {}
# Process the zip file entry as a stream to conserve memory and extract the delimited text
- with zf.open(info) as raw:
- with io.TextIOWrapper(raw, encoding=encoding) as stream:
- for line in stream:
- if line.startswith("#"):
- continue
- # Use partition() instead of split() to split the line into key and value
- key, sep, value = line.rstrip("\r\n").partition(delimiter)
- # Only store the key-value pair if the delimiter is present
- if sep:
- # Note: many of the "values" contain BBCode style formatting directives
- # that could be stripped out before storing them in the JSON
- result[key] = value
+ with zf.open(info) as raw, io.TextIOWrapper(raw, encoding=encoding) as stream:
+ for line in stream:
+ if line.startswith("#"):
+ continue
+ # Use partition() instead of split() to split the line into key and value
+ key, sep, value = line.rstrip("\r\n").partition(delimiter)
+ # Only store the key-value pair if the delimiter is present
+ if sep:
+ # Note: many of the "values" contain BBCode style formatting directives
+ # that can be stripped or converted before storing them. See
+ # `swgoh_comlink.helpers.parse_swgoh_string` for a parser that
+ # emits plain text, Discord, terminal ANSI, or HTML output.
+ result[key] = value
with open(output_path, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False)
@@ -89,10 +91,10 @@ def parse_loc_zip(
print("Fetching localization bundle (id=%s)", remote_lang)
location_bundle = comlink.get_localization_bundle(
- localization_id=remote_lang,
- )
+ localization_id=remote_lang,
+ )
- loc_bundle_decoded = base64.b64decode(location_bundle['localizationBundle'])
+ loc_bundle_decoded = base64.b64decode(location_bundle["localizationBundle"])
parse_loc_zip(zipfile.ZipFile(io.BytesIO(loc_bundle_decoded)), _LANG_DIR)
print("Localization bundle parsed to %s", _LANG_DIR)
diff --git a/src/swgoh_comlink/helpers/_localization.py b/src/swgoh_comlink/helpers/_localization.py
index 507ce32..baefa71 100644
--- a/src/swgoh_comlink/helpers/_localization.py
+++ b/src/swgoh_comlink/helpers/_localization.py
@@ -4,7 +4,7 @@
from __future__ import annotations
import re
-from typing import Literal
+from typing import Any, Literal
__all__ = ["parse_swgoh_string"]
@@ -12,11 +12,13 @@
ANSI_RESET = "\033[0m"
ANSI_BOLD = "\033[1m"
ANSI_ITALIC = "\033[3m"
+ANSI_UNDERLINE = "\033[4m"
+ANSI_STRIKE = "\033[9m"
# ANSI color support: convert hex to nearest 256-color or use truecolor if available
def _hex_to_ansi_truecolor(hex_color: str) -> str:
- """Convert a hex color string to an ANSI truecolor escape sequence."""
+ """Convert a hex color string (RRGGBB) to an ANSI truecolor escape sequence."""
hex_color = hex_color.lstrip("#")
r = int(hex_color[0:2], 16)
g = int(hex_color[2:4], 16)
@@ -24,64 +26,201 @@ def _hex_to_ansi_truecolor(hex_color: str) -> str:
return f"\033[38;2;{r};{g};{b}m"
-def _parse_tokens(text: str) -> list[dict[str, str]]:
+def _expand_color_hex(raw: str) -> tuple[int, int, int, int]:
+ """
+ Expand a SWGOH/NGUI color literal into an (r, g, b, a) tuple.
+
+ Accepted lengths (matching NGUIText.ParseSymbol):
+ - 3 digits: RGB, each nibble duplicated -> RRGGBB, alpha = FF
+ - 4 digits: RGBA, each nibble duplicated -> RRGGBBAA
+ - 6 digits: RRGGBB, alpha = FF
+ - 8 digits: RRGGBBAA
+ """
+ raw = raw.lower()
+ if len(raw) == 3:
+ r = int(raw[0] * 2, 16)
+ g = int(raw[1] * 2, 16)
+ b = int(raw[2] * 2, 16)
+ a = 255
+ elif len(raw) == 4:
+ r = int(raw[0] * 2, 16)
+ g = int(raw[1] * 2, 16)
+ b = int(raw[2] * 2, 16)
+ a = int(raw[3] * 2, 16)
+ elif len(raw) == 6:
+ r = int(raw[0:2], 16)
+ g = int(raw[2:4], 16)
+ b = int(raw[4:6], 16)
+ a = 255
+ elif len(raw) == 8:
+ r = int(raw[0:2], 16)
+ g = int(raw[2:4], 16)
+ b = int(raw[4:6], 16)
+ a = int(raw[6:8], 16)
+ else: # pragma: no cover - regex prevents other lengths
+ raise ValueError(f"Unsupported color hex length: {raw!r}")
+ return r, g, b, a
+
+
+def _expand_alpha_hex(raw: str) -> int:
+ """Expand a 1-digit alpha hex literal (e.g. 'F') to an int 0-255."""
+ return int(raw * 2, 16)
+
+
+# Tag grammar - order matters. Named tags and parameterized tags are tried
+# before bare hex literals because tag names (b, c, f, ...) are also valid
+# hex digits.
+_TAG_RE = re.compile(
+ r"""
+ \[ (?:
+ /?sub(?:=\d+(?:\.\d+)?)? # [sub], [/sub], [sub=1.5]
+ | /?sup(?:=\d+(?:\.\d+)?)? # [sup], [/sup], [sup=0.8]
+ | /?y(?:=\d+(?:\.\d+)?)? # [y=1.2], [/y]
+ | /c | -c | /b | /i | /u | /s | /t
+ | b | i | u | s | c | t | -
+ | [0-9A-Fa-f]{8} # RRGGBBAA
+ | [0-9A-Fa-f]{6} # RRGGBB
+ | [0-9A-Fa-f]{4} # RGBA short
+ | [0-9A-Fa-f]{3} # RGB short
+ | [0-9A-Fa-f] # single-hex alpha
+ ) \]
+ """,
+ re.IGNORECASE | re.VERBOSE,
+)
+_HEX_COLOR_RE = re.compile(r"^[0-9A-Fa-f]+$")
+_PARAM_RE = re.compile(r"^(/?)(sub|sup|y)(?:=(\d+(?:\.\d+)?))?$", re.IGNORECASE)
+
+
+def _parse_tokens(text: str) -> list[dict[str, Any]]:
"""
Tokenize a SWGOH BBCode string into a list of tokens.
Each token is a dict with a 'type' and relevant fields.
Token types:
- - 'text': plain text content
- - 'color': opens a color scope {'hex': 'F0FF23'}
- - 'color_reset': [-] inside a color scope
- - 'color_end': [/c] closes color scope
- - 'bold_open': [b]
- - 'bold_close': [/b]
- - 'italic_open': [i]
- - 'italic_close': [/i]
- - 'newline': \\n literal escape in the source string
+ - 'text': plain text content {'value': str}
+ - 'color': set foreground color (any length) {'r','g','b','a','hex6','hex8'}
+ - 'alpha': set alpha only (1-digit hex) {'a': int}
+ - 'color_block_open': [c]
+ - 'color_reset': [-]
+ - 'color_end': [/c] or [-c]
+ - 'bold_open' / 'bold_close'
+ - 'italic_open' / 'italic_close'
+ - 'underline_open' / 'underline_close'
+ - 'strike_open' / 'strike_close'
+ - 'sprite_open' / 'sprite_close': [t] / [/t] (no-op in text outputs)
+ - 'sub_open' / 'sub_close': [sub], [sub=X], [/sub] {'scale': float|None}
+ - 'sup_open' / 'sup_close': [sup], [sup=X], [/sup] {'scale': float|None}
+ - 'scale_open' / 'scale_close': [y=X], [/y] {'scale': float|None}
+ - 'newline': \\n literal escape in the source string
"""
- # Tag pattern: [TAG] where TAG is /c, -c, -, b, /b, i, /i, or a hex color
- TAG_RE = re.compile(r"(\[(?:/c|-c|-|/b|/i|b|i|c|[0-9A-Fa-f]{6})\])", re.IGNORECASE)
+ tokens: list[dict[str, Any]] = []
+ last_end = 0
- tokens = []
- parts = TAG_RE.split(text)
+ for match in _TAG_RE.finditer(text):
+ # Emit any text between the prior match and this tag.
+ if match.start() > last_end:
+ _emit_text_segment(tokens, text[last_end : match.start()])
+ last_end = match.end()
- for part in parts:
- if not part:
+ inner = match.group(0)[1:-1]
+ lower = inner.lower()
+
+ # Parameterized / named tags -------------------------------------------------
+ if lower in ("/c", "-c"):
+ tokens.append({"type": "color_end"})
+ continue
+ if lower == "c":
+ tokens.append({"type": "color_block_open"})
+ continue
+ if lower == "-":
+ tokens.append({"type": "color_reset"})
+ continue
+ if lower == "b":
+ tokens.append({"type": "bold_open"})
+ continue
+ if lower == "/b":
+ tokens.append({"type": "bold_close"})
+ continue
+ if lower == "i":
+ tokens.append({"type": "italic_open"})
+ continue
+ if lower == "/i":
+ tokens.append({"type": "italic_close"})
+ continue
+ if lower == "u":
+ tokens.append({"type": "underline_open"})
+ continue
+ if lower == "/u":
+ tokens.append({"type": "underline_close"})
+ continue
+ if lower == "s":
+ tokens.append({"type": "strike_open"})
+ continue
+ if lower == "/s":
+ tokens.append({"type": "strike_close"})
+ continue
+ if lower == "t":
+ tokens.append({"type": "sprite_open"})
+ continue
+ if lower == "/t":
+ tokens.append({"type": "sprite_close"})
continue
- if TAG_RE.fullmatch(part):
- inner = part[1:-1] # strip [ ]
- lower = inner.lower()
-
- if lower in ("/c", "-c"):
- tokens.append({"type": "color_end"})
- elif lower == "c":
- tokens.append({"type": "color_block_open"})
- elif lower == "-":
- tokens.append({"type": "color_reset"})
- elif lower == "b":
- tokens.append({"type": "bold_open"})
- elif lower == "/b":
- tokens.append({"type": "bold_close"})
- elif lower == "i":
- tokens.append({"type": "italic_open"})
- elif lower == "/i":
- tokens.append({"type": "italic_close"})
- elif re.fullmatch(r"[0-9A-Fa-f]{6}", inner):
- tokens.append({"type": "color", "hex": inner.upper()})
- else:
- # Split on literal \n sequences in the source
- segments = part.split("\\n")
- for idx, segment in enumerate(segments):
- if segment:
- tokens.append({"type": "text", "value": segment})
- if idx < len(segments) - 1:
- tokens.append({"type": "newline"})
+ param_match = _PARAM_RE.match(inner)
+ if param_match:
+ closing, name, scale_raw = param_match.groups()
+ name = name.lower()
+ scale = float(scale_raw) if scale_raw is not None else None
+ if name == "sub":
+ token_type = "sub_close" if closing else "sub_open"
+ elif name == "sup":
+ token_type = "sup_close" if closing else "sup_open"
+ else: # y
+ token_type = "scale_close" if closing else "scale_open"
+ entry: dict[str, Any] = {"type": token_type}
+ if not closing:
+ entry["scale"] = scale
+ tokens.append(entry)
+ continue
+
+ # Hex color literal ----------------------------------------------------------
+ if _HEX_COLOR_RE.match(inner):
+ if len(inner) == 1:
+ tokens.append({"type": "alpha", "a": _expand_alpha_hex(inner)})
+ else:
+ r, g, b, a = _expand_color_hex(inner)
+ tokens.append(
+ {
+ "type": "color",
+ "r": r,
+ "g": g,
+ "b": b,
+ "a": a,
+ "hex6": f"{r:02X}{g:02X}{b:02X}",
+ "hex8": f"{r:02X}{g:02X}{b:02X}{a:02X}",
+ }
+ )
+ continue
+
+ # Remaining trailing text
+ if last_end < len(text):
+ _emit_text_segment(tokens, text[last_end:])
return tokens
+def _emit_text_segment(tokens: list[dict[str, Any]], segment: str) -> None:
+ """Emit text segment(s), splitting on literal '\\n' escapes into newline tokens."""
+ if not segment:
+ return
+ parts = segment.split("\\n")
+ for idx, part in enumerate(parts):
+ if part:
+ tokens.append({"type": "text", "value": part})
+ if idx < len(parts) - 1:
+ tokens.append({"type": "newline"})
+
+
def parse_swgoh_string(text: str, output: OutputFormat = "bare") -> str:
"""
Parse a SWGOH BBCode-style rich text string and convert it to the
@@ -94,36 +233,49 @@ def parse_swgoh_string(text: str, output: OutputFormat = "bare") -> str:
Returns:
A formatted string in the target format.
- Supported tags:
- [c] - Open color block
- [RRGGBB] - Set hex color (must follow [c])
- [-] - Reset color within a color block
- [/c] [-c] - Close color block
- [b] [/b] - Bold
- [i] [/i] - Italic
- \\n - Newline (literal backslash-n in source)
+ Supported tags (parity with NGUIText.ParseSymbol):
+ ``[c]`` ``[/c]`` ``[-c]`` - Optional color block wrapper
+ ``[-]`` - Reset the active color
+ ``[RGB]`` ``[RGBA]`` - Short-form hex color (each nibble duplicated)
+ ``[RRGGBB]`` ``[RRGGBBAA]`` - Hex color (alpha defaults to FF when omitted)
+ ``[A]`` - 1-digit hex alpha (reuses previous RGB or white)
+ ``[b]`` ``[/b]`` - Bold
+ ``[i]`` ``[/i]`` - Italic
+ ``[u]`` ``[/u]`` - Underline
+ ``[s]`` ``[/s]`` - Strikethrough
+ ``[t]`` ``[/t]`` - Sprite color forcing (stripped in text output)
+ ``[sub]`` ``[sub=X]`` ``[/sub]`` - Subscript (optional font-size scale)
+ ``[sup]`` ``[sup=X]`` ``[/sup]`` - Superscript (optional font-size scale)
+ ``[y=X]`` ``[/y]`` - Font scaling
+ ``\\n`` - Newline (literal backslash-n in source)
+
+ Notes:
+ The `[c]...[/c]` wrapper is optional; bare color codes such as `[FF0000]`
+ take effect on their own. A `[-]` anywhere clears the active color.
Output formats:
- terminal - ANSI truecolor escape codes
- discord - Discord markdown (**bold**, *italic*, no color support)
- web - HTML with , ,
+ terminal - ANSI truecolor escape codes + SGR styles
+ discord - Discord markdown (**bold**, *italic*, __underline__, ~~strike~~)
+ web - HTML (, , , , , , )
bare - Plain text, all markup stripped
"""
tokens = _parse_tokens(text)
state = _FormattingState()
- result = []
+ result: list[str] = []
for token in tokens:
token_type = token["type"]
if token_type == "text":
- result.append(token["value"])
+ result.append(str(token["value"]))
elif token_type == "newline":
result.append("\n")
elif token_type == "color_block_open":
state.in_color_block = True
elif token_type == "color":
_handle_color(token, output, state, result)
+ elif token_type == "alpha":
+ _handle_alpha(token, output, state, result)
elif token_type == "color_reset":
_handle_color_reset(output, state, result)
elif token_type == "color_end":
@@ -136,6 +288,27 @@ def parse_swgoh_string(text: str, output: OutputFormat = "bare") -> str:
_handle_italic_open(output, state, result)
elif token_type == "italic_close":
_handle_italic_close(output, state, result)
+ elif token_type == "underline_open":
+ _handle_underline_open(output, state, result)
+ elif token_type == "underline_close":
+ _handle_underline_close(output, state, result)
+ elif token_type == "strike_open":
+ _handle_strike_open(output, state, result)
+ elif token_type == "strike_close":
+ _handle_strike_close(output, state, result)
+ elif token_type == "sub_open":
+ _handle_sub_open(token, output, result)
+ elif token_type == "sub_close":
+ _handle_sub_close(output, result)
+ elif token_type == "sup_open":
+ _handle_sup_open(token, output, result)
+ elif token_type == "sup_close":
+ _handle_sup_close(output, result)
+ elif token_type == "scale_open":
+ _handle_scale_open(token, output, result)
+ elif token_type == "scale_close":
+ _handle_scale_close(output, result)
+ # sprite_open / sprite_close are intentional no-ops in text outputs
return _finalize_output(output, state, result)
@@ -144,27 +317,71 @@ class _FormattingState:
"""Tracks the current formatting state during parsing."""
def __init__(self) -> None:
- self.active_color: str | None = None
+ # Active color kept as RGBA tuple + cached hex for renderers.
+ self.active_color: tuple[int, int, int, int] | None = None
self.in_color_block = False
self.bold_depth = 0
self.italic_depth = 0
-
-
-def _handle_color(token: dict[str, str], output: OutputFormat, state: _FormattingState, result: list[str]) -> None:
- """Handle color token."""
- state.active_color = token["hex"]
+ self.underline_depth = 0
+ self.strike_depth = 0
+
+ def any_style_active(self) -> bool:
+ return bool(self.bold_depth or self.italic_depth or self.underline_depth or self.strike_depth)
+
+
+def _web_color_style(color: tuple[int, int, int, int]) -> str:
+ """Render an RGBA tuple as a CSS ``color:`` value, preferring ``#HEX`` when opaque."""
+ r, g, b, a = color
+ if a == 255:
+ return f"color:#{r:02X}{g:02X}{b:02X}"
+ alpha = round(a / 255, 3)
+ return f"color:rgba({r},{g},{b},{alpha})"
+
+
+def _handle_color(token: dict[str, Any], output: OutputFormat, state: _FormattingState, result: list[str]) -> None:
+ """Handle color token (set foreground color)."""
+ new_color: tuple[int, int, int, int] = (
+ int(token["r"]),
+ int(token["g"]),
+ int(token["b"]),
+ int(token["a"]),
+ )
+ if output == "web" and state.active_color is not None:
+ # Close the previous span before opening a new one.
+ result.append("")
+ state.active_color = new_color
if output == "terminal":
- result.append(_hex_to_ansi_truecolor(state.active_color))
+ result.append(_hex_to_ansi_truecolor(f"{new_color[0]:02X}{new_color[1]:02X}{new_color[2]:02X}"))
elif output == "web":
- result.append(f'')
+ result.append(f'')
+
+
+def _handle_alpha(token: dict[str, Any], output: OutputFormat, state: _FormattingState, result: list[str]) -> None:
+ """Handle 1-digit alpha token."""
+ alpha = int(token["a"])
+ if state.active_color is None:
+ new_color = (255, 255, 255, alpha)
+ else:
+ r, g, b, _ = state.active_color
+ new_color = (r, g, b, alpha)
+ if output == "web" and state.active_color is not None:
+ result.append("")
+ state.active_color = new_color
+ if output == "terminal":
+ # ANSI truecolor foreground has no alpha channel; the RGB stays the same,
+ # so only re-emit the sequence if the RGB actually changed.
+ result.append(_hex_to_ansi_truecolor(f"{new_color[0]:02X}{new_color[1]:02X}{new_color[2]:02X}"))
+ elif output == "web":
+ result.append(f'')
def _handle_color_reset(output: OutputFormat, state: _FormattingState, result: list[str]) -> None:
"""Handle color reset token [-]."""
- if output == "terminal" and state.in_color_block:
+ if output == "terminal":
+ # Reset covers both active color and all SGR styles; reapply the styles after.
result.append(ANSI_RESET)
_reapply_terminal_styles(state, result)
- elif output == "web" and state.active_color:
+ elif output == "web" and state.active_color is not None:
result.append("")
state.active_color = None
@@ -175,13 +392,12 @@ def _handle_color_end(output: OutputFormat, state: _FormattingState, result: lis
if output == "terminal":
result.append(ANSI_RESET)
_reapply_terminal_styles(state, result)
- elif output == "web" and state.active_color:
+ elif output == "web" and state.active_color is not None:
result.append("")
state.active_color = None
def _handle_bold_open(output: OutputFormat, state: _FormattingState, result: list[str]) -> None:
- """Handle bold open token [b]."""
state.bold_depth += 1
if output == "terminal":
result.append(ANSI_BOLD)
@@ -192,14 +408,9 @@ def _handle_bold_open(output: OutputFormat, state: _FormattingState, result: lis
def _handle_bold_close(output: OutputFormat, state: _FormattingState, result: list[str]) -> None:
- """Handle bold close token [/b]."""
state.bold_depth = max(0, state.bold_depth - 1)
if output == "terminal" and state.bold_depth == 0:
- result.append(ANSI_RESET)
- if state.active_color:
- result.append(_hex_to_ansi_truecolor(state.active_color))
- if state.italic_depth > 0:
- result.append(ANSI_ITALIC)
+ _reset_and_reapply_terminal(state, result, drop_bold=True)
elif output == "discord":
result.append("**")
elif output == "web":
@@ -207,7 +418,6 @@ def _handle_bold_close(output: OutputFormat, state: _FormattingState, result: li
def _handle_italic_open(output: OutputFormat, state: _FormattingState, result: list[str]) -> None:
- """Handle italic open token [i]."""
state.italic_depth += 1
if output == "terminal":
result.append(ANSI_ITALIC)
@@ -218,32 +428,164 @@ def _handle_italic_open(output: OutputFormat, state: _FormattingState, result: l
def _handle_italic_close(output: OutputFormat, state: _FormattingState, result: list[str]) -> None:
- """Handle italic close token [/i]."""
state.italic_depth = max(0, state.italic_depth - 1)
if output == "terminal" and state.italic_depth == 0:
- result.append(ANSI_RESET)
- if state.active_color:
- result.append(_hex_to_ansi_truecolor(state.active_color))
- if state.bold_depth > 0:
- result.append(ANSI_BOLD)
+ _reset_and_reapply_terminal(state, result, drop_italic=True)
elif output == "discord":
result.append("*")
elif output == "web":
result.append("")
+def _handle_underline_open(output: OutputFormat, state: _FormattingState, result: list[str]) -> None:
+ state.underline_depth += 1
+ if output == "terminal":
+ result.append(ANSI_UNDERLINE)
+ elif output == "discord":
+ result.append("__")
+ elif output == "web":
+ result.append("")
+
+
+def _handle_underline_close(output: OutputFormat, state: _FormattingState, result: list[str]) -> None:
+ state.underline_depth = max(0, state.underline_depth - 1)
+ if output == "terminal" and state.underline_depth == 0:
+ _reset_and_reapply_terminal(state, result, drop_underline=True)
+ elif output == "discord":
+ result.append("__")
+ elif output == "web":
+ result.append("")
+
+
+def _handle_strike_open(output: OutputFormat, state: _FormattingState, result: list[str]) -> None:
+ state.strike_depth += 1
+ if output == "terminal":
+ result.append(ANSI_STRIKE)
+ elif output == "discord":
+ result.append("~~")
+ elif output == "web":
+ result.append("")
+
+
+def _handle_strike_close(output: OutputFormat, state: _FormattingState, result: list[str]) -> None:
+ state.strike_depth = max(0, state.strike_depth - 1)
+ if output == "terminal" and state.strike_depth == 0:
+ _reset_and_reapply_terminal(state, result, drop_strike=True)
+ elif output == "discord":
+ result.append("~~")
+ elif output == "web":
+ result.append("")
+
+
+def _handle_sub_open(token: dict[str, Any], output: OutputFormat, result: list[str]) -> None:
+ if output != "web":
+ return
+ scale = token.get("scale")
+ if scale is None:
+ result.append("")
+ else:
+ result.append(f'')
+
+
+def _handle_sub_close(output: OutputFormat, result: list[str]) -> None:
+ if output == "web":
+ result.append("")
+
+
+def _handle_sup_open(token: dict[str, Any], output: OutputFormat, result: list[str]) -> None:
+ if output != "web":
+ return
+ scale = token.get("scale")
+ if scale is None:
+ result.append("")
+ else:
+ result.append(f'')
+
+
+def _handle_sup_close(output: OutputFormat, result: list[str]) -> None:
+ if output == "web":
+ result.append("")
+
+
+def _handle_scale_open(token: dict[str, Any], output: OutputFormat, result: list[str]) -> None:
+ if output != "web":
+ return
+ scale = token.get("scale")
+ if scale is None:
+ result.append("")
+ else:
+ result.append(f'')
+
+
+def _handle_scale_close(output: OutputFormat, result: list[str]) -> None:
+ if output == "web":
+ result.append("")
+
+
+def _fmt_scale(value: float) -> str:
+ """Format a float scale without trailing zeros (1.5 stays 1.5, 2.0 becomes 2)."""
+ if value == int(value):
+ return str(int(value))
+ return f"{value:g}"
+
+
+def _reset_and_reapply_terminal(
+ state: _FormattingState,
+ result: list[str],
+ *,
+ drop_bold: bool = False,
+ drop_italic: bool = False,
+ drop_underline: bool = False,
+ drop_strike: bool = False,
+) -> None:
+ """Emit ANSI_RESET and reapply remaining active color/styles (minus any dropped ones)."""
+ if not (
+ state.active_color or state.any_style_active() or drop_bold or drop_italic or drop_underline or drop_strike
+ ):
+ return
+ result.append(ANSI_RESET)
+ if state.active_color is not None:
+ r, g, b, _ = state.active_color
+ result.append(_hex_to_ansi_truecolor(f"{r:02X}{g:02X}{b:02X}"))
+ if not drop_bold and state.bold_depth > 0:
+ result.append(ANSI_BOLD)
+ if not drop_italic and state.italic_depth > 0:
+ result.append(ANSI_ITALIC)
+ if not drop_underline and state.underline_depth > 0:
+ result.append(ANSI_UNDERLINE)
+ if not drop_strike and state.strike_depth > 0:
+ result.append(ANSI_STRIKE)
+
+
def _reapply_terminal_styles(state: _FormattingState, result: list[str]) -> None:
- """Re-apply active bold/italic styles after ANSI reset."""
+ """Re-apply active bold/italic/underline/strike styles after ANSI reset."""
if state.bold_depth > 0:
result.append(ANSI_BOLD)
if state.italic_depth > 0:
result.append(ANSI_ITALIC)
+ if state.underline_depth > 0:
+ result.append(ANSI_UNDERLINE)
+ if state.strike_depth > 0:
+ result.append(ANSI_STRIKE)
def _finalize_output(output: OutputFormat, state: _FormattingState, result: list[str]) -> str:
- """Finalize and return the formatted output."""
- if output == "terminal" and (state.active_color or state.bold_depth or state.italic_depth):
+ """Finalize and return the formatted output, balancing any unclosed markup."""
+ if output == "terminal" and (state.active_color or state.any_style_active()):
result.append(ANSI_RESET)
+ elif output == "web":
+ # Close any tags the source string left dangling so the HTML stays well-formed.
+ # Inner-most first: styles, then color span.
+ for _ in range(state.strike_depth):
+ result.append("")
+ for _ in range(state.underline_depth):
+ result.append("")
+ for _ in range(state.italic_depth):
+ result.append("")
+ for _ in range(state.bold_depth):
+ result.append("")
+ if state.active_color is not None:
+ result.append("")
formatted = "".join(result)
diff --git a/tests/unit/test_helpers.py b/tests/unit/test_helpers.py
index 9a6c24b..d64f4eb 100644
--- a/tests/unit/test_helpers.py
+++ b/tests/unit/test_helpers.py
@@ -948,7 +948,13 @@ def test_hex_color_token(self):
from swgoh_comlink.helpers._localization import _parse_tokens
tokens = _parse_tokens("[F0FF23]")
- assert tokens[0] == {"type": "color", "hex": "F0FF23"}
+ assert tokens[0]["type"] == "color"
+ assert tokens[0]["hex6"] == "F0FF23"
+ assert tokens[0]["hex8"] == "F0FF23FF"
+ assert tokens[0]["r"] == 0xF0
+ assert tokens[0]["g"] == 0xFF
+ assert tokens[0]["b"] == 0x23
+ assert tokens[0]["a"] == 0xFF
def test_empty_parts_skipped(self):
from swgoh_comlink.helpers._localization import _parse_tokens
@@ -1148,6 +1154,390 @@ def test_empty_string(self):
assert parse_swgoh_string("", output="web") == "
x
" + + def test_strike_html(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + assert parse_swgoh_string("[s]x[/s]", output="web") == "x
x
" + + def test_sub_with_scale(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + result = parse_swgoh_string("[sub=0.8]x[/sub]", output="web") + assert 'x' in result + + def test_sup_with_scale(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + result = parse_swgoh_string("[sup=1.25]x[/sup]", output="web") + assert 'x' in result + + def test_scale_integer_formatted_without_decimal(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + result = parse_swgoh_string("[y=2]x[/y]", output="web") + assert 'x' in result + + def test_short_hex_color_span(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + result = parse_swgoh_string("[F0A]x[/c]", output="web") + assert 'x' in result + + def test_rgba_color_uses_rgba_css(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + # [F0A8] -> FF00AA with alpha 0x88 (136). 136/255 ≈ 0.533 + result = parse_swgoh_string("[F0A8]x[/c]", output="web") + assert "rgba(255,0,170,0.533)" in result + + def test_alpha_only_reuses_prior_rgb(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + result = parse_swgoh_string("[FF0000]red[8]faded", output="web") + # First span opens with opaque red, then closes, then reopens at alpha 0x88. + assert '' in result + assert "rgba(255,0,0," in result + + def test_sprite_tag_stripped(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + assert parse_swgoh_string("[t]icon[/t]", output="web") == "icon
" + + +class TestParseSwgohStringComplexExtended: + def test_deep_nested_styles(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + assert parse_swgoh_string("[b][u][i]x[/i][/u][/b]", output="bare") == "x" + + def test_discord_nested_style_combo(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + result = parse_swgoh_string("[b][u][s]x[/s][/u][/b]", output="discord") + assert result == "**__~~x~~__**" + + def test_web_nested_preserves_tag_structure(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + result = parse_swgoh_string("[b][u]x[/u][/b]", output="web") + assert result == "x
" + + def test_color_then_alpha_then_new_color(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + text = "[FF0000]red[8]faded[00FF00]green[/c]" + result = parse_swgoh_string(text, output="web") + # One opening span for each color change; matching closes at [/c]. + assert result.count('") == 3 + + def test_issue_83_golden_string(self): + """End-to-end smoke for all four outputs on a representative string.""" + from swgoh_comlink.helpers._localization import parse_swgoh_string + + s = "[c][FF0000][b]Boss[/b][-] deals [u]2x[/u] damage[/c]" + assert parse_swgoh_string(s, output="bare") == "Boss deals 2x damage" + assert parse_swgoh_string(s, output="discord") == "**Boss** deals __2x__ damage" + + +class TestParseSwgohStringIssue83Coverage: + """Round-trip coverage for every tag and color format named in issue #83. + + For text-only outputs (bare/terminal/discord) the visual-only tags + ([t], [y=X], [sub], [sup]) are expected to be stripped without leaking + any markup characters into the rendered string. + """ + + # --- [t] / [/t] sprite color forcing --------------------------------------- + def test_sprite_terminal_strips(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + assert parse_swgoh_string("[t]icon[/t]", output="terminal") == "icon" + + def test_sprite_discord_strips(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + assert parse_swgoh_string("[t]icon[/t]", output="discord") == "icon" + + # --- [y=FLOAT] / [/y] font scaling ----------------------------------------- + def test_scale_terminal_strips(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + assert parse_swgoh_string("[y=1.5]big[/y]", output="terminal") == "big" + + # --- [sub] / [sub=FLOAT] / [/sub] subscript -------------------------------- + def test_sub_terminal_strips(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + assert parse_swgoh_string("[sub]x[/sub]", output="terminal") == "x" + assert parse_swgoh_string("[sub=0.8]x[/sub]", output="terminal") == "x" + + # --- [sup] / [sup=FLOAT] / [/sup] superscript ------------------------------ + def test_sup_terminal_strips(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + assert parse_swgoh_string("[sup]x[/sup]", output="terminal") == "x" + assert parse_swgoh_string("[sup=1.25]x[/sup]", output="terminal") == "x" + + # --- 4-digit [RGBA] color -------------------------------------------------- + def test_four_digit_rgba_bare(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + assert parse_swgoh_string("[F0A8]x[/c]", output="bare") == "x" + + def test_four_digit_rgba_terminal_uses_rgb_channel(self): + """Terminal can't render alpha, but it must still emit the RGB channel.""" + from swgoh_comlink.helpers._localization import parse_swgoh_string + + result = parse_swgoh_string("[F0A8]x[/c]", output="terminal") + # F0A8 -> RGB FF00AA, alpha 88 -> ignored by ANSI but RGB still shown. + assert "\033[38;2;255;0;170m" in result + assert "x" in result + + # --- 8-digit [RRGGBBAA] color ---------------------------------------------- + def test_eight_digit_rgba_bare(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + assert parse_swgoh_string("[12345678]x[/c]", output="bare") == "x" + + def test_eight_digit_rgba_terminal(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + result = parse_swgoh_string("[12345678]x[/c]", output="terminal") + # 0x12=18, 0x34=52, 0x56=86 (alpha 0x78 dropped by ANSI) + assert "\033[38;2;18;52;86m" in result + assert "x" in result + + def test_eight_digit_rgba_web(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + result = parse_swgoh_string("[12345678]x[/c]", output="web") + # alpha 0x78 = 120 -> 120/255 = 0.471 + assert "rgba(18,52,86,0.471)" in result + + # --- 1-digit [A] alpha-only ------------------------------------------------ + def test_alpha_only_terminal_no_rgb_change(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + result = parse_swgoh_string("[FF0000]r[8]still_red[/c]", output="terminal") + # The alpha tag re-emits the same RGB sequence in terminal output. + assert result.count("\033[38;2;255;0;0m") == 2 + assert "still_red" in result + + # --- [c] is optional: standalone color without wrapper --------------------- + def test_standalone_color_web_without_c_wrapper(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + result = parse_swgoh_string("[00FF00]green", output="web") + assert 'green' in result + + # ── Additional quick-win helper tests ──────────────────────────────────