Skip to content

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

Merged
hannahwestra25 merged 21 commits into
microsoft:mainfrom
Pushpak731:fix/ascii-art-converter-dropped-characters
Oct 16, 2026
Merged

hannahwestra25 merged 21 commits into
microsoft:mainfrom
Pushpak731:fix/ascii-art-converter-dropped-characters

Conversation

@Pushpak731

@Pushpak731 Pushpak Siva Sai (Pushpak731) commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Description

Fixes #2942 (Option B, per the discussion with Roman Lutz (@romanlutz) below).

AsciiArtConverter passed the prompt straight to art.text2art, which silently omits characters it has no glyph for. Per the issue's measurements, accented Latin, CJK, emoji, smart quotes and em-dashes are dropped by every font, 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 it ever reached the target.

This is not just an i18n edge case: 14 of our 56 seed prompt files contain characters no font renders, and two of them are plain English broken only by an em-dash and a curly apostrophe.

The converter now renders the characters the font covers and passes the rest through as literal text, logging a warning so the degradation is visible instead of silent.

Why pass-through rather than raising

An earlier revision of this PR raised a ValueError. That reliably stops prompts from being mangled, but it also turns a large amount of existing seed data and any non-Latin prompt into a hard failure, and it makes the converter unusable in stacked pipelines where an upstream converter injects characters the font lacks. Passing through keeps every prompt deliverable while still surfacing the problem, and matches the existing NatoConverter / BrailleConverter convention of leaving unmapped characters alone.

A strict=True flag that restores the raising behaviour will follow in a separate PR, defaulting to False so nothing breaks.

Rendering

Maximal runs of renderable characters are laid out in a single text2art call, so the font's native spacing and letter joining are preserved; only the unrenderable characters are spliced in, each occupying a one-character column centered against the art rows. Prompts the font fully covers skip the splicing path entirely and are byte-identical to the previous output, so there is no regression for ASCII workloads.

INPUT: "don’t"

before (curly apostrophe silently dropped, target receives "dont"):
    _             _
 __| | ___  _ _  | |_
/ _` |/ _ \| ' \ |  _|
\__,_|\___/|_||_| \__|

after:
    _              _
 __| | ___  _ _   | |_
/ _` |/ _ \| ' \ ’|  _|
\__,_|\___/|_||_|  \__|

When no character in the prompt is renderable (e.g. "日本語") the result degrades to the original text — a NoOp, which the converter guidelines explicitly allow, and still strictly better than today's empty string.

Detecting renderability

A character is probed by rendering it on its own with text2art and checking for any non-whitespace output — the same omission mechanism the issue measured, so the check cannot drift from art's actual behaviour. Results are cached per (character, font) with functools.lru_cache(maxsize=100_000). The bound matters because a prompt full of distinct unrenderable characters probes every pool font per character; without it the cache would grow for the lifetime of the process. Printable ASCII tops out near 34k entries across the pool, so ASCII workloads never evict.

Only " " and "\n" are layout for text2art and skip the probe. Other whitespace — tab, carriage return, NBSP, U+3000 — has no glyph and is silently dropped by art (text2art("a\tb", font="block") == text2art("ab", font="block")), so it goes through the same probe and is preserved rather than being waved through by an isspace() check. An empty prompt still converts, preserving the existing test contract.

font="rand"

Pool fonts freely use blank glyphs, especially for punctuation and digits, so probing a single randomly chosen font would degrade ordinary ASCII prompts on some draws and not others. The pool is first filtered to the fonts that render every unique character of the prompt, and the random choice is made among those — maximising art fidelity. When no pool font covers the prompt, the choice falls back to the full pool and the uncovered characters are passed through. Exactly one RNG draw happens either way, so seeded determinism is unchanged.

Warning

Font 'block' has no glyph for 1 character(s): 'é'. They are passed through as literal text
rather than dropped, so the output is not entirely ASCII art. Transliterate them or pick
another font to avoid this.

This follows the existing converter convention of logging when output isn't what was asked for (ImageCompressionConverter, ZalgoConverter, SneakyBitsSmugglerConverter). Note it fires per conversion, so a large run over non-ASCII prompts will repeat it.

