From 15368533fc09cbbf60964d3ca5044fc9e8c91499 Mon Sep 17 00:00:00 2001 From: MarTrepodi Date: Tue, 14 Apr 2026 14:31:36 -0400 Subject: [PATCH 1/2] fix(examples): update string quoting and rename params for localization bundle calls docs(helpers): document parse_swgoh_string and its extended tag grammar chore: add commitlint config and ignore .pythonrc.py fix(helpers): extend parse_swgoh_string to cover full NGUI tag set (#83) --- .commitlintrc.json | 13 + .gitignore | 1 + .pre-commit-config.yaml | 1 + docs/api/helpers.md | 50 ++ examples/Async/get_location_bundle.py | 10 +- examples/Sync/get_location_bundle.py | 26 +- examples/Sync/get_location_bundle_adv.py | 46 +- src/swgoh_comlink/helpers/_localization.py | 501 +++++++++++++++++---- tests/unit/test_helpers.py | 300 +++++++++++- 9 files changed, 822 insertions(+), 126 deletions(-) create mode 100644 .commitlintrc.json 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..e7d0b6b 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,31 +428,150 @@ 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): + if output == "terminal" and (state.active_color or state.any_style_active()): result.append(ANSI_RESET) formatted = "".join(result) diff --git a/tests/unit/test_helpers.py b/tests/unit/test_helpers.py index 9a6c24b..155513b 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,298 @@ def test_empty_string(self): assert parse_swgoh_string("", output="web") == "

" +class TestParseTokensExtendedTags: + """Tokenization coverage for tags added in response to issue #83.""" + + def test_underline_tokens(self): + from swgoh_comlink.helpers._localization import _parse_tokens + + tokens = _parse_tokens("[u]x[/u]") + types = [t["type"] for t in tokens] + assert types == ["underline_open", "text", "underline_close"] + + def test_strike_tokens(self): + from swgoh_comlink.helpers._localization import _parse_tokens + + tokens = _parse_tokens("[s]x[/s]") + types = [t["type"] for t in tokens] + assert types == ["strike_open", "text", "strike_close"] + + def test_sprite_tokens(self): + from swgoh_comlink.helpers._localization import _parse_tokens + + tokens = _parse_tokens("[t]x[/t]") + types = [t["type"] for t in tokens] + assert types == ["sprite_open", "text", "sprite_close"] + + def test_sub_and_sup_tokens(self): + from swgoh_comlink.helpers._localization import _parse_tokens + + tokens = _parse_tokens("[sub]a[/sub][sup]b[/sup]") + types = [t["type"] for t in tokens] + assert types == ["sub_open", "text", "sub_close", "sup_open", "text", "sup_close"] + + def test_sub_sup_with_scale(self): + from swgoh_comlink.helpers._localization import _parse_tokens + + tokens = _parse_tokens("[sub=1.5]a[/sub][sup=0.8]b[/sup]") + opens = [t for t in tokens if t["type"] in ("sub_open", "sup_open")] + assert opens[0] == {"type": "sub_open", "scale": 1.5} + assert opens[1] == {"type": "sup_open", "scale": 0.8} + + def test_scale_tokens(self): + from swgoh_comlink.helpers._localization import _parse_tokens + + tokens = _parse_tokens("[y=2]xx[/y]") + types = [t["type"] for t in tokens] + assert types == ["scale_open", "text", "scale_close"] + assert tokens[0]["scale"] == 2.0 + + def test_three_digit_hex_expands(self): + from swgoh_comlink.helpers._localization import _parse_tokens + + tokens = _parse_tokens("[F0A]") + assert tokens[0]["type"] == "color" + assert tokens[0]["hex6"] == "FF00AA" + assert tokens[0]["a"] == 0xFF + + def test_four_digit_hex_rgba(self): + from swgoh_comlink.helpers._localization import _parse_tokens + + tokens = _parse_tokens("[F0A8]") + assert tokens[0]["type"] == "color" + assert tokens[0]["hex8"] == "FF00AA88" + assert tokens[0]["a"] == 0x88 + + def test_eight_digit_hex_rgba(self): + from swgoh_comlink.helpers._localization import _parse_tokens + + tokens = _parse_tokens("[12345678]") + assert tokens[0]["type"] == "color" + assert tokens[0]["hex8"] == "12345678" + + def test_single_hex_alpha_token(self): + from swgoh_comlink.helpers._localization import _parse_tokens + + tokens = _parse_tokens("[F]") + assert tokens[0] == {"type": "alpha", "a": 0xFF} + + def test_standalone_color_without_block(self): + from swgoh_comlink.helpers._localization import _parse_tokens + + tokens = _parse_tokens("[FF0000]red") + types = [t["type"] for t in tokens] + assert types == ["color", "text"] + + def test_named_tag_wins_over_hex(self): + """Single-letter named tags must not be misread as 1-digit alpha.""" + from swgoh_comlink.helpers._localization import _parse_tokens + + assert _parse_tokens("[b]")[0]["type"] == "bold_open" + assert _parse_tokens("[c]")[0]["type"] == "color_block_open" + assert _parse_tokens("[i]")[0]["type"] == "italic_open" + assert _parse_tokens("[s]")[0]["type"] == "strike_open" + assert _parse_tokens("[t]")[0]["type"] == "sprite_open" + assert _parse_tokens("[u]")[0]["type"] == "underline_open" + + +class TestParseSwgohStringBareExtended: + def test_strips_underline(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + assert parse_swgoh_string("[u]under[/u]", output="bare") == "under" + + def test_strips_strike(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + assert parse_swgoh_string("[s]cross[/s]", output="bare") == "cross" + + def test_strips_sub_sup_scale(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + text = "[sub=0.8]x[/sub][sup]y[/sup][y=1.5]z[/y]" + assert parse_swgoh_string(text, output="bare") == "xyz" + + def test_strips_sprite_and_short_color(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + assert parse_swgoh_string("[t]icon[/t][F0A]red", output="bare") == "iconred" + + def test_strips_alpha_literal(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + assert parse_swgoh_string("hello[8]world", output="bare") == "helloworld" + + +class TestParseSwgohStringTerminalExtended: + def test_underline_emits_ansi(self): + from swgoh_comlink.helpers._localization import ANSI_RESET, ANSI_UNDERLINE, parse_swgoh_string + + result = parse_swgoh_string("[u]x[/u]", output="terminal") + assert ANSI_UNDERLINE in result + assert "x" in result + assert result.endswith(ANSI_RESET) + + def test_strike_emits_ansi(self): + from swgoh_comlink.helpers._localization import ANSI_RESET, ANSI_STRIKE, parse_swgoh_string + + result = parse_swgoh_string("[s]x[/s]", output="terminal") + assert ANSI_STRIKE in result + assert result.endswith(ANSI_RESET) + + def test_underline_preserves_color(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + result = parse_swgoh_string("[FF0000][u]red[/u]still", output="terminal") + # After closing underline, the red foreground should be reapplied. + after_close = result.split("red")[1] + assert "\033[38;2;255;0;0m" in after_close + + def test_strike_close_reapplies_bold(self): + from swgoh_comlink.helpers._localization import ANSI_BOLD, parse_swgoh_string + + result = parse_swgoh_string("[b][s]x[/s]still_bold[/b]", output="terminal") + tail = result.split("x")[1] + assert ANSI_BOLD in tail + + def test_short_rgb_expands(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + result = parse_swgoh_string("[F0A]x", output="terminal") + assert "\033[38;2;255;0;170m" in result + + def test_standalone_color_without_c_wrapper(self): + """A color literal without [c] should still colorize in terminal output.""" + from swgoh_comlink.helpers._localization import parse_swgoh_string + + result = parse_swgoh_string("[00FF00]green", output="terminal") + assert "\033[38;2;0;255;0m" in result + assert "green" in result + + def test_dash_resets_color_outside_block(self): + from swgoh_comlink.helpers._localization import ANSI_RESET, parse_swgoh_string + + result = parse_swgoh_string("[FF0000]red[-]plain", output="terminal") + assert ANSI_RESET in result + assert "plain" in result + + +class TestParseSwgohStringDiscordExtended: + def test_underline_markdown(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + assert parse_swgoh_string("[u]x[/u]", output="discord") == "__x__" + + def test_strike_markdown(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + assert parse_swgoh_string("[s]x[/s]", output="discord") == "~~x~~" + + def test_sub_sup_scale_stripped(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + text = "[sub=0.8]a[/sub][sup]b[/sup][y=1.2]c[/y]" + assert parse_swgoh_string(text, output="discord") == "abc" + + +class TestParseSwgohStringWebExtended: + def test_underline_html(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + assert parse_swgoh_string("[u]x[/u]", 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

" + + def test_sub_default_no_scale(self): + from swgoh_comlink.helpers._localization import parse_swgoh_string + + assert parse_swgoh_string("[sub]x[/sub]", output="web") == "

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" + + # ── Additional quick-win helper tests ────────────────────────────────── From c21d60687479300a4391c1baa7f7f17ff50ecf1d Mon Sep 17 00:00:00 2001 From: MarTrepodi Date: Wed, 15 Apr 2026 07:20:43 -0400 Subject: [PATCH 2/2] test(helpers): close coverage gaps for every tag/color form in issue #83 Adds a TestParseSwgohStringIssue83Coverage class that exercises every tag and every color literal length called out in the issue across the output formats where they are renderable but were previously untested: - [t]/[/t] sprite, [y=X]/[/y] scale, [sub], [sub=X], [sup], [sup=X] in terminal/discord (must strip cleanly, no markup leakage) - 4-digit [RGBA] in bare and terminal (RGB channel emitted, alpha dropped) - 8-digit [RRGGBBAA] in bare, terminal, and web (web uses rgba() with the alpha channel converted to the 0-1 CSS float) - 1-digit [A] alpha-only literal in terminal (re-emits the prior RGB) - Standalone color literal in web with no enclosing [c] wrapper Adding the web standalone-color test surfaced a latent bug: the web finalizer never closed dangling spans/tags. _finalize_output now balances any open color span and unclosed bold/italic/underline/strike depth so strings that omit closing tags still produce well-formed HTML. Total localization tests: 89 (was 77). --- src/swgoh_comlink/helpers/_localization.py | 15 +++- tests/unit/test_helpers.py | 92 ++++++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/src/swgoh_comlink/helpers/_localization.py b/src/swgoh_comlink/helpers/_localization.py index e7d0b6b..baefa71 100644 --- a/src/swgoh_comlink/helpers/_localization.py +++ b/src/swgoh_comlink/helpers/_localization.py @@ -570,9 +570,22 @@ def _reapply_terminal_styles(state: _FormattingState, result: list[str]) -> None def _finalize_output(output: OutputFormat, state: _FormattingState, result: list[str]) -> str: - """Finalize and return the formatted output.""" + """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 155513b..d64f4eb 100644 --- a/tests/unit/test_helpers.py +++ b/tests/unit/test_helpers.py @@ -1446,6 +1446,98 @@ def test_issue_83_golden_string(self): 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 ──────────────────────────────────