From 9b77d8370abf4ffde9e05a4ee62b508855508909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Degros?= <57880064+fdegros@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:28:35 +1100 Subject: [PATCH 1/2] Make --anonymous cards show only the group name --anonymous only removed the cryptocurrency addresses and QR codes. Each card still printed the name passed to the tool ("SLIP39 First(1/1) for: Alice", on the front and on the back), the requirements of all of the groups ("Recover w/ 2 of 4: First(1), Second(1), Fam(2/4), Frens(3/6)", which tells whoever holds a single card the names, thresholds and sizes of the other groups), and a diagonal group label. With --anonymous, the title of a card is now just its group name, and none of the above is printed; the mnemonic words remain the large ones. Cards without --anonymous are unchanged. The `anonymous` argument defaults to ANONYMOUS (False) instead of None, which removes the repeated `ANONYMOUS if anonymous is None else anonymous`. Add tests of the texts rendered on the cards, with and without --anonymous. See #11. --- slip39/layout/pdf.py | 29 +++++++++++++---------- slip39/output_test.py | 55 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 14 deletions(-) diff --git a/slip39/layout/pdf.py b/slip39/layout/pdf.py index d4b63d23..181ab680 100644 --- a/slip39/layout/pdf.py +++ b/slip39/layout/pdf.py @@ -187,7 +187,7 @@ def produce_pdf( groups: Dict[str,Tuple[int,List[str]]], # SLIP-39 Groups {: (,[,...]) accounts: Sequence[Sequence[Account]], # The crypto account(s); at least 1 of each required using_bip39: bool, # Using BIP-39 wallets Seed generation - anonymous: Optional[bool] = None, # Avoid printing crypto addresses, QR codes + anonymous: bool = ANONYMOUS, # Avoid printing crypto addresses, QR codes card_format: str = CARD, # 'index' or '(,),' paper_format: Any = None, # 'Letter', (x,y) dimensions in mm. orientations: Sequence[str] = None, # available orientations; default portrait, landscape @@ -355,21 +355,24 @@ def produce_pdf( _,f,b = page[-1][-1] - f['card-title'] = \ - b['card-title'] = f"SLIP39 {g_name}({mn_n+1}/{len(g_mnems)}) for: {name}" - f['card-requires'] = requires - if not (ANONYMOUS if anonymous is None else anonymous): - f['card-crypto1']= f"{accounts[0][0].crypto} {accounts[0][0].path}: {accounts[0][0].address}" + # Anonymous cards show only the group name: not the name, the other groups, nor the + # cryptocurrency addresses and QR codes. + title = g_name + if not anonymous: + title = f"SLIP39 {g_name}({mn_n+1}/{len(g_mnems)}) for: {name}" + f['card-requires'] = requires + f['card-crypto1'] = f"{accounts[0][0].crypto} {accounts[0][0].path}: {accounts[0][0].address}" f['card-qr1'] = io.BytesIO( qr_acct[0].to_string(encoding='unicode').encode('UTF-8')) # get_image() - if not (ANONYMOUS if anonymous is None else anonymous) and len( accounts[0] ) > 1: - f['card-crypto2'] = f"{accounts[0][1].crypto} {accounts[0][1].path}: {accounts[0][1].address}" - f['card-qr2'] = io.BytesIO( qr_acct[1].to_string(encoding='unicode').encode('UTF-8')) # get_image() - f[f'card-g{g_n}'] = \ - b[f'card-g{g_n}'] = f"{g_name:7.7}..{mn_n+1}" if len(g_name) > 8 else f"{g_name} {mn_n+1}" + if len( accounts[0] ) > 1: + f['card-crypto2'] = f"{accounts[0][1].crypto} {accounts[0][1].path}: {accounts[0][1].address}" + f['card-qr2'] = io.BytesIO( qr_acct[1].to_string(encoding='unicode').encode('UTF-8')) # get_image() + f[f'card-g{g_n}'] = \ + b[f'card-g{g_n}'] = f"{g_name:7.7}..{mn_n+1}" if len(g_name) > 8 else f"{g_name} {mn_n+1}" + f['card-title'] = b['card-title'] = title if watermark: f['card-watermark'] = watermark for n,m in enumerate_mnemonic( mnem ).items(): - mn_idx = f"mnem-{n}-big" if (ANONYMOUS if anonymous is None else anonymous) else f"mnem-{n}" + mn_idx = f"mnem-{n}-big" if anonymous else f"mnem-{n}" f[mn_idx] = m # Back contains QR code of the card's SLIP-39 Mnemonic @@ -412,7 +415,7 @@ def write_pdfs( group_threshold = None, # int, or 1/2 of groups by default cryptocurrency = None, # sequence of [ 'ETH:', ... ] to produce accounts for edit = None, # Adjust crypto paths according to the provided path edit - anonymous = None, # Produce cryptocurrency addresses and QR codes + anonymous = ANONYMOUS, # Produce cryptocurrency addresses and QR codes card_format = None, # Eg. "credit"; False outputs no SLIP-39 Mnemonic cards to PDF paper_format = None, # Eg. "Letter", "Legal", (x,y) filename = None, # A file name format w/ {path}, etc. formatters diff --git a/slip39/output_test.py b/slip39/output_test.py index 903c0b4e..0e0c22da 100644 --- a/slip39/output_test.py +++ b/slip39/output_test.py @@ -1,9 +1,10 @@ +import pytest import qrcode from pytest import approx from fpdf import FPDF, FlexTemplate -from .layout import Region, Text, Image, Box, Coordinate +from .layout import Region, Text, Image, Box, Coordinate, write_pdfs from .defaults import MM_IN @@ -73,3 +74,55 @@ def test_Region(): tpl.render( offsetx = card_size.x * MM_IN, offsety = card_size.y * MM_IN ) #pdf.output( "test.pdf" ) # To view results in test.pdf, uncomment + + +@pytest.fixture +def rendered_cards( monkeypatch ): + """Records the texts of every template rendered into a PDF.""" + rendered = [] + render = FlexTemplate.render + + def record( self, *args, **kwds ): + rendered.append( dict( self.texts )) + return render( self, *args, **kwds ) + monkeypatch.setattr( FlexTemplate, 'render', record ) + return rendered + + +def write_cards( tmp_path, **kwds ): + write_pdfs( + names = [ "Alice" ], + master_secret = bytes.fromhex( "ff" * 16 ), + cryptocurrency = [ "BTC" ], + cover_page = False, + filepath = str( tmp_path ), + filename = "cards", + **kwds + ) + + +def test_cards( tmp_path, rendered_cards ): + """Cards carry the name, the requirements of all groups, the address, and the group label.""" + write_cards( tmp_path ) + cards = [ t for t in rendered_cards if 'card-title' in t ] + fronts = [ t for t in cards if 'card-requires' in t ] # the backs only have a title (and QR code) + assert fronts + assert all( "Alice" in t['card-title'] for t in cards ) + for t in fronts: + assert "Recover w/ 2 of 4" in t['card-requires'] + assert 'card-crypto1' in t and 'card-qr1' in t + assert any( k.startswith( 'card-g' ) for k in t ) + + +def test_anonymous_cards( tmp_path, rendered_cards ): + """Anonymous cards (front and back) show just the group name, and the mnemonic.""" + write_cards( tmp_path, anonymous=True ) + cards = [ t for t in rendered_cards if 'card-title' in t ] + assert cards + assert { t['card-title'] for t in cards } == { "First", "Second", "Fam", "Frens" } + for t in cards: + assert not any( k.startswith(( 'card-g', 'card-requires', 'card-crypto', 'card-qr1', 'card-qr2' )) for k in t ), \ + f"Anonymous card shows: {sorted( t )}" + assert "Alice" not in "".join( str( v ) for v in t.values() ) + # The mnemonic words are the (large) ones + assert any( k.endswith( '-big' ) for t in cards for k in t if k.startswith( 'mnem-' )) From 865cbbc9a148d21b337551e2c810343e5a40816e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Degros?= <57880064+fdegros@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:28:54 +1100 Subject: [PATCH 2/2] Simplify the title of cards Print the title of a card as "SLIP39 Fam 2 for Alice", instead of "SLIP39 Fam(2/4) for: Alice": the number of the card within its group, only if the group has several cards, and the name only if there is one. This avoids printing the size of the group (and a trailing "for: " with no name), and repeats less of what the "Recover w/ ..." line and the mnemonics already show. Only changes the title of cards that are not anonymous. --- slip39/layout/pdf.py | 6 +++++- slip39/output_test.py | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/slip39/layout/pdf.py b/slip39/layout/pdf.py index 181ab680..eefff1f6 100644 --- a/slip39/layout/pdf.py +++ b/slip39/layout/pdf.py @@ -359,7 +359,11 @@ def produce_pdf( # cryptocurrency addresses and QR codes. title = g_name if not anonymous: - title = f"SLIP39 {g_name}({mn_n+1}/{len(g_mnems)}) for: {name}" + title = f"SLIP39 {g_name}" + if len( g_mnems ) > 1: + title = f"{title} {mn_n+1}" + if name: + title = f"{title} for {name}" f['card-requires'] = requires f['card-crypto1'] = f"{accounts[0][0].crypto} {accounts[0][0].path}: {accounts[0][0].address}" f['card-qr1'] = io.BytesIO( qr_acct[0].to_string(encoding='unicode').encode('UTF-8')) # get_image() diff --git a/slip39/output_test.py b/slip39/output_test.py index 0e0c22da..1e5d9134 100644 --- a/slip39/output_test.py +++ b/slip39/output_test.py @@ -107,7 +107,11 @@ def test_cards( tmp_path, rendered_cards ): cards = [ t for t in rendered_cards if 'card-title' in t ] fronts = [ t for t in cards if 'card-requires' in t ] # the backs only have a title (and QR code) assert fronts - assert all( "Alice" in t['card-title'] for t in cards ) + assert { t['card-title'] for t in cards } == { + "SLIP39 First for Alice", "SLIP39 Second for Alice", + "SLIP39 Fam 1 for Alice", "SLIP39 Fam 2 for Alice", "SLIP39 Fam 3 for Alice", "SLIP39 Fam 4 for Alice", + *( f"SLIP39 Frens {n} for Alice" for n in range( 1, 7 )), + } for t in fronts: assert "Recover w/ 2 of 4" in t['card-requires'] assert 'card-crypto1' in t and 'card-qr1' in t