Skip to content

Commit 16ebe36

Browse files
author
Saiprashanth Pulisetti
committed
feat(ciphers): add columnar transposition cipher with doctests
1 parent e018e65 commit 16ebe36

1 file changed

Lines changed: 121 additions & 0 deletions

File tree

ciphers/columnar_transposition.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""Columnar Transposition cipher.
2+
3+
This classical cipher writes the plaintext in rows under a keyword and reads
4+
columns in the order of the alphabetical rank of the keyword letters.
5+
6+
Reference: https://en.wikipedia.org/wiki/Transposition_cipher#Columnar_transposition
7+
8+
We keep spaces and punctuation. Key must be alphabetic (case-insensitive).
9+
10+
>>> pt = "WE ARE DISCOVERED. FLEE AT ONCE"
11+
>>> ct = encrypt(pt, "ZEBRAS")
12+
>>> decrypt(ct, "ZEBRAS") == pt
13+
True
14+
15+
Edge cases:
16+
>>> encrypt("HELLO", "A")
17+
'HELLO'
18+
>>> decrypt("HELLO", "A")
19+
'HELLO'
20+
>>> encrypt("HELLO", "HELLO")
21+
'EHLLO'
22+
>>> decrypt("EHLLO", "HELLO")
23+
'HELLO'
24+
>>> encrypt("HELLO", "")
25+
Traceback (most recent call last):
26+
...
27+
ValueError: Key must be a non-empty alphabetic string
28+
"""
29+
from __future__ import annotations
30+
31+
32+
def _normalize_key(key: str) -> str:
33+
k = "".join(ch for ch in key.upper() if ch.isalpha())
34+
if not k:
35+
raise ValueError("Key must be a non-empty alphabetic string")
36+
return k
37+
38+
39+
def _column_order(key: str) -> list[int]:
40+
# Stable sort by character then original index to handle duplicates
41+
indexed = list(enumerate(key))
42+
return [i for i, _ in sorted(indexed, key=lambda t: (t[1], t[0]))]
43+
44+
45+
def encrypt(plaintext: str, key: str) -> str:
46+
"""Encrypt using columnar transposition.
47+
48+
:param plaintext: Input text (any characters)
49+
:param key: Alphabetic keyword
50+
:return: Ciphertext
51+
:raises ValueError: on invalid key
52+
"""
53+
k = _normalize_key(key)
54+
cols = len(k)
55+
if cols == 1:
56+
return plaintext
57+
58+
order = _column_order(k)
59+
60+
# Build ragged rows without padding
61+
rows = (len(plaintext) + cols - 1) // cols
62+
grid: list[str] = [plaintext[i * cols : (i + 1) * cols] for i in range(rows)]
63+
64+
# Read columns in sorted order, skipping missing cells
65+
out: list[str] = []
66+
for col in order:
67+
for r in range(rows):
68+
if col < len(grid[r]):
69+
out.append(grid[r][col])
70+
return "".join(out)
71+
72+
73+
def decrypt(ciphertext: str, key: str) -> str:
74+
"""Decrypt columnar transposition ciphertext.
75+
76+
:param ciphertext: Encrypted text
77+
:param key: Alphabetic keyword
78+
:return: Decrypted plaintext
79+
:raises ValueError: on invalid key
80+
"""
81+
k = _normalize_key(key)
82+
cols = len(k)
83+
if cols == 1:
84+
return ciphertext
85+
86+
order = _column_order(k)
87+
L = len(ciphertext)
88+
rows = (L + cols - 1) // cols
89+
r = L % cols
90+
91+
# Column lengths based on ragged last row (no padding during encryption)
92+
col_lengths: list[int] = []
93+
for c in range(cols):
94+
if r == 0:
95+
col_lengths.append(rows)
96+
else:
97+
col_lengths.append(rows if c < r else rows - 1)
98+
99+
# Slice ciphertext into columns following the sorted order
100+
columns: list[str] = [""] * cols
101+
idx = 0
102+
for col in order:
103+
ln = col_lengths[col]
104+
columns[col] = ciphertext[idx : idx + ln]
105+
idx += ln
106+
107+
# Rebuild plaintext row-wise
108+
out: list[str] = []
109+
pointers = [0] * cols
110+
for _ in range(rows * cols):
111+
c = len(out) % cols
112+
if pointers[c] < len(columns[c]):
113+
out.append(columns[c][pointers[c]])
114+
pointers[c] += 1
115+
return "".join(out)
116+
117+
118+
if __name__ == "__main__": # pragma: no cover
119+
import doctest
120+
121+
doctest.testmod()

0 commit comments

Comments
 (0)