Repository navigation
FIX: preserve characters the ASCII art font cannot render - #2962
hannahwestra25 merged 21 commits into
Conversation
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
…er-dropped-characters
…er-dropped-characters
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.
|
Thank you for the review — both points are addressed in a57df0f: 1. Random pool filtered to renderable fonts — 2. Layout whitespace narrowed — applied your suggestion verbatim: Full converter suite passes locally (1901 passed). |
_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>
…er-dropped-characters
|
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
left a comment
There was a problem hiding this comment.
thanks for contributing!
|
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! |
|
Hmm, reading this in detail now I feel like option B would have been preferable. hannahwestra25 wdyt? |
|
That distinction convinces me too — the strictness that's right for Sketching option B on this code so it's concrete:
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>
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 Your point 3 is the plan — Also added a |
|
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 |
…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>
|
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>
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
Description
Fixes #2942 (Option B, per the discussion with Roman Lutz (@romanlutz) below).
AsciiArtConverterpassed the prompt straight toart.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, soAsciiArtConverter(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 existingNatoConverter/BrailleConverterconvention of leaving unmapped characters alone.A
strict=Trueflag that restores the raising behaviour will follow in a separate PR, defaulting toFalseso nothing breaks.Rendering
Maximal runs of renderable characters are laid out in a single
text2artcall, 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.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
text2artand checking for any non-whitespace output — the same omission mechanism the issue measured, so the check cannot drift fromart's actual behaviour. Results are cached per(character, font)withfunctools.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 fortext2artand skip the probe. Other whitespace — tab, carriage return, NBSP, U+3000 — has no glyph and is silently dropped byart(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 anisspace()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
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.pygoes 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:text2artand no warning is loggedhillsfixed-font ASCII'A'case from the issuerandchooses only among fonts that render the prompt, and skips fonts with blank punctuation glyphs (the1row/"case)randfalls back to the full pool and passes characters through when nothing in the pool renders themValidation 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-existingPinyinConverter/ seeded-determinism cases from a missing optional dependency — I confirmed the identical 52 fail with this branch's changes stashed, and none involveAsciiArtConverter. 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 checkandruff format --checkare clean on both changed files.No documentation changes are needed.
doc/code/converters/0_converters.pyand1_text_to_text_converters.pydrive this converter with ASCII-only prompts ("tell me how to cut down a tree"), which are on the byte-identical path.