After some further research, I found that extended characters (anything past char(127)) are handled specially in standard UTF-8 base64 encoding. I did a little tampering with my existing rules and came up with the following updates today:
Encoder:
/* encode */
a!localVariables(
local!text: ri!text,
local!charset: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
local!codeArray: code(local!text),
local!binArray: a!forEach(
local!codeArray,
if(
fv!item > 127,
if(
fv!item > 2047,
if(
fv!item > 65535,
dec2bin(fv!item, 21),
dec2bin(fv!item, 16)
),
dec2bin(fv!item, 11)
),
dec2bin(fv!item, 8)
)
),
local!preProcessBinArray: a!forEach(
/* encoding UTF-8 self-synchronizing (grumble) ref: https://en.wikipedia.org/wiki/UTF-8#Description */
items: local!binArray,
expression: a!match(
value: len(fv!item), default: fv!item,
equals: 8,
then: fv!item,
equals: 11,
then: {
concat( "110", left(fv!item, 5) ),
concat( "10", right(fv!item, 6) )
},
equals: 16,
then: {
concat( "1110", left(fv!item, 4) ),
concat( "10", mid(fv!item, 5, 6) ),
concat( "10", right(fv!item, 6) )
},
equals: 21,
then: {
concat( "11110", left(fv!item, 3) ),
concat( "10", mid(fv!item, 4, 6) ),
concat( "10", mid(fv!item, 10, 6) ),
concat( "10", right(fv!item, 6) )
}
)
),
local!bins: concat(local!preProcessBinArray),
local!groupedBins: split(concat(
a!forEach(
items: code(local!bins),
expression: char(fv!item) & if(and(mod(fv!index, 6) = 0, not(fv!isLast)), "-=-=-", "")
)
), "-=-=-" ),
local!lastBin: index(local!groupedBins, length(local!groupedBins)),
local!padding: a!match(
value: len(local!lastBin),
equals: 2, then: "==",
equals: 4, then: "=",
equals: 6, then: "",
default: "error condition"
),
local!bindexes: a!forEach(
items: local!groupedBins,
expression: if(
fv!isLast,
bin2dec(substitute(padright(fv!item, 6), " ", "0")),
bin2dec(fv!item)
)
) + 1,
local!assembledOutput: concat(a!forEach(items: local!bindexes, expression: charat(local!charset, fv!item))) & local!padding,
local!assembledOutput
)
Decoder:
/* decode */
a!localVariables(
local!base64text: ri!base64text,
local!charset: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
local!charCodes: code(local!base64text),
/*local!paddingCount: count(wherecontains(code("="), local!charCodes)),*/
local!unpaddedChars: a!flatten(a!forEach(
items: local!charCodes,
expression: if(fv!item = code("="), {}, find(char(fv!item), local!charset)-1)
)),
local!rawBins: dec2bin(tointeger(local!unpaddedChars), 6),
local!decodedBins: split(
concat(
a!forEach(
items: code(concat(local!rawBins)),
expression: if(
mod(fv!index, 8) = 0,
char(fv!item) & "-=-=-",
char(fv!item)
)
)
),
"-=-=-"
),
local!intermediaryDecodedBinList: a!flatten(a!forEach(
/* update to handle UTF-8 self-synchronizing for extended character-set items. ref: https://en.wikipedia.org/wiki/UTF-8#Description */
items: local!decodedBins,
expression: if(
or(fv!item = "0000", fv!item = "00"),
{},
a!match(
value: fv!item, default: fv!value,
/* handle 2-byte items */
whentrue: left(fv!value, 3) = "110",
then: concat(right(fv!item, 5), right(local!decodedBins[fv!index+1], 6)),
whenTrue: or( /* manually check and skip re-using subsequent ones on following iterations (ugh) */
and(fv!index >= 2, left(local!decodedBins[fv!index-1], 3) = "110"),
),
then: {},
/* handle 3-byte items */
whentrue: left(fv!value, 4) = "1110",
then: concat(right(fv!item, 4), right(local!decodedBins[fv!index+1], 6), right(local!decodedBins[fv!index+2], 6)),
whenTrue: or( /* manually check and skip re-using subsequent ones on following iterations (ugh) */
and(fv!index >= 2, left(local!decodedBins[fv!index-1], 4) = "1110"),
and(fv!index >= 3, left(local!decodedBins[fv!index-2], 4) = "1110")
),
then: {},
/* handle 4-byte items */
whentrue: left(fv!value, 5) = "11110",
then: concat(right(fv!item, 3), right(local!decodedBins[fv!index+1], 6), right(local!decodedBins[fv!index+2], 6), right(local!decodedBins[fv!index+3], 6)),
whenTrue: or( /* manually check and skip re-using subsequent ones on following iterations (ugh) */
and(fv!index >= 2, left(local!decodedBins[fv!index-1], 5) = "11110"),
and(fv!index >= 3, left(local!decodedBins[fv!index-2], 5) = "11110"),
and(fv!index >= 4, left(local!decodedBins[fv!index-3], 5) = "11110")
),
then: {}
)
)
)),
concat(
a!forEach(
local!intermediaryDecodedBinList,
char(bin2dec(fv!item))
)
)
)
These work with text containing the euro symbol and other extended character set items to an extent. Warning that I have *not* tested them very thoroughly for corner cases (like items that exist at character set boundaries) yet, but the results so far match results given by public online base64 encoder tools.