Articles

ColdFusion Pseudolocalization for Generating "Fake Translations"

This is beneficial when preparing your app for Iñtërnâtiônàlizætiøn (i18n) compliance.

Image about ColdFusion Pseudolocalization for Generating "Fake Translations"
June 19, 2025

About 10 years ago, I wrote a ColdFusion User Defined Function (UDF) named pseudolocalize for transforming a given string by replacing its Roman (Latin) characters with similar-looking characters that have simple diacritical marks (accents, tildes, etc.). I shared it on my old Tumblr blog the same year, but thought I'd modernize it and reshare it with a demo on MyCFML. (NOTE: This UDF was written by a human, not AI. Enjoy!)

I've attempted to add support for tokens and optional support for preserving HTML tags & entities. The function shouldn't corrupt code, placeholders or HTML markup during pseudolocalization.

I've also added a helper pseudolocalizeOneLetter function to pseudolocalize a single letter in each word. I've occasionally used the output to share messages on social media and bypass simplistic keyword filters and also make it difficult for my content to be data mined or show up in search results.

The one feature that I didn't add was "text" expansion. Most of the alternative software approaches that I reviewed would simply add a bunch of bogus symbols to the beginning and end of a text block.

I recently learned that "pseudo-locales" were initially built in to Windows Vista back in 2006 to support Western, Near-Eastern & Far-Eastern language families to produce text that still "reads" as English, but is made up of script from another language. (The names of the pseudo-locales are "qps-ploc", "qps-ploca", and "qps-plocm". As of Windows 10, the pseudo-locale "qps-Latn-x-sh" is also available.) These specific alterations are intended to tackle the most problematic characteristics of the world's languages: text expansion (varying length of text or characters), language direction, fit into the interface and so on. I've been using this pseudolocalization function on our multi-language ColdFusion web applications during QA to identify which text fragments in the user interface haven't been updated and need to be flagged for translation.

UPDATE 6/21/2025: Added a "reverse" option so that pseudolocalized text created with this function could be reverted to the original text.

Source Code