Behavior change: prompts containing unrenderable characters now keep those characters as literal text instead of having them silently dropped. ASCII input is byte-identical to before. Nothing raises that did not raise previously.

Tests and Documentation

tests/unit/converter/test_ascii_art_converter.py goes from 7 to 25 tests (39 including parametrised cases). The original pass/convert tests are unchanged; the tests added by the earlier raising revision were repointed to assert pass-through and warning behaviour rather than deleted, so the same scenarios still guard against silent dropping. Coverage:

  • ASCII, empty and whitespace-only prompts still convert
  • regression guard: for 3 fonts × 6 renderable prompts (including multi-line), output is asserted byte-identical to text2art and no warning is logged
  • accented, CJK, emoji and smart-quote/em-dash prompts each preserve their characters in the output and name them in the warning
  • the warning lists the unique character set and mentions remedies
  • the hills fixed-font ASCII 'A' case from the issue
  • rand chooses only among fonts that render the prompt, and skips fonts with blank punctuation glyphs (the 1row / " case)
  • rand falls back to the full pool and passes characters through when nothing in the pool renders them
  • tab and NBSP are preserved rather than silently dropped
  • multi-line prompts with unrenderable characters on separate lines

Validation run locally: the converter file's suite is 39 passed. tests/unit/converter/ is 2048 passed, 34 skipped, 52 failed; all 52 failures are pre-existing PinyinConverter / seeded-determinism cases from a missing optional dependency — I confirmed the identical 52 fail with this branch's changes stashed, and none involve AsciiArtConverter. The other suites that touch this converter (test_converter.py, test_red_team_agent.py, azure_ai_evaluation/test_converter_contract.py) are 236 passed, 2 skipped. ruff check and ruff format --check are clean on both changed files.

No documentation changes are needed. doc/code/converters/0_converters.py and 1_text_to_text_converters.py drive this converter with ASCII-only prompts ("tell me how to cut down a tree"), which are on the byte-identical path.

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 thread pyrit/converter/ascii_art_converter.py Outdated
Comment thread pyrit/converter/ascii_art_converter.py Outdated
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
Contributor 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).

hannahwestra25 and others added 3 commits October 8, 2026 15:06
_font_renders_character used bare @cache, which never evicts. Prompts with
many distinct unrenderable characters (e.g. CJK) probe every pool font per
character, so a run feeding varied non-ASCII prompts grew the cache without
limit (~200k entries / 22.8 MiB after 10 such prompts) even though those
prompts raise anyway.

Switch to lru_cache(maxsize=100_000). Printable ASCII tops out near 34k
entries across the 354-font pool, so ASCII workloads never evict and
steady-state conversion speed is unchanged; memory now plateaus instead of
growing for the lifetime of the process.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@Pushpak731

Copy link
Copy Markdown
Contributor Author

Thanks hannahwestra25 for the lru_cache bound — good catch on the unbounded growth from non-ASCII prompts, and the 100k ceiling with ASCII's ~34k working set is the right shape. I've merged main into the branch (fb6a947) so it's no longer behind; the full converter suite passes on the merged state (2051 passed).

@hannahwestra25 hannahwestra25 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.

thanks for contributing!

@hannahwestra25
hannahwestra25 added this pull request to the merge queue Oct 9, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 9, 2026
@Pushpak731

Copy link
Copy Markdown
Contributor Author

Thank you hannahwestra25 for the approval and for the review that shaped this — the blank-glyph pool filter and the layout-whitespace narrowing both made the converter meaningfully more correct, and your lru_cache bound fixed a real memory cliff. Much appreciated!

@romanlutz

Copy link
Copy Markdown
Contributor

Hmm, reading this in detail now I feel like option B would have been preferable.
ASCII art is a visible formatting change, unlike AsciiSmuggler, where plaintext would defeat the purpose of hiding the message. An accent, emoji, or smart quote should not fail conversion when we can preserve its content. This avoids silent data loss and matches NATO and Braille's fallback behavior. Strict rejection could be opt-in for callers who require every character to be rendered.

hannahwestra25 wdyt?

@Pushpak731

Copy link
Copy Markdown
Contributor Author

