roxify 1.6.6 → 1.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +223 -22
- package/dist/cli.js +70 -11
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/utils/audio.d.ts +23 -0
- package/dist/utils/audio.js +98 -0
- package/dist/utils/decoder.js +104 -0
- package/dist/utils/ecc.d.ts +75 -0
- package/dist/utils/ecc.js +446 -0
- package/dist/utils/encoder.js +151 -43
- package/dist/utils/robust-audio.d.ts +54 -0
- package/dist/utils/robust-audio.js +400 -0
- package/dist/utils/robust-image.d.ts +54 -0
- package/dist/utils/robust-image.js +515 -0
- package/dist/utils/types.d.ts +24 -0
- package/dist/utils/zstd.js +26 -12
- package/package.json +1 -1
- package/roxify_native-x86_64-unknown-linux-gnu.node +0 -0
- package/roxify_native.node +0 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lossy-Resilient Image Encoding (PNG container).
|
|
3
|
+
*
|
|
4
|
+
* Encodes binary data using QR-code-inspired techniques:
|
|
5
|
+
* - Large pixel blocks (configurable 2×2 to 8×8) for JPEG/WebP resilience.
|
|
6
|
+
* - Binary encoding (black/white) with threshold detection.
|
|
7
|
+
* - Finder patterns at corners for automatic alignment.
|
|
8
|
+
* - Reed-Solomon error correction with configurable redundancy.
|
|
9
|
+
* - Byte-level interleaving to spread burst errors across RS blocks.
|
|
10
|
+
*
|
|
11
|
+
* The resulting image looks like a structured data pattern (similar to
|
|
12
|
+
* a QR code) and can be recovered even after JPEG recompression at
|
|
13
|
+
* quality levels as low as 30–50.
|
|
14
|
+
*/
|
|
15
|
+
import { EccLevel } from './ecc.js';
|
|
16
|
+
export interface RobustImageEncodeOptions {
|
|
17
|
+
/** Pixel size per data block (2–8). Higher = more lossy resilience. Default: 4. */
|
|
18
|
+
blockSize?: number;
|
|
19
|
+
/** Error correction level. Default: 'medium'. */
|
|
20
|
+
eccLevel?: EccLevel;
|
|
21
|
+
}
|
|
22
|
+
export interface RobustImageDecodeResult {
|
|
23
|
+
data: Buffer;
|
|
24
|
+
correctedErrors: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Encode binary data into a lossy-resilient PNG image.
|
|
28
|
+
*
|
|
29
|
+
* The output uses QR-code-like techniques:
|
|
30
|
+
* - Finder patterns for alignment after re-encoding.
|
|
31
|
+
* - Large pixel blocks for JPEG/WebP resilience.
|
|
32
|
+
* - Reed-Solomon ECC for automatic error correction.
|
|
33
|
+
*
|
|
34
|
+
* @param data - Raw data to encode.
|
|
35
|
+
* @param opts - Encoding options.
|
|
36
|
+
* @returns PNG image as a Buffer.
|
|
37
|
+
*/
|
|
38
|
+
export declare function encodeRobustImage(data: Buffer, opts?: RobustImageEncodeOptions): Buffer;
|
|
39
|
+
/**
|
|
40
|
+
* Decode binary data from a lossy-resilient PNG image.
|
|
41
|
+
*
|
|
42
|
+
* Handles images that have been re-encoded through JPEG/WebP at various
|
|
43
|
+
* quality levels. The Reed-Solomon ECC layer corrects any bit errors
|
|
44
|
+
* introduced by lossy compression.
|
|
45
|
+
*
|
|
46
|
+
* @param png - PNG (or raw RGB) image buffer.
|
|
47
|
+
* @returns Decoded data and error correction stats.
|
|
48
|
+
*/
|
|
49
|
+
export declare function decodeRobustImage(png: Buffer): RobustImageDecodeResult;
|
|
50
|
+
/**
|
|
51
|
+
* Check if a PNG buffer contains a robust-image-encoded payload.
|
|
52
|
+
* Looks for finder patterns in the corners.
|
|
53
|
+
*/
|
|
54
|
+
export declare function isRobustImage(png: Buffer): boolean;
|
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lossy-Resilient Image Encoding (PNG container).
|
|
3
|
+
*
|
|
4
|
+
* Encodes binary data using QR-code-inspired techniques:
|
|
5
|
+
* - Large pixel blocks (configurable 2×2 to 8×8) for JPEG/WebP resilience.
|
|
6
|
+
* - Binary encoding (black/white) with threshold detection.
|
|
7
|
+
* - Finder patterns at corners for automatic alignment.
|
|
8
|
+
* - Reed-Solomon error correction with configurable redundancy.
|
|
9
|
+
* - Byte-level interleaving to spread burst errors across RS blocks.
|
|
10
|
+
*
|
|
11
|
+
* The resulting image looks like a structured data pattern (similar to
|
|
12
|
+
* a QR code) and can be recovered even after JPEG recompression at
|
|
13
|
+
* quality levels as low as 30–50.
|
|
14
|
+
*/
|
|
15
|
+
import { eccDecode, eccEncode } from './ecc.js';
|
|
16
|
+
import { native } from './native.js';
|
|
17
|
+
// ─── Configuration ──────────────────────────────────────────────────────────
|
|
18
|
+
/** Finder pattern (7×7 blocks, like QR codes). */
|
|
19
|
+
const FINDER_SIZE = 7;
|
|
20
|
+
/** Alignment pattern (5×5 blocks). */
|
|
21
|
+
const ALIGNMENT_SIZE = 5;
|
|
22
|
+
/** Quiet zone around finder patterns (blocks). */
|
|
23
|
+
const QUIET_ZONE = 1;
|
|
24
|
+
/** Header area width in blocks (next to top-left finder). */
|
|
25
|
+
const HEADER_BLOCKS = 20;
|
|
26
|
+
/** Magic bytes for robust image format. */
|
|
27
|
+
const ROBUST_IMG_MAGIC = Buffer.from('RBI1');
|
|
28
|
+
// ─── Finder Pattern ─────────────────────────────────────────────────────────
|
|
29
|
+
/**
|
|
30
|
+
* Generate a 7×7 finder pattern (same as QR code finder).
|
|
31
|
+
* Returns a 2D boolean grid (true = black, false = white).
|
|
32
|
+
*/
|
|
33
|
+
function finderPattern() {
|
|
34
|
+
const p = [];
|
|
35
|
+
for (let y = 0; y < FINDER_SIZE; y++) {
|
|
36
|
+
const row = [];
|
|
37
|
+
for (let x = 0; x < FINDER_SIZE; x++) {
|
|
38
|
+
const border = y === 0 || y === 6 || x === 0 || x === 6;
|
|
39
|
+
const inner = y >= 2 && y <= 4 && x >= 2 && x <= 4;
|
|
40
|
+
row.push(border || inner);
|
|
41
|
+
}
|
|
42
|
+
p.push(row);
|
|
43
|
+
}
|
|
44
|
+
return p;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Generate a 5×5 alignment pattern.
|
|
48
|
+
*/
|
|
49
|
+
function alignmentPattern() {
|
|
50
|
+
const p = [];
|
|
51
|
+
for (let y = 0; y < ALIGNMENT_SIZE; y++) {
|
|
52
|
+
const row = [];
|
|
53
|
+
for (let x = 0; x < ALIGNMENT_SIZE; x++) {
|
|
54
|
+
const border = y === 0 || y === 4 || x === 0 || x === 4;
|
|
55
|
+
const center = y === 2 && x === 2;
|
|
56
|
+
row.push(border || center);
|
|
57
|
+
}
|
|
58
|
+
p.push(row);
|
|
59
|
+
}
|
|
60
|
+
return p;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Compute grid layout: place finder patterns, quiet zones, and determine
|
|
64
|
+
* which block positions are available for data.
|
|
65
|
+
* Optimized: uses flat boolean array instead of Set<string> for reserved lookup.
|
|
66
|
+
*/
|
|
67
|
+
function computeLayout(dataBlocks) {
|
|
68
|
+
// Minimum grid to fit data + finder patterns + quiet zones
|
|
69
|
+
const finderFootprint = FINDER_SIZE + QUIET_ZONE;
|
|
70
|
+
// Start with a square grid and expand if needed
|
|
71
|
+
let side = Math.ceil(Math.sqrt(dataBlocks + 4 * finderFootprint * finderFootprint));
|
|
72
|
+
side = Math.max(side, finderFootprint * 2 + 4); // minimum for finders
|
|
73
|
+
const gridW = side;
|
|
74
|
+
const gridH = side;
|
|
75
|
+
// Determine reserved regions (finder patterns + quiet zones) using flat array
|
|
76
|
+
const reserved = new Uint8Array(gridW * gridH);
|
|
77
|
+
// Top-left finder
|
|
78
|
+
for (let y = 0; y < finderFootprint; y++) {
|
|
79
|
+
for (let x = 0; x < finderFootprint; x++) {
|
|
80
|
+
reserved[y * gridW + x] = 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Top-right finder
|
|
84
|
+
for (let y = 0; y < finderFootprint; y++) {
|
|
85
|
+
for (let x = gridW - finderFootprint; x < gridW; x++) {
|
|
86
|
+
reserved[y * gridW + x] = 1;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Bottom-left finder
|
|
90
|
+
for (let y = gridH - finderFootprint; y < gridH; y++) {
|
|
91
|
+
for (let x = 0; x < finderFootprint; x++) {
|
|
92
|
+
reserved[y * gridW + x] = 1;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Bottom-right finder
|
|
96
|
+
for (let y = gridH - finderFootprint; y < gridH; y++) {
|
|
97
|
+
for (let x = gridW - finderFootprint; x < gridW; x++) {
|
|
98
|
+
reserved[y * gridW + x] = 1;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Collect available data positions (row-by-row)
|
|
102
|
+
const dataPositions = [];
|
|
103
|
+
for (let y = 0; y < gridH; y++) {
|
|
104
|
+
const rowBase = y * gridW;
|
|
105
|
+
for (let x = 0; x < gridW; x++) {
|
|
106
|
+
if (!reserved[rowBase + x]) {
|
|
107
|
+
dataPositions.push([x, y]);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { gridW, gridH, dataPositions };
|
|
112
|
+
}
|
|
113
|
+
// ─── Block Rendering ────────────────────────────────────────────────────────
|
|
114
|
+
/**
|
|
115
|
+
* Render the block grid into an RGB pixel buffer.
|
|
116
|
+
* Optimized: fills row spans instead of individual pixels.
|
|
117
|
+
*
|
|
118
|
+
* @param grid - 2D grid of block values (0 = black, 255 = white).
|
|
119
|
+
* @param blockSize - Pixel size of each block (e.g., 4 = 4×4 pixels per block).
|
|
120
|
+
* @returns { rgb, width, height } - Raw RGB buffer and pixel dimensions.
|
|
121
|
+
*/
|
|
122
|
+
function renderGrid(grid, gridW, gridH, blockSize) {
|
|
123
|
+
const width = gridW * blockSize;
|
|
124
|
+
const height = gridH * blockSize;
|
|
125
|
+
const rgb = Buffer.alloc(width * height * 3);
|
|
126
|
+
const stride = width * 3;
|
|
127
|
+
for (let by = 0; by < gridH; by++) {
|
|
128
|
+
const row = grid[by];
|
|
129
|
+
// Build one pixel row for this block row
|
|
130
|
+
const firstRowOffset = by * blockSize * stride;
|
|
131
|
+
for (let bx = 0; bx < gridW; bx++) {
|
|
132
|
+
const val = row[bx];
|
|
133
|
+
const pxStart = firstRowOffset + bx * blockSize * 3;
|
|
134
|
+
// Fill one row of pixel span for this block
|
|
135
|
+
for (let dx = 0; dx < blockSize; dx++) {
|
|
136
|
+
const off = pxStart + dx * 3;
|
|
137
|
+
rgb[off] = val;
|
|
138
|
+
rgb[off + 1] = val;
|
|
139
|
+
rgb[off + 2] = val;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Copy the first pixel row to the remaining (blockSize - 1) rows
|
|
143
|
+
const srcStart = firstRowOffset;
|
|
144
|
+
for (let dy = 1; dy < blockSize; dy++) {
|
|
145
|
+
rgb.copy(rgb, firstRowOffset + dy * stride, srcStart, srcStart + stride);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { rgb, width, height };
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Place a finder pattern on the grid at a given position.
|
|
152
|
+
*/
|
|
153
|
+
function placeFinderPattern(grid, startX, startY) {
|
|
154
|
+
const fp = finderPattern();
|
|
155
|
+
for (let y = 0; y < FINDER_SIZE; y++) {
|
|
156
|
+
for (let x = 0; x < FINDER_SIZE; x++) {
|
|
157
|
+
grid[startY + y][startX + x] = fp[y][x] ? 0 : 255;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// ─── Read Blocks from Image ─────────────────────────────────────────────────
|
|
162
|
+
/**
|
|
163
|
+
* Read block values from an RGB pixel buffer using majority voting.
|
|
164
|
+
* Each block's pixels are averaged, then thresholded to 0 or 255.
|
|
165
|
+
*/
|
|
166
|
+
function readBlocks(rgb, width, height, blockSize, gridW, gridH) {
|
|
167
|
+
const grid = [];
|
|
168
|
+
for (let by = 0; by < gridH; by++) {
|
|
169
|
+
const row = new Uint8Array(gridW);
|
|
170
|
+
for (let bx = 0; bx < gridW; bx++) {
|
|
171
|
+
let sum = 0;
|
|
172
|
+
let count = 0;
|
|
173
|
+
for (let dy = 0; dy < blockSize; dy++) {
|
|
174
|
+
for (let dx = 0; dx < blockSize; dx++) {
|
|
175
|
+
const py = by * blockSize + dy;
|
|
176
|
+
const px = bx * blockSize + dx;
|
|
177
|
+
if (py < height && px < width) {
|
|
178
|
+
const idx = (py * width + px) * 3;
|
|
179
|
+
// Average RGB to grayscale
|
|
180
|
+
sum += (rgb[idx] + rgb[idx + 1] + rgb[idx + 2]) / 3;
|
|
181
|
+
count++;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// Threshold at midpoint (128)
|
|
186
|
+
row[bx] = count > 0 && sum / count > 128 ? 255 : 0;
|
|
187
|
+
}
|
|
188
|
+
grid.push(row);
|
|
189
|
+
}
|
|
190
|
+
return grid;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Detect finder patterns and extract grid parameters from an image.
|
|
194
|
+
* Returns the estimated block size and grid dimensions.
|
|
195
|
+
*/
|
|
196
|
+
function detectFinderPatterns(rgb, width, height) {
|
|
197
|
+
// Try each candidate block size (2–8) and look for finder patterns
|
|
198
|
+
for (let bs = 2; bs <= 8; bs++) {
|
|
199
|
+
const gw = Math.floor(width / bs);
|
|
200
|
+
const gh = Math.floor(height / bs);
|
|
201
|
+
if (gw < FINDER_SIZE * 2 + 4 || gh < FINDER_SIZE * 2 + 4)
|
|
202
|
+
continue;
|
|
203
|
+
// Check top-left corner for finder pattern
|
|
204
|
+
const fp = finderPattern();
|
|
205
|
+
let matchCount = 0;
|
|
206
|
+
let totalChecked = 0;
|
|
207
|
+
for (let fy = 0; fy < FINDER_SIZE; fy++) {
|
|
208
|
+
for (let fx = 0; fx < FINDER_SIZE; fx++) {
|
|
209
|
+
const expected = fp[fy][fx] ? 0 : 255;
|
|
210
|
+
// Sample center of block
|
|
211
|
+
const py = fy * bs + Math.floor(bs / 2);
|
|
212
|
+
const px = fx * bs + Math.floor(bs / 2);
|
|
213
|
+
if (py >= height || px >= width)
|
|
214
|
+
continue;
|
|
215
|
+
const idx = (py * width + px) * 3;
|
|
216
|
+
const gray = (rgb[idx] + rgb[idx + 1] + rgb[idx + 2]) / 3;
|
|
217
|
+
const actual = gray > 128 ? 255 : 0;
|
|
218
|
+
totalChecked++;
|
|
219
|
+
if (actual === expected)
|
|
220
|
+
matchCount++;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Require ≥80% match for finder detection
|
|
224
|
+
if (totalChecked > 0 && matchCount / totalChecked >= 0.8) {
|
|
225
|
+
return { blockSize: bs, gridW: gw, gridH: gh };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
// ─── Header Encoding ────────────────────────────────────────────────────────
|
|
231
|
+
/**
|
|
232
|
+
* Encode header bits into a sequence of data positions.
|
|
233
|
+
* Header: [4B magic] [1B blockSize] [1B eccLevel] [4B dataLen] [2B gridW] [2B gridH]
|
|
234
|
+
* Total: 14 bytes = 112 bits.
|
|
235
|
+
*/
|
|
236
|
+
function encodeHeader(blockSize, eccLevel, dataLen, gridW, gridH) {
|
|
237
|
+
const header = Buffer.alloc(14);
|
|
238
|
+
ROBUST_IMG_MAGIC.copy(header, 0);
|
|
239
|
+
header[4] = blockSize;
|
|
240
|
+
header[5] = eccLevel;
|
|
241
|
+
header.writeUInt32BE(dataLen, 6);
|
|
242
|
+
header.writeUInt16BE(gridW, 10);
|
|
243
|
+
header.writeUInt16BE(gridH, 12);
|
|
244
|
+
return new Uint8Array(header);
|
|
245
|
+
}
|
|
246
|
+
function decodeHeader(bytes) {
|
|
247
|
+
if (bytes.length < 14)
|
|
248
|
+
return null;
|
|
249
|
+
const buf = Buffer.from(bytes);
|
|
250
|
+
if (!buf.subarray(0, 4).equals(ROBUST_IMG_MAGIC))
|
|
251
|
+
return null;
|
|
252
|
+
return {
|
|
253
|
+
blockSize: buf[4],
|
|
254
|
+
eccLevel: buf[5],
|
|
255
|
+
dataLen: buf.readUInt32BE(6),
|
|
256
|
+
gridW: buf.readUInt16BE(10),
|
|
257
|
+
gridH: buf.readUInt16BE(12),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
// ─── Bit/Byte Packing ──────────────────────────────────────────────────────
|
|
261
|
+
/**
|
|
262
|
+
* Convert bytes to bits (MSB first within each byte).
|
|
263
|
+
*/
|
|
264
|
+
function bytesToBits(data) {
|
|
265
|
+
const bits = new Uint8Array(data.length * 8);
|
|
266
|
+
for (let i = 0; i < data.length; i++) {
|
|
267
|
+
for (let bit = 7; bit >= 0; bit--) {
|
|
268
|
+
bits[i * 8 + (7 - bit)] = (data[i] >> bit) & 1;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return bits;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Convert bits back to bytes (MSB first).
|
|
275
|
+
*/
|
|
276
|
+
function bitsToBytes(bits) {
|
|
277
|
+
const numBytes = Math.ceil(bits.length / 8);
|
|
278
|
+
const bytes = new Uint8Array(numBytes);
|
|
279
|
+
for (let i = 0; i < numBytes; i++) {
|
|
280
|
+
let byte = 0;
|
|
281
|
+
for (let bit = 0; bit < 8; bit++) {
|
|
282
|
+
const idx = i * 8 + bit;
|
|
283
|
+
if (idx < bits.length && bits[idx]) {
|
|
284
|
+
byte |= 1 << (7 - bit);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
bytes[i] = byte;
|
|
288
|
+
}
|
|
289
|
+
return bytes;
|
|
290
|
+
}
|
|
291
|
+
const ECC_LEVEL_MAP = {
|
|
292
|
+
low: 0,
|
|
293
|
+
medium: 1,
|
|
294
|
+
quartile: 2,
|
|
295
|
+
high: 3,
|
|
296
|
+
};
|
|
297
|
+
const ECC_LEVEL_REVERSE = {
|
|
298
|
+
0: 'low',
|
|
299
|
+
1: 'medium',
|
|
300
|
+
2: 'quartile',
|
|
301
|
+
3: 'high',
|
|
302
|
+
};
|
|
303
|
+
/**
|
|
304
|
+
* Encode binary data into a lossy-resilient PNG image.
|
|
305
|
+
*
|
|
306
|
+
* The output uses QR-code-like techniques:
|
|
307
|
+
* - Finder patterns for alignment after re-encoding.
|
|
308
|
+
* - Large pixel blocks for JPEG/WebP resilience.
|
|
309
|
+
* - Reed-Solomon ECC for automatic error correction.
|
|
310
|
+
*
|
|
311
|
+
* @param data - Raw data to encode.
|
|
312
|
+
* @param opts - Encoding options.
|
|
313
|
+
* @returns PNG image as a Buffer.
|
|
314
|
+
*/
|
|
315
|
+
export function encodeRobustImage(data, opts = {}) {
|
|
316
|
+
const blockSize = opts.blockSize ?? 4;
|
|
317
|
+
const eccLevel = opts.eccLevel ?? 'medium';
|
|
318
|
+
if (blockSize < 2 || blockSize > 8) {
|
|
319
|
+
throw new Error(`Block size must be 2–8, got ${blockSize}`);
|
|
320
|
+
}
|
|
321
|
+
// 1. Protect with ECC
|
|
322
|
+
const protectedData = eccEncode(data, eccLevel);
|
|
323
|
+
// 2. Convert header + protected data to bits
|
|
324
|
+
const headerBytes = encodeHeader(blockSize, ECC_LEVEL_MAP[eccLevel], data.length, 0, // gridW placeholder (filled after layout)
|
|
325
|
+
0);
|
|
326
|
+
// Total payload: header (14 bytes) + protected data
|
|
327
|
+
const payload = Buffer.concat([Buffer.from(headerBytes), protectedData]);
|
|
328
|
+
const bits = bytesToBits(new Uint8Array(payload));
|
|
329
|
+
// 3. Compute layout
|
|
330
|
+
const layout = computeLayout(bits.length);
|
|
331
|
+
// Update header with actual grid dimensions
|
|
332
|
+
const headerFinal = encodeHeader(blockSize, ECC_LEVEL_MAP[eccLevel], data.length, layout.gridW, layout.gridH);
|
|
333
|
+
const payloadFinal = Buffer.concat([Buffer.from(headerFinal), protectedData]);
|
|
334
|
+
const bitsFinal = bytesToBits(new Uint8Array(payloadFinal));
|
|
335
|
+
// Re-layout with correct size if needed
|
|
336
|
+
const finalLayout = computeLayout(bitsFinal.length);
|
|
337
|
+
if (finalLayout.dataPositions.length < bitsFinal.length) {
|
|
338
|
+
throw new Error(`Data too large for image: need ${bitsFinal.length} blocks, have ${finalLayout.dataPositions.length}`);
|
|
339
|
+
}
|
|
340
|
+
// 4. Build the block grid
|
|
341
|
+
const { gridW, gridH, dataPositions } = finalLayout;
|
|
342
|
+
const grid = [];
|
|
343
|
+
for (let y = 0; y < gridH; y++) {
|
|
344
|
+
grid.push(new Uint8Array(gridW).fill(255)); // white background
|
|
345
|
+
}
|
|
346
|
+
// Place finder patterns at 4 corners
|
|
347
|
+
placeFinderPattern(grid, 0, 0);
|
|
348
|
+
placeFinderPattern(grid, gridW - FINDER_SIZE, 0);
|
|
349
|
+
placeFinderPattern(grid, 0, gridH - FINDER_SIZE);
|
|
350
|
+
placeFinderPattern(grid, gridW - FINDER_SIZE, gridH - FINDER_SIZE);
|
|
351
|
+
// Place data bits
|
|
352
|
+
for (let i = 0; i < bitsFinal.length && i < dataPositions.length; i++) {
|
|
353
|
+
const [bx, by] = dataPositions[i];
|
|
354
|
+
grid[by][bx] = bitsFinal[i] ? 0 : 255; // 1 = black, 0 = white
|
|
355
|
+
}
|
|
356
|
+
// 5. Render to pixels
|
|
357
|
+
const { rgb, width, height } = renderGrid(grid, gridW, gridH, blockSize);
|
|
358
|
+
// 6. Encode as PNG
|
|
359
|
+
if (native?.rgbToPng) {
|
|
360
|
+
return Buffer.from(native.rgbToPng(rgb, width, height));
|
|
361
|
+
}
|
|
362
|
+
// Fallback: manual PNG generation (minimal, no compression)
|
|
363
|
+
return manualPngEncode(rgb, width, height);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Decode binary data from a lossy-resilient PNG image.
|
|
367
|
+
*
|
|
368
|
+
* Handles images that have been re-encoded through JPEG/WebP at various
|
|
369
|
+
* quality levels. The Reed-Solomon ECC layer corrects any bit errors
|
|
370
|
+
* introduced by lossy compression.
|
|
371
|
+
*
|
|
372
|
+
* @param png - PNG (or raw RGB) image buffer.
|
|
373
|
+
* @returns Decoded data and error correction stats.
|
|
374
|
+
*/
|
|
375
|
+
export function decodeRobustImage(png) {
|
|
376
|
+
// 1. Get raw pixels
|
|
377
|
+
let width;
|
|
378
|
+
let height;
|
|
379
|
+
let rgb;
|
|
380
|
+
if (native?.sharpMetadata && native?.sharpToRaw) {
|
|
381
|
+
const meta = native.sharpMetadata(png);
|
|
382
|
+
width = meta.width;
|
|
383
|
+
height = meta.height;
|
|
384
|
+
const raw = native.sharpToRaw(png);
|
|
385
|
+
rgb = Buffer.from(raw.pixels);
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
throw new Error('Robust image decoding requires the native module (sharpMetadata + sharpToRaw)');
|
|
389
|
+
}
|
|
390
|
+
// 2. Detect finder patterns to determine block size and grid
|
|
391
|
+
const detection = detectFinderPatterns(rgb, width, height);
|
|
392
|
+
if (!detection) {
|
|
393
|
+
throw new Error('Could not detect finder patterns — image may be too corrupted');
|
|
394
|
+
}
|
|
395
|
+
const { blockSize, gridW, gridH } = detection;
|
|
396
|
+
// 3. Read block grid
|
|
397
|
+
const grid = readBlocks(rgb, width, height, blockSize, gridW, gridH);
|
|
398
|
+
// 4. Compute layout (same algorithm as encoding)
|
|
399
|
+
const finderFootprint = FINDER_SIZE + QUIET_ZONE;
|
|
400
|
+
const reserved = new Uint8Array(gridW * gridH);
|
|
401
|
+
for (let y = 0; y < finderFootprint; y++) {
|
|
402
|
+
for (let x = 0; x < finderFootprint; x++) {
|
|
403
|
+
reserved[y * gridW + x] = 1;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
for (let y = 0; y < finderFootprint; y++) {
|
|
407
|
+
for (let x = gridW - finderFootprint; x < gridW; x++) {
|
|
408
|
+
reserved[y * gridW + x] = 1;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
for (let y = gridH - finderFootprint; y < gridH; y++) {
|
|
412
|
+
for (let x = 0; x < finderFootprint; x++) {
|
|
413
|
+
reserved[y * gridW + x] = 1;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
for (let y = gridH - finderFootprint; y < gridH; y++) {
|
|
417
|
+
for (let x = gridW - finderFootprint; x < gridW; x++) {
|
|
418
|
+
reserved[y * gridW + x] = 1;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const dataPositions = [];
|
|
422
|
+
for (let y = 0; y < gridH; y++) {
|
|
423
|
+
const rowBase = y * gridW;
|
|
424
|
+
for (let x = 0; x < gridW; x++) {
|
|
425
|
+
if (!reserved[rowBase + x]) {
|
|
426
|
+
dataPositions.push([x, y]);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
// 5. Extract bits from data positions
|
|
431
|
+
const bits = new Uint8Array(dataPositions.length);
|
|
432
|
+
for (let i = 0; i < dataPositions.length; i++) {
|
|
433
|
+
const [bx, by] = dataPositions[i];
|
|
434
|
+
if (by < grid.length && bx < grid[by].length) {
|
|
435
|
+
bits[i] = grid[by][bx] === 0 ? 1 : 0; // black = 1, white = 0
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// 6. Convert bits to bytes
|
|
439
|
+
const allBytes = bitsToBytes(bits);
|
|
440
|
+
// 7. Parse header (first 14 bytes)
|
|
441
|
+
const header = decodeHeader(allBytes);
|
|
442
|
+
if (!header) {
|
|
443
|
+
throw new Error('Invalid robust image header — data may be corrupted');
|
|
444
|
+
}
|
|
445
|
+
// 8. Extract ECC-protected payload
|
|
446
|
+
const eccData = Buffer.from(allBytes.subarray(14));
|
|
447
|
+
// 9. Decode ECC
|
|
448
|
+
const { data, totalCorrected } = eccDecode(eccData);
|
|
449
|
+
// 10. Trim to original length
|
|
450
|
+
return {
|
|
451
|
+
data: data.subarray(0, header.dataLen),
|
|
452
|
+
correctedErrors: totalCorrected,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Check if a PNG buffer contains a robust-image-encoded payload.
|
|
457
|
+
* Looks for finder patterns in the corners.
|
|
458
|
+
*/
|
|
459
|
+
export function isRobustImage(png) {
|
|
460
|
+
try {
|
|
461
|
+
if (!native?.sharpMetadata || !native?.sharpToRaw)
|
|
462
|
+
return false;
|
|
463
|
+
const meta = native.sharpMetadata(png);
|
|
464
|
+
const raw = native.sharpToRaw(png);
|
|
465
|
+
const rgb = Buffer.from(raw.pixels);
|
|
466
|
+
return detectFinderPatterns(rgb, meta.width, meta.height) !== null;
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
// ─── Fallback PNG Encoder ───────────────────────────────────────────────────
|
|
473
|
+
/**
|
|
474
|
+
* Minimal PNG encoder for when the native module is unavailable.
|
|
475
|
+
* Uses uncompressed IDAT (zlib stored blocks).
|
|
476
|
+
*/
|
|
477
|
+
function manualPngEncode(rgb, width, height) {
|
|
478
|
+
const zlib = require('zlib');
|
|
479
|
+
// Build raw image data with filter byte (0 = None) per row
|
|
480
|
+
const bytesPerRow = width * 3;
|
|
481
|
+
const rawData = Buffer.alloc(height * (1 + bytesPerRow));
|
|
482
|
+
for (let y = 0; y < height; y++) {
|
|
483
|
+
rawData[y * (1 + bytesPerRow)] = 0; // filter: None
|
|
484
|
+
rgb.copy(rawData, y * (1 + bytesPerRow) + 1, y * bytesPerRow, (y + 1) * bytesPerRow);
|
|
485
|
+
}
|
|
486
|
+
const deflated = zlib.deflateSync(rawData, { level: 0 });
|
|
487
|
+
// PNG signature
|
|
488
|
+
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
489
|
+
// IHDR chunk
|
|
490
|
+
const ihdr = Buffer.alloc(13);
|
|
491
|
+
ihdr.writeUInt32BE(width, 0);
|
|
492
|
+
ihdr.writeUInt32BE(height, 4);
|
|
493
|
+
ihdr[8] = 8; // bit depth
|
|
494
|
+
ihdr[9] = 2; // color type: RGB
|
|
495
|
+
ihdr[10] = 0; // compression
|
|
496
|
+
ihdr[11] = 0; // filter
|
|
497
|
+
ihdr[12] = 0; // interlace
|
|
498
|
+
function pngChunk(type, data) {
|
|
499
|
+
const typeBuf = Buffer.from(type, 'ascii');
|
|
500
|
+
const length = Buffer.alloc(4);
|
|
501
|
+
length.writeUInt32BE(data.length, 0);
|
|
502
|
+
const combined = Buffer.concat([typeBuf, data]);
|
|
503
|
+
const { crc32: crc32Fn } = require('./crc.js');
|
|
504
|
+
const crcVal = crc32Fn(data, crc32Fn(typeBuf));
|
|
505
|
+
const crcBuf = Buffer.alloc(4);
|
|
506
|
+
crcBuf.writeUInt32BE(crcVal >>> 0, 0);
|
|
507
|
+
return Buffer.concat([length, combined, crcBuf]);
|
|
508
|
+
}
|
|
509
|
+
return Buffer.concat([
|
|
510
|
+
sig,
|
|
511
|
+
pngChunk('IHDR', ihdr),
|
|
512
|
+
pngChunk('IDAT', deflated),
|
|
513
|
+
pngChunk('IEND', Buffer.alloc(0)),
|
|
514
|
+
]);
|
|
515
|
+
}
|
package/dist/utils/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { PackedFile } from '../pack.js';
|
|
2
|
+
import type { EccLevel } from './ecc.js';
|
|
2
3
|
export interface EncodeOptions {
|
|
3
4
|
compression?: 'zstd';
|
|
4
5
|
compressionLevel?: number;
|
|
@@ -11,6 +12,27 @@ export interface EncodeOptions {
|
|
|
11
12
|
_skipAuto?: boolean;
|
|
12
13
|
output?: 'auto' | 'png' | 'rox';
|
|
13
14
|
outputFormat?: 'png' | 'webp';
|
|
15
|
+
/** Container format: 'image' (PNG, default) or 'sound' (WAV) */
|
|
16
|
+
container?: 'image' | 'sound';
|
|
17
|
+
/**
|
|
18
|
+
* Enable lossy-resilient encoding. When true, the output survives lossy
|
|
19
|
+
* compression (MP3/AAC for audio, JPEG/WebP for image) using QR-code-like
|
|
20
|
+
* error correction and block-based encoding.
|
|
21
|
+
*/
|
|
22
|
+
lossyResilient?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Error correction level for lossy-resilient mode.
|
|
25
|
+
* - 'low': ~10% redundancy, corrects ~4% errors
|
|
26
|
+
* - 'medium': ~19% redundancy, corrects ~9% errors (default)
|
|
27
|
+
* - 'quartile': ~33% redundancy, corrects ~15% errors
|
|
28
|
+
* - 'high': ~100% redundancy, corrects ~25% errors
|
|
29
|
+
*/
|
|
30
|
+
eccLevel?: EccLevel;
|
|
31
|
+
/**
|
|
32
|
+
* Block size for lossy-resilient image mode (2–8 pixels per data block).
|
|
33
|
+
* Larger blocks survive heavier lossy compression. Default: 4.
|
|
34
|
+
*/
|
|
35
|
+
robustBlockSize?: number;
|
|
14
36
|
includeName?: boolean;
|
|
15
37
|
includeFileList?: boolean;
|
|
16
38
|
fileList?: Array<string | {
|
|
@@ -33,6 +55,8 @@ export interface DecodeResult {
|
|
|
33
55
|
name?: string;
|
|
34
56
|
};
|
|
35
57
|
files?: PackedFile[];
|
|
58
|
+
/** Number of symbol errors corrected by Reed-Solomon ECC (lossy-resilient mode). */
|
|
59
|
+
correctedErrors?: number;
|
|
36
60
|
}
|
|
37
61
|
export interface DecodeOptions {
|
|
38
62
|
passphrase?: string;
|
package/dist/utils/zstd.js
CHANGED
|
@@ -46,6 +46,31 @@ export async function compressStream(stream, level = 19, onProgress, dict) {
|
|
|
46
46
|
}
|
|
47
47
|
export async function parallelZstdCompress(payload, level = 19, onProgress, dict) {
|
|
48
48
|
const chunkSize = 8 * 1024 * 1024;
|
|
49
|
+
// For small payloads (< chunkSize), concatenate and compress as single frame
|
|
50
|
+
// to avoid multi-chunk overhead (16+ bytes header per chunk boundary).
|
|
51
|
+
let flat = null;
|
|
52
|
+
if (Array.isArray(payload)) {
|
|
53
|
+
const totalLen = payload.reduce((a, b) => a + b.length, 0);
|
|
54
|
+
if (totalLen <= chunkSize) {
|
|
55
|
+
flat = Buffer.concat(payload);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
flat = payload;
|
|
60
|
+
}
|
|
61
|
+
if (flat && flat.length <= chunkSize) {
|
|
62
|
+
if (onProgress)
|
|
63
|
+
onProgress(0, 1);
|
|
64
|
+
if (!nativeZstdCompress && !nativeZstdCompressWithDict) {
|
|
65
|
+
throw new Error('Native zstd compression not available');
|
|
66
|
+
}
|
|
67
|
+
const result = Buffer.from(nativeZstdCompressWithDict && dict ?
|
|
68
|
+
nativeZstdCompressWithDict(flat, level, dict)
|
|
69
|
+
: nativeZstdCompress(flat, level));
|
|
70
|
+
if (onProgress)
|
|
71
|
+
onProgress(1, 1);
|
|
72
|
+
return [result];
|
|
73
|
+
}
|
|
49
74
|
const chunks = [];
|
|
50
75
|
if (Array.isArray(payload)) {
|
|
51
76
|
for (const p of payload) {
|
|
@@ -60,17 +85,6 @@ export async function parallelZstdCompress(payload, level = 19, onProgress, dict
|
|
|
60
85
|
}
|
|
61
86
|
}
|
|
62
87
|
else {
|
|
63
|
-
if (payload.length <= chunkSize) {
|
|
64
|
-
if (onProgress)
|
|
65
|
-
onProgress(0, 1);
|
|
66
|
-
if (!nativeZstdCompress) {
|
|
67
|
-
throw new Error('Native zstd compression not available');
|
|
68
|
-
}
|
|
69
|
-
const result = Buffer.from(nativeZstdCompress(payload, level));
|
|
70
|
-
if (onProgress)
|
|
71
|
-
onProgress(1, 1);
|
|
72
|
-
return [result];
|
|
73
|
-
}
|
|
74
88
|
for (let i = 0; i < payload.length; i += chunkSize) {
|
|
75
89
|
chunks.push(payload.subarray(i, Math.min(i + chunkSize, payload.length)));
|
|
76
90
|
}
|
|
@@ -132,7 +146,7 @@ export async function parallelZstdDecompress(payload, onProgress) {
|
|
|
132
146
|
const decompressedChunks = [];
|
|
133
147
|
for (let i = 0; i < numChunks; i++) {
|
|
134
148
|
const size = chunkSizes[i];
|
|
135
|
-
const chunk = payload.
|
|
149
|
+
const chunk = payload.subarray(offset, offset + size);
|
|
136
150
|
offset += size;
|
|
137
151
|
if (!nativeZstdDecompress) {
|
|
138
152
|
throw new Error('Native zstd decompression not available');
|
package/package.json
CHANGED
|
Binary file
|
package/roxify_native.node
CHANGED
|
Binary file
|