@fairfox/zws
Version:
Zero-width steganography utilities for invisibly embedding data in text using Unicode zero-width characters
163 lines (162 loc) • 4.86 kB
JavaScript
// src/index.ts
var START_MARKER = "";
var ZERO_BIT = "";
var ONE_BIT = "";
var LENGTH_BITS = 16;
var MAX_DATA_LENGTH = 100;
var MAX_ENCODED_LENGTH = MAX_DATA_LENGTH * 16;
function hasEmbeddedData(text) {
return text.includes(START_MARKER);
}
function embed(text, data, options = {}) {
if (hasEmbeddedData(text)) {
throw new Error("Cannot embed: text already contains embedded data");
}
const payload = encodeData(data);
const lengthPrefix = bitsForLength(data.length);
const marker = START_MARKER + lengthPrefix + payload;
if (options.position === "after-first-sentence") {
const sentenceEnd = text.search(/[.!?]\s/);
if (sentenceEnd !== -1) {
const insertPos = sentenceEnd + 1;
return text.substring(0, insertPos) + marker + text.substring(insertPos);
}
}
return text + marker;
}
function extract(text) {
if (!hasEmbeddedData(text))
return "";
const ranges = findEmbeds(text);
if (ranges.length === 0)
return "";
const r = ranges[0];
return decodeData(text.substring(r.payloadStart, r.payloadStart + r.payloadLength));
}
function extractAll(text) {
if (!hasEmbeddedData(text))
return [];
return findEmbeds(text).map((r) => decodeData(text.substring(r.payloadStart, r.payloadStart + r.payloadLength)));
}
function getCleanText(text) {
if (!hasEmbeddedData(text))
return text;
const ranges = findEmbeds(text);
if (ranges.length === 0)
return text;
let out = "";
let cursor = 0;
for (const r of ranges) {
out += text.substring(cursor, r.start);
cursor = r.end;
}
out += text.substring(cursor);
return out;
}
function encodeData(data) {
if (data.length > MAX_DATA_LENGTH) {
throw new Error(`Data too long: ${data.length} characters. Maximum allowed: ${MAX_DATA_LENGTH} characters.`);
}
for (let i = 0;i < data.length; i++) {
const codePoint = data.codePointAt(i) || 0;
if (codePoint > 65535) {
throw new Error(`Invalid character in data: "${data[i]}" (U+${codePoint.toString(16).toUpperCase()}). Data must lie within the Basic Multilingual Plane.`);
}
}
const binary = Array.from(data).map((char) => char.charCodeAt(0).toString(2).padStart(16, "0")).join("");
return binary.split("").map((bit) => bit === "0" ? ZERO_BIT : ONE_BIT).join("");
}
function decodeData(encodedBinary) {
if (encodedBinary.length > MAX_ENCODED_LENGTH) {
throw new Error(`Encoded data too long: ${encodedBinary.length} characters. Maximum allowed: ${MAX_ENCODED_LENGTH} characters.`);
}
const cleanBinary = encodedBinary.split("").filter((char) => char === ZERO_BIT || char === ONE_BIT).map((char) => char === ZERO_BIT ? "0" : "1").join("");
const chars = [];
for (let i = 0;i < cleanBinary.length; i += 16) {
const charBinary = cleanBinary.substring(i, i + 16);
if (charBinary.length === 16) {
const codePoint = Number.parseInt(charBinary, 2);
chars.push(String.fromCharCode(codePoint));
}
}
return chars.join("");
}
function bitsForLength(n) {
return n.toString(2).padStart(LENGTH_BITS, "0").split("").map((bit) => bit === "0" ? ZERO_BIT : ONE_BIT).join("");
}
function findEmbeds(text) {
const ranges = [];
let i = 0;
while (i <= text.length - START_MARKER.length) {
const startIdx = text.indexOf(START_MARKER, i);
if (startIdx === -1)
break;
const lengthStart = startIdx + START_MARKER.length;
const payloadStart = lengthStart + LENGTH_BITS;
if (payloadStart > text.length) {
i = startIdx + 1;
continue;
}
let lengthBinary = "";
let valid = true;
for (let k = 0;k < LENGTH_BITS; k++) {
const c = text[lengthStart + k];
if (c === ZERO_BIT)
lengthBinary += "0";
else if (c === ONE_BIT)
lengthBinary += "1";
else {
valid = false;
break;
}
}
if (!valid) {
i = startIdx + 1;
continue;
}
const dataLength = Number.parseInt(lengthBinary, 2);
if (dataLength > MAX_DATA_LENGTH) {
i = startIdx + 1;
continue;
}
const payloadLength = dataLength * 16;
const end = payloadStart + payloadLength;
if (end > text.length) {
i = startIdx + 1;
continue;
}
let payloadValid = true;
for (let k = 0;k < payloadLength; k++) {
const c = text[payloadStart + k];
if (c !== ZERO_BIT && c !== ONE_BIT) {
payloadValid = false;
break;
}
}
if (!payloadValid) {
i = startIdx + 1;
continue;
}
ranges.push({ start: startIdx, end, payloadStart, payloadLength });
i = end;
}
return ranges;
}
var zws = {
START_MARKER,
ZERO_BIT,
ONE_BIT,
MAX_DATA_LENGTH,
MAX_ENCODED_LENGTH,
hasEmbeddedData,
embed,
extract,
extractAll,
getCleanText,
encodeData,
decodeData
};
var src_default = zws;
export {
src_default as default
};