Skip to content

FIX: raise on characters the ASCII art font cannot render - #2962

Open
Pushpak Siva Sai (Pushpak731) wants to merge 5 commits into
microsoft:mainfrom
Pushpak731:fix/ascii-art-converter-dropped-characters
Open

Pushpak Siva Sai (Pushpak731) wants to merge 5 commits into
microsoft:mainfrom
Pushpak731:fix/ascii-art-converter-dropped-characters

Conversation

@Pushpak731

Copy link
Copy Markdown

Description

Fixes #2942 (Option A, as leaned in the issue and consistent with the AsciiSmugglerConverter convention from #2540).

AsciiArtConverter passed the prompt straight to art.text2art, which silently omits characters without a glyph. Per the issue's measurements, accented Latin, CJK, emoji, smart quotes and em-dashes are dropped by all 371 fonts, so AsciiArtConverter(font="block").convert_async(prompt="café") converted 3 glyphs, and a prompt written entirely in a non-ASCII script converted to the empty string — a red-teaming prompt silently mangled or emptied before reaching the target.

The converter now validates the prompt against the resolved font (after rand picks one) and raises ValueError naming the unrenderable characters, their count, and remedies, e.g.:

Cannot convert 1 character(s) to ASCII art with font 'block': 'é'. The font has no glyph for them
and they would be silently dropped from the converted prompt; remove them, transliterate them,
or pick another font.

Implementation notes:

  • Renderability is detected by rendering the single character with text2art and checking for non-whitespace output — the same omission mechanism the issue measured, so the check cannot drift from art's actual behavior. Results are cached (functools.cache) since (character, font) pairs repeat across calls.
  • Whitespace characters are layout for text2art and always pass; an empty prompt still converts (existing test contract).
  • The count follows the AsciiSmugglerConverter convention (occurrences); the listing is the sorted unique set.
  • Font-dependent characters (e.g. ß, dropped by 307/371 fonts) raise based on the resolved font, so under font="rand" only borderline prompts vary per draw — the all-fonts-dropped set above is deterministic, as the issue notes.
  • The per-font check also covers fixed fonts outside the rand pool, e.g. 'A' in the hills font (from the issue's table).

Behavior change (deliberate, per the issue): prompts containing unrenderable characters now raise instead of converting with those characters silently dropped. Existing ASCII-input behavior is unchanged.

Tests and Documentation

  • 10 new tests in tests/unit/converter/test_ascii_art_converter.py: ASCII prompt still converts, empty prompt still converts, whitespace-only prompt converts, accented/CJK/emoji/smart-quote prompts each raise naming their characters, occurrence counting with unique listing, the hills-font ASCII case, the rand path raising on the deterministic set, and the message's remedies. The 7 existing tests pass unchanged.
  • Full converter suite: 1854 passed, 64 skipped locally. ruff check and ruff format clean on both changed files.
  • The convert_async docstring now documents the new ValueError (matching the smuggler's style); no other documentation references this converter's input restrictions.

AsciiArtConverter passed the prompt straight to art.text2art, which
silently omits characters without a glyph: accented Latin, CJK, emoji,
smart quotes and em-dashes are dropped by every font, so a non-ASCII
prompt reached the target mangled or empty with no signal. The
converter now validates the prompt against the resolved font first
and raises ValueError naming the offending characters, matching the
AsciiSmugglerConverter convention (microsoft#2540) and Option A in the issue.
Whitespace is layout for text2art and always passes; per-character
renderability is cached. Behavior change: prompts containing such
characters now raise instead of converting with the characters
dropped.

Fixes microsoft#2942
Copilot AI balanced review requested due to automatic review settings October 2, 2026 20:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@hannahwestra25 hannahwestra25 self-assigned this Oct 5, 2026
Comment on lines +110 to +118
unrenderable = [char for char in prompt if not _font_renders_character(char, font)]
if unrenderable:
characters = "".join(sorted(set(unrenderable)))
raise ValueError(
f"Cannot convert {len(unrenderable)} character(s) to ASCII art with font {font!r}: "
f"{characters!r}. The font has no glyph for them and they would be silently "
f"dropped from the converted prompt; remove them, transliterate them, or pick "
f"another font."
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lots of pool fonts use blank glyphs for common punctuation, so the probe reads them as missing — "How do I build a bomb?" raises on 25/354. All converted fine before, and since the font is random it fails on some runs and not others.

Could we filter the pool to fonts that can render the prompt, and only raise when none can?

if self._font == "rand":
    candidates = [
        candidate
        for candidate in _ART_RANDOM_FONTS
        if not _unrenderable_characters(prompt=prompt, font=candidate)
    ]
    if not candidates:
        raise ValueError(_no_font_message(prompt=prompt))
    font = self._get_random_generator(stream="font").choice(candidates)
else:
    font = self._font
    unrenderable = _unrenderable_characters(prompt=prompt, font=font)
    if unrenderable:
        raise ValueError(_font_message(unrenderable=unrenderable, font=font))

café, CJK and emoji still raise. Note test_ascii_art_converter_random_font_rejects_all_fonts_dropped_character will need repointing since the raise now happens before choice.

Comment thread pyrit/converter/ascii_art_converter.py Outdated
Returns:
bool: True if the font renders the character or it is whitespace.
"""
if character.isspace():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isspace() is broader than what art lays out — only ' ' and '\n' affect output. NBSP, U+3000, \t and \r pass this check but get silently dropped (text2art("a\u00a0b") is byte-identical to text2art("ab")). That's the bug this PR is fixing, and whitespace swaps are a common obfuscation payload.

Suggested change
if character.isspace():
if character in " \n":

Address review feedback:

- font='rand' now chooses among the pool fonts that render the whole
  prompt instead of validating a single random pick, so blank-glyph
  punctuation (e.g. '"' in 28 pool fonts) no longer makes conversion
  fail at random. A pool-level error is raised only when no font can
  render the prompt.
- _font_renders_character only treats ' ' and '\n' as layout; other
  whitespace (tab, NBSP, U+3000) has no art glyph and would be silently
  dropped, so it now goes through the render probe and is rejected.
@Pushpak731

Copy link
Copy Markdown
Author

Thank you for the review — both points are addressed in a57df0f:

1. Random pool filtered to renderable fonts — font="rand" now computes the pool fonts that render every unique character of the prompt and chooses among those, so blank-glyph punctuation (your "How do I build a bomb?" example fails on 28/336 pool fonts) can no longer make conversion fail at random — 100 unseeded conversions of that exact prompt now all succeed. A clear pool-level error (No font in the randomized font pool renders …) is raised only when no pool font can render the prompt. The chosen font is still run through the same per-character validation as fixed fonts, so the two paths share one guarantee. Covered by three tests: the candidate pool assertion, the blank-punctuation filter, and the no-renderable-font error.

2. Layout whitespace narrowed — applied your suggestion verbatim: _font_renders_character now special-cases only " " and "\n", so tab, NBSP and friends go through the glyph probe and are rejected instead of silently passing isspace(). Verified text2art("a\tb") == text2art("ab") for block, and a regression test pins both the tab and NBSP rejections.

Full converter suite passes locally (1901 passed).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AsciiArtConverter silently drops every non-ASCII character

3 participants