That distinction convinces me too — the strictness that's right for AsciiSmugglerConverter (where plaintext would defeat the covert channel) isn't right for a visible-formatting converter. Signalling over fidelity inverts here.

Sketching option B on this code so it's concrete:

  1. Transliterate what has a natural ASCII mapping before rendering — accents via unicodedata NFKD + strip combining marks (é→e), smart quotes/apostrophes→straight, em-dash→--, NBSP/tab→space. These render as art, so content and formatting survive.
  2. Pass through what doesn't — characters the resolved font still can't render (emoji, CJK) are appended after the art as a plain-text line rather than silently dropped, so the prompt that reaches the target verifiably contains everything the author wrote. Mixed output, as the issue put it, but no silent loss.
  3. strict: bool = False opt-in preserving today's behaviour exactly (raise naming the characters) for callers who need every glyph rendered — keeps the regression suite meaningful too.

Happy to implement this on the branch as soon as you and hannahwestra25 align — and if a raise is wanted for the truly-unmappable class even in the default mode, that's a one-line variant of 3.

art silently omits characters the selected font has no glyph for, which
destroyed part of the prompt. Rather than failing the conversion, render
the characters the font covers and splice the remaining ones through as
literal text, logging a warning so the degradation is visible.

Renderable runs are laid out in a single text2art call, so prompts the
font fully covers are byte-identical to before. The random font pool is
still filtered to fonts that render the whole prompt and now falls back
to the full pool instead of raising when none do.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@hannahwestra25 hannahwestra25 changed the title FIX: raise on characters the ASCII art font cannot render FIX: preserve characters the ASCII art font cannot render Oct 14, 2026
@hannahwestra25

hannahwestra25 commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

That distinction convinces me too — the strictness that's right for AsciiSmugglerConverter (where plaintext would defeat the covert channel) isn't right for a visible-formatting converter. Signalling over fidelity inverts here.

Sketching option B on this code so it's concrete:

  1. Transliterate what has a natural ASCII mapping before rendering — accents via unicodedata NFKD + strip combining marks (é→e), smart quotes/apostrophes→straight, em-dash→--, NBSP/tab→space. These render as art, so content and formatting survive.
  2. Pass through what doesn't — characters the resolved font still can't render (emoji, CJK) are appended after the art as a plain-text line rather than silently dropped, so the prompt that reaches the target verifiably contains everything the author wrote. Mixed output, as the issue put it, but no silent loss.
  3. strict: bool = False opt-in preserving today's behaviour exactly (raise naming the characters) for callers who need every glyph rendered — keeps the regression suite meaningful too.

Happy to implement this on the branch as soon as you and hannahwestra25 align — and if a raise is wanted for the truly-unmappable class even in the default mode, that's a one-line variant of 3.

Thanks for the sketch [@Pushpak731](https://github.com/Pushpak731) — Roman and I landed on B too. So sorry--I'd already pushed it to the branch before seeing this (fa45130).

I skipped the transliteration step though. Rewriting é→e changes the payload we're sending, which feels off for a red-teaming tool, and it's really a separate stackable converter if we want it. I also spliced the unrenderable chars in place rather than appending after the art, so character order survives.

Your point 3 is the plan — use_strict in a follow-up, defaulting to False but something that we want for all converters probably so planning on moving to a different PR.

Also added a logger.warning on pass-through, since image_compression and zalgo already do that.

@Pushpak731

Copy link
Copy Markdown
Contributor Author

Thanks hannahwestra25 — the in-place splice is better than my append-after-art sketch exactly for the reason you implemented it (character order survives), and skipping transliteration is the right call for payload fidelity: a red-teaming tool shouldn't silently rewrite the prompt it's testing. Happy to pick up the use_strict follow-up when you open that PR, or take it myself if useful — either way this looks ready from my side.

Copilot AI and others added 3 commits October 14, 2026 12:24
…t engine

`_render_with_passthrough` splits on "\n" only, so the `\r` of a CRLF prompt
was classified as unrenderable content. Every Windows-authored prompt logged
a "no glyph" warning and had a bare CR spliced into the middle of the art
block, widening every row of that block by one column. `\r` now joins `" "`
and `"\n"` as layout, so CRLF prompts take the fast path and stay
byte-identical to `text2art`, matching pre-change behavior.

Also restores two test guarantees:

- `_ART_RANDOM_FONTS` is a hardcoded exclusion list, but the test that pinned
  it to `art`'s own `RANDOM_FILTERED_FONTS` was weakened to a subset check, so
  the pool could silently drift out of sync with the dependency. Asserts the
  module constant directly again.
- Every pass-through test only asserted `<char> in output_text`, which the raw
  prompt also satisfies: stubbing `_render_with_passthrough` to a total NoOp
  left all tests passing. The layout engine is now pinned on row count, art-row
  alignment, and vertical centering of the passed-through character.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
art resolves "wizard"/"wiz"/"magic", "rnd-small" and the other size variants,
"rnd-na", and "random"/"rnd" to a different concrete font on *every* text2art
call, and only the exact string "rand" was intercepted. The pass-through path
calls text2art once per renderable segment, so a single line came back with
each segment in a different font:

    caf[é] caf[é]  ->  segment 1 in "chunky", segment 2 in "slant"

Two further consequences: `_font_renders_character` is lru_cached, so a random
probe result was memoized process-wide for (character, selector) pairs, making
the glyph check non-deterministic and sticky; and the warning named the
selector ("wizard") rather than the font actually rendered with, which left
users no way to act on it.

The configured font is now resolved to a concrete name exactly once per
conversion, before the glyph probe, the warning, and any rendering:

- "rand"/"random"/"rnd" are intercepted and picked from `_ART_RANDOM_FONTS`
  using PyRIT's seeded generator, so they stay reproducible and prompt-aware.
  `_ART_RANDOM_FONTS` is already pinned to art's own pool by test, so widening
  the alias set does not change which fonts are reachable.
- Everything else is delegated to art's own `indirect_font` once, rather than
  mirroring art's wizard/size/non-ASCII pools here and letting them drift.
  This also resolves art's fuzzy name matching up front, so a misspelled font
  reports the font it actually rendered with.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@romanlutz

Copy link
Copy Markdown
Contributor

I didn't have a very clear perspective on this either so apologies for perhaps confusing things further for a while 🙂 Thanks, hannahwestra25 for bringing clarity!

The `ty (type check)` pre-commit hook failed with unsound-return-statement:
`art` ships no py.typed marker, so ty infers `indirect_font` as returning
`Unknown`, which is assignable to `str` but not a subtype of it.

`indirect_font` returns a str on every branch (random.choice over the font
name lists, wizard_font, or the fuzzy-matched `min(fonts, ...)`), so narrow it
with `cast`, matching the idiom already used for untyped third-party returns
elsewhere in the codebase (e.g. `pyrit/auth/azure_auth.py`).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@hannahwestra25
hannahwestra25 added this pull request to the merge queue Oct 15, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 15, 2026
@hannahwestra25
hannahwestra25 added this pull request to the merge queue Oct 16, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 16, 2026
@hannahwestra25
hannahwestra25 added this pull request to the merge queue Oct 16, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 16, 2026
Normalize font selectors before resolution, detect partially rendered graphemes, and preserve mix mode with operation-local seeded glyph choices. Align literal Unicode clusters using terminal display widths and cover the rendering boundaries with regression tests.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 24d18905-c56f-4f81-bdf5-d57009a293ec
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 24d18905-c56f-4f81-bdf5-d57009a293ec
@romanlutz
Roman Lutz (romanlutz) added this pull request to the merge queue Oct 16, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 16, 2026
@romanlutz
Roman Lutz (romanlutz) added this pull request to the merge queue Oct 16, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 16, 2026
@hannahwestra25 hannahwestra25 assigned Copilot and unassigned Copilot Oct 16, 2026
@hannahwestra25
hannahwestra25 added this pull request to the merge queue Oct 16, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 16, 2026
@hannahwestra25
hannahwestra25 added this pull request to the merge queue Oct 16, 2026
Merged via the queue into microsoft:main with commit 00072a1 Oct 16, 2026
129 of 134 checks passed
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

6 participants