<cfscript>
/* 1.1 Added ability to use single result instead of random; added simple HTML tag filter (default=on) (2015-07-10)
	1.2 bug fixes; retain HTML entities; option to convert using HTML Entities instead of UTF-8 character (2019-04-03)
	1.3 Modernize to use cfscript and local scope (2025-06-18)
	1.4 Add reverse option (2025-06-21)
*/
public string function pseudolocalize(required any str, string startDelim="{", string endDelim="}", string randomResult="", string ignoreHTMLTags="1", string useHTMLEntities="0", boolean reverse=false) output=false hint="I replace a string with roman characters with similar-looking diacritical characters." {
	// single string or struct containing arguments
	local.delimiterData = ["pos": [], "len": []];
	// Character Translation Table
	local.pseudoTable = [
		"65": [192,193,194,195,196,197,256,258,260,506,512,514,550] // A
		,"66": [223,385,579,665] // B
		,"67": [262,264,266,268,391] // C
		,"68": [270,272,393,394] // D
		,"69": [274,276,278,280,282,516,518] // E
		,"70": [401] // F
		,"71": [284,286,288,290,403] // G
		,"72": [292,294] // H
		,"73": [296,298,300,302,304] // I
		,"74": [309] // J
		,"75": [310,408] // K
		,"76": [313,315,317,319,321] // L
		,"78": [323,325,327,330,413] // N
		,"79": [332,334,336,416,510] // O
		,"80": [420] // P
		,"81": [490,492] // Q
		,"82": [340,342,344,422] // R
		,"83": [346,348,350,352] // S
		,"84": [354,356,358] // T
		,"85": [360,362,364,366,368,370] // U
		,"87": [372] // W
		,"89": [374,376,435,562,590] // Y
		,"90": [377,379,381,437] // Z
		,"97": [224,225,226,227,228,229,257,259,261,507,513,515,551] // a
		,"98": [384,386,387,388,389,595] // b
		,"99": [263,265,267,269,392] // c
		,"100": [271,273] // d
		,"101": [275,277,279,281,283,517,519] // e
		,"102": [402] // f
		,"103": [285,287,289,291,608] // g
		,"104": [293,295] // h
		,"105": [297,299,301,303,305] // i
		,"106": [308] // j
		,"107": [311,312,409] // k
		,"108": [314,316,318,320,322] // l
		,"110": [324,326,328,329,331,414] // n
		,"111": [333,335,337,417,511] // o
		,"112": [421,447] // p
		,"113": [491,493,587] // q
		,"114": [341,343,345] // r
		,"115": [347,349,351,353] // s
		,"116": [355,357,359] // t
		,"117": [361,363,365,367,369,371] // u
		,"119": [373] // w
		,"121": [375,436,563,591] // y
		,"122": [378,380,382,438] // z
	];

	// accept argument configuration as a single first-position variable
	if (isstruct(arguments.str)) {
		local.tempStruct = arguments.str;
		for (local.tempKey in structkeylist(local.tempStruct)) {
			if ( structkeyexists(arguments, local.tempKey) ) {
				arguments[local.tempKey] = tempStruct[local.tempKey];
			}
		}
		if (isstruct(arguments.str)) {
			arguments.str = "";
		}
	}
	arguments.str = tostring(arguments.str);
	local.strArray = rematch(".", arguments.str);

	// reverse pseudo back to original string
	if (arguments.reverse){
		local.reverseTable = [:];
		for(local.chr in local.pseudoTable){
			for(local.letter in local.pseudoTable[local.chr]){
				local.reverseTable[local.letter] = [local.chr];
			}
		}
		local.pseudoTable = local.reverseTable;
	}

	// Identify position and length of all tokens
	if (len(trim(arguments.startDelim)) && len(trim(arguments.endDelim)) && find(arguments.startDelim, arguments.str) && find(arguments.startDelim, arguments.str)){
		local.currentPos = 1;
		local.regexResult = ["len": [1]];
		while (local.regexResult.len[1] neq 0){
			local.regexResult = refind(arguments.startDelim & "\s*[\w\.\s*]+\s*" & arguments.endDelim, arguments.str, local.currentPos, "true");
			if (local.regexResult.len[1] neq 0){
				arrayappend(local.delimiterData.pos, local.regexResult.pos[1]);
				arrayappend(local.delimiterData.len, local.regexResult.len[1]);
				local.currentPos = local.regexResult.pos[1] + local.regexResult.len[1];
			}
		}
	}
	// Identify positions and length of HTML Tags; add to ignore list
	if (yesnoformat(arguments.ignoreHTMLTags) && find("<", arguments.str) ){
		local.currentPos = 1;
		local.regexResult = ["len": [1]];
		while (local.regexResult.len[1] neq 0){
			/* local.regexResult = refind("<[^>]*[^\\s>][^>]*>", arguments.str, local.currentPos, "true"); */
			local.regexResult = refind("<[^>]*(?:>|$)", arguments.str, local.currentPos, "true");
			if (local.regexResult.len[1] neq 0){
				arrayappend(local.delimiterData.pos, local.regexResult.pos[1]);
				arrayappend(local.delimiterData.len, local.regexResult.len[1]);
				local.currentPos = local.regexResult.pos[1] + local.regexResult.len[1];
			}
		}
	}
	// Identify positions and length of HTML Entities; add to ignore list
	local.currentPos = 1;
	local.regexResult = ["len": [1]];
	while (local.regexResult.len[1] neq 0){
		local.regexResult = refind("&[^\s]*;", arguments.str, local.currentPos, "true");
		if (local.regexResult.len[1] neq 0){
			arrayappend(local.delimiterData.pos, local.regexResult.pos[1]);
			arrayappend(local.delimiterData.len, local.regexResult.len[1]);
			local.currentPos = local.regexResult.pos[1] + local.regexResult.len[1];
		}
	}
	// Create a struct with all token positions to ignore
	local.tokenPos = [:];
	if (structkeyexists(local.delimiterData, "pos") && arraylen(local.delimiterData.pos) && val(local.delimiterData.pos[1])){
		for (local.currentPos=1; local.currentPos lte arraylen(local.delimiterData.pos); local.currentPos+=1){
			for (local.currentLen=1; local.currentLen lte local.delimiterData.len[local.currentPos]; local.currentLen+=1){
				local.tokenPos[local.delimiterData.pos[local.currentPos] + local.currentLen] = 0;
			}
		}
	}

	// replace string characters, ignore identified token character positions
	local.newString = [];
	local.transPos = 1;
	for (local.currentPos=1; local.currentPos lte arraylen(local.strArray); local.currentPos+=1){
		if (!structkeyexists(local.tokenPos, local.currentPos) && structkeyexists(local.pseudoTable, asc(local.strArray[local.currentPos]))){
			if (yesnoformat(arguments.randomResult)){
				local.transPos = randrange(1, arraylen(local.pseudoTable[asc(local.strArray[local.currentPos])]), "SHA1PRNG");
			}
			if (yesnoformat(arguments.useHTMLEntities)){
				arrayappend(local.newString, "&###local.pseudoTable[asc(local.strArray[local.currentPos])][local.transPos]#;");
			} else {
				arrayappend(local.newString, chr(local.pseudoTable[asc(local.strArray[local.currentPos])][local.transPos]));
			}
		} else {
			arrayappend(local.newString, local.strArray[local.currentPos]);
		}
	}
	return arraytolist(local.newString, "");
}

string function pseudolocalizeOneLetter(string str) hint="I pseudolocalize a single letter in each word" {
	local.result = listtoarray(arguments.str, " ");
	for (local.currentPos=1; local.currentPos lte arraylen(local.result); local.currentPos+=1){
		local.tempWord = local.result[local.currentPos];
		if (len(local.tempWord) gt 1){
			local.tempLetter = mid(local.tempword, randrange(1,len(local.tempword), "SHA1PRNG"), 1);
			local.result[local.currentPos] = replace(local.result[local.currentPos], local.tempLetter, pseudolocalize(local.tempLetter));
		}
	}
	return arraytoList(local.result, " ");
}
</cfscript>

Demo Code

<cfscript>
cfparam(name="form.Text", default="");
cfparam(name="form.OneLetter", default="0");
cfparam(name="form.Reverse", default="0");

isOneLetter = isvalid("boolean", form.OneLetter) && form.OneLetter;
testText = (issimplevalue(form.Text) && len(trim(form.Text))) ? trim(form.Text) : "";
isReverse = isvalid("boolean", form.reverse) && form.reverse;

tests = [
	"All work and no play makes {name} a dull boy."
	// ,"The quick brown fox jumps over the lazy dog."
	// ,"Now is the time for all good men to come to the aid of their country."
];
</cfscript>

<!--- entry form --->
<cfoutput>
	<form id="textForm" action="" method="post">
		<div>
			<textarea id="inputText" name="text" style="width:95%; height:100px;"><cfif len(testText)>#encodeforhtml(testText)#<cfelse>#tests[randrange(1,arraylen(tests), "SHA1PRING")]#</cfif></textarea>
		</div>
		<div>
			<b>Reverse:</b> <input type="checkbox" id="reverse" name="Reverse" value="1"<cfif isReverse> checked</cfif> />
			<label for="reverse">Revert to original latin</label>
		</div>
		<div>
			<b>Letters:</b> <input type="radio" id="OneLetter0" name="OneLetter" value="0"<cfif !isOneLetter> checked</cfif> />
			<label for="OneLetter0">All</label>
			<input type="radio" id="OneLetter1" name="OneLetter" value="1" class="required"<cfif isOneLetter> checked</cfif> />
			<label for="OneLetter1">One per word</label>
		</div>

		<button type="submit" class="btnui button-primary button-small button-raised">Pseudolocalize</button>
	</form>
</cfoutput>

<cfif !len(testText)>
	<cfexit>
</cfif>

<!--- View results --->
<cfset start = gettickcount()>
<cfset result = (isOneLetter) ? pseudolocalizeOneLetter(str=testText) : pseudolocalize(str=testText, reverse=isReverse)>
<cfset totalTime = gettickcount() - start>

<cfoutput>
	<fieldset style="margin:25px 0;">
		<legend><b>Pseudolocalized Result</b><cfif isOneLetter> (One Letter Per Word)</cfif> (#numberformat(totalTime)# ms; #len(result)# chars)</legend>
		<div>
			<span id="textResult">
				#encodeforhtml(result)#
			</span>
			<a href="##" id="revertBtn">Set as Source Text</a>
		</div>
		<cfif len(testText)>
			<button type="button" class="copyBtn btnui button-tiny button-raised" data-clipboard-target="##textResult">Copy to Clipboard</button>
		</cfif>
	</fieldset>
</cfoutput>

<script src="https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.11/clipboard.min.js"></script>

<script defer>
/* Clipboard */
var clipboard = new ClipboardJS('.copyBtn');
clipboard.on('success', function(e) {
	e.clearSelection();
	e.trigger.innerHTML = "Copied!";
	setTimeout(function() {e.trigger.innerHTML = "Copy to Clipboard";}, 1500);
});

/* Set as Source */
var revertBtn = document.getElementById("revertBtn");
var textArea = document.getElementById("inputText");
var textDiv = document.getElementById("textResult");
var textForm = document.getElementById("textForm");
if (revertBtn && textArea && textDiv && textForm) {
	revertBtn.onclick = function(e){
		e.preventDefault();
		textArea.value = textDiv.innerHTML.trim();
		revertBtn.remove();
	}
}
</script>