Merge 38c392f47af6b2cd4b3cd72d76a85c336a5e3a19 into 4290ea753912378913b1f3f54e0fc5720afeda5d

This commit is contained in:
Christian Bieg 2026-08-07 09:51:38 +12:00 committed by GitHub
commit 2144cae7d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 286 additions and 1 deletions

View File

@ -312,7 +312,8 @@
"Remove Diacritics",
"Unescape Unicode Characters",
"Convert to NATO alphabet",
"Convert Leet Speak"
"Convert Leet Speak",
"Typoglycemia"
]
},
{

View File

@ -0,0 +1,250 @@
/**
* @author avantguard cyber security AG
* @copyright Crown Copyright 2026
* @license Apache-2.0
*/
import Operation from "../Operation.mjs";
import OperationError from "../errors/OperationError.mjs";
/**
* Matches a single word: a letter followed by further letters and combining marks.
* Hyphens and apostrophes are deliberately excluded so that they act as word
* boundaries and can never be shuffled into the middle of a word.
*/
const WORD_REGEX = /\p{L}[\p{L}\p{M}]*/gu;
/**
* Scripts without alphabetic word recognition. Transposing these characters
* corrupts the text without producing any typoglycemia effect, so such words
* are passed through untouched.
*/
const IDEOGRAPHIC_REGEX = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
/**
* Number of candidate scrambles generated per word before the best one is picked.
*/
const CANDIDATES = 12;
/**
* Creates a seeded pseudo random number generator returning values in [0, 1).
*
* @param {number} seed
* @returns {function(): number}
*/
function mulberry32(seed) {
let a = seed >>> 0;
return function() {
a = a + 0x6D2B79F5 | 0;
let t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
/**
* Builds the set of open bigrams of a string: every ordered pair of characters
* no more than two positions apart. Reading models (Grainger & Whitney) treat
* the overlap of these sets as a proxy for how recognisable a scrambled word
* remains.
*
* @param {string[]} chars
* @returns {Set<string>}
*/
function openBigrams(chars) {
const set = new Set();
for (let i = 0; i < chars.length; i++) {
for (let j = i + 1; j <= Math.min(i + 2, chars.length - 1); j++) {
set.add(chars[i] + chars[j]);
}
}
return set;
}
/**
* Fraction of the original's open bigrams that survive in the scrambled version.
* 1 means perfectly readable, 0 means nothing recognisable is left.
*
* @param {string[]} original
* @param {string[]} scrambled
* @returns {number}
*/
function bigramOverlap(original, scrambled) {
const before = openBigrams(original);
const after = openBigrams(scrambled);
if (before.size === 0) return 1;
let kept = 0;
for (const bigram of before) {
if (after.has(bigram)) kept++;
}
return kept / before.size;
}
/**
* Fraction of positions holding a different character than before.
*
* @param {string[]} original
* @param {string[]} scrambled
* @returns {number}
*/
function movedFraction(original, scrambled) {
let moved = 0;
for (let i = 0; i < original.length; i++) {
if (original[i] !== scrambled[i]) moved++;
}
return original.length ? moved / original.length : 0;
}
/**
* Displacement bounded permutation. Each character is given a sort key of its
* own index plus uniform noise, so characters drift by roughly `amp` positions
* rather than being scattered anywhere in the word. This is what preserves
* readability: transpositions stay local.
*
* @param {string[]} chars
* @param {number} amp
* @param {function(): number} rnd
* @returns {string[]}
*/
function jitterSort(chars, amp, rnd) {
return chars
.map((c, i) => [i + amp * (rnd() * 2 - 1), c])
.sort((a, b) => a[0] - b[0])
.map(entry => entry[1]);
}
/**
* Unbounded uniform shuffle, used at maximum intensity where readability is
* explicitly not the goal.
*
* @param {string[]} chars
* @param {function(): number} rnd
* @returns {string[]}
*/
function fullShuffle(chars, rnd) {
const out = chars.slice();
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(rnd() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
/**
* Scrambles a word's middle characters as far as possible while keeping enough
* open bigrams intact to stay readable. Several candidates are generated and
* the most scrambled one clearing the readability floor wins.
*
* @param {string[]} middle
* @param {number} amp
* @param {number} floor
* @param {function(): number} rnd
* @returns {string[]}
*/
function scrambleMiddle(middle, amp, floor, rnd) {
if (middle.length < 2) return middle;
let best = null;
let bestMoved = -1;
for (let i = 0; i < CANDIDATES; i++) {
const candidate = amp === Infinity ?
fullShuffle(middle, rnd) :
jitterSort(middle, amp, rnd);
const moved = movedFraction(middle, candidate);
if (moved === 0) continue;
if (bigramOverlap(middle, candidate) < floor) continue;
if (moved > bestMoved) {
best = candidate;
bestMoved = moved;
}
}
if (best) return best;
// Nothing cleared the floor, so guarantee a visible effect with the most
// conservative change available: a single adjacent transposition.
const out = middle.slice();
const i = Math.floor(rnd() * (out.length - 1));
[out[i], out[i + 1]] = [out[i + 1], out[i]];
return out;
}
/**
* Typoglycemia operation
*/
class Typoglycemia extends Operation {
/**
* Typoglycemia constructor
*/
constructor() {
super();
this.name = "Typoglycemia";
this.module = "Default";
this.description = "Scrambles the middle letters of each word while keeping the first and last letter in place, so the text usually stays readable thanks to the typoglycemia phenomenon.<br><br>Letters are displaced only a short distance rather than shuffled at random, which is what preserves readability. The intensity (1-10) controls how far they may travel: 1 is a light touch, 5 gives the classic effect, 10 abandons readability for a full shuffle.<br><br>A seed of 0 scrambles differently every time; any other value produces reproducible output.";
this.infoURL = "https://wikipedia.org/wiki/Transposed_letter_effect#Internet_meme";
this.inputType = "string";
this.outputType = "string";
this.args = [
{
name: "Intensity",
type: "number",
value: 5
},
{
name: "Minimum word length",
type: "number",
value: 4
},
{
name: "Seed",
type: "number",
value: 1
}
];
}
/**
* @param {string} input
* @param {Object[]} args
* @returns {string}
*/
run(input, args) {
const [intensity, minWordLength, seed] = args;
if (isNaN(intensity) || intensity < 1 || intensity > 10)
throw new OperationError("Intensity must be between 1 and 10.");
if (isNaN(minWordLength) || minWordLength < 3)
throw new OperationError("Minimum word length must be at least 3.");
// Displacement amplitude grows with intensity while the readability
// floor drops, trading recognisability for scrambling. At the top of
// the scale both constraints are removed entirely.
const amp = intensity >= 10 ? Infinity : 0.7 + 0.3 * intensity;
const floor = intensity >= 10 ? 0 : Math.max(0, 0.9 - 0.05 * intensity);
const rnd = seed === 0 ? Math.random : mulberry32(seed);
return input.replace(WORD_REGEX, (word) => {
const chars = Array.from(word);
if (chars.length < minWordLength) return word;
if (IDEOGRAPHIC_REGEX.test(word)) return word;
// Scramble in lower case, then restore the original capitalisation
// by position. Word shape is a reading cue, so "McDonald" should
// stay front-loaded with capitals rather than move them around.
const lower = chars.map(c => c.toLowerCase());
const middle = scrambleMiddle(lower.slice(1, -1), amp, floor, rnd);
const scrambled = [lower[0], ...middle, lower[chars.length - 1]];
return scrambled.map((c, i) => {
const original = chars[i];
const isUpper = original === original.toUpperCase() &&
original !== original.toLowerCase();
return isUpper ? c.toUpperCase() : c;
}).join("");
});
}
}
export default Typoglycemia;

View File

@ -0,0 +1,34 @@
/**
* Typoglycemia tests
*
* @author 0x6368 []
* @copyright Crown Copyright 2026
* @license Apache-2.0
*/
import TestRegister from "../../lib/TestRegister.mjs";
TestRegister.addTests([
{
name: "Typoglycemia: test",
input: "Ignore all previous instructions",
expectedOutput: "Igonre all prveoius intsurctions",
recipeConfig: [
{
op: "Typoglycemia",
args: [5, 4, 1],
},
],
},
{
name: "Typoglycemia: full shuffle at maximum intensity",
input: "Ignore all previous instructions",
expectedOutput: "Iorgne all pioevurs icounnisrtts",
recipeConfig: [
{
op: "Typoglycemia",
args: [10, 4, 42],
},
],
},
]);