roxify 1.6.8 → 1.7.0

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 CHANGED
@@ -46,6 +46,7 @@ The core compression and image-processing logic is written in Rust and exposed t
46
46
  - **Audio container** -- encode data as structured multi-frequency tones (not white noise) in WAV files
47
47
  - **Directory packing** -- encode entire directory trees into a single PNG
48
48
  - **Screenshot reconstitution** -- recover data from photographed or screenshotted PNGs
49
+ - **Stretch-resilient decoding** -- automatically un-stretches nearest-neighbor scaled images back to original pixel data
49
50
  - **CLI and programmatic API** -- use from the terminal or import as a library
50
51
  - **Cross-platform** -- prebuilt binaries for Linux x64, macOS x64/ARM64, and Windows x64
51
52
  - **Full TypeScript support** with exported types and TSDoc annotations
@@ -388,6 +389,21 @@ interface DecodeResult {
388
389
  | `screenshot` | Encodes data as RGB pixels in a standard PNG. The image looks like a gradient or noise pattern and survives re-uploads and social media processing. | Sharing on image-only platforms, bypassing file-type filters |
389
390
  | `compact` | Minimal 1x1 PNG with data embedded in a custom ancillary chunk (`rXDT`). Produces the smallest possible output. | Programmatic use, archival, maximum compression ratio |
390
391
 
392
+ ### Stretch-Resilient Decoding
393
+
394
+ Roxify automatically detects and recovers data from **nearest-neighbor stretched** images. If a roxified PNG is scaled up (e.g., zoomed in a browser, pasted in a document, resized in an image editor with nearest-neighbor interpolation), the decoder:
395
+
396
+ 1. **Crops** the image to the non-background bounding box
397
+ 2. **Collapses** horizontal runs of identical pixels back to single logical pixels
398
+ 3. **Deduplicates** consecutive identical rows
399
+
400
+ This means you can share a roxified image at any zoom level and it will still decode correctly. Non-uniform stretch factors and white padding are fully supported.
401
+
402
+ ```bash
403
+ # Works even on stretched/zoomed screenshots
404
+ rox decode zoomed-screenshot.png -o output/
405
+ ```
406
+
391
407
  ---
392
408
 
393
409
  ## Encryption
@@ -603,7 +619,7 @@ Input --> RS ECC Encode --> Interleave --> Block Encode (MFSK audio / QR-like im
603
619
  ### Decompression Pipeline
604
620
 
605
621
  ```
606
- Input --> PNG Parse --> AES-256-GCM Decrypt (optional) --> Zstd Decompress --> Output
622
+ Input --> PNG Parse --> Un-stretch (if needed) --> AES-256-GCM Decrypt (optional) --> Zstd Decompress --> Output
607
623
  ```
608
624
 
609
625
  ### Lossy-Resilient Decode Pipeline
@@ -1,4 +1,17 @@
1
1
  import { DecodeOptions, DecodeResult } from './types.js';
2
+ /**
3
+ * Un-stretch an image that was nearest-neighbor scaled.
4
+ * 1. Crops to non-background bounding box
5
+ * 2. Collapses horizontal runs of identical pixels into single pixels
6
+ * 3. Removes duplicate consecutive rows
7
+ *
8
+ * Returns null if the image doesn't appear to be stretched.
9
+ */
10
+ export declare function unstretchImage(rawRGB: Buffer, width: number, height: number, tolerance?: number): {
11
+ data: Buffer;
12
+ width: number;
13
+ height: number;
14
+ } | null;
2
15
  /**
3
16
  * Decode a ROX PNG or buffer into the original binary payload or files list.
4
17
  *
@@ -15,6 +15,133 @@ import { parallelZstdDecompress, tryZstdDecompress } from './zstd.js';
15
15
  function isColorMatch(r1, g1, b1, r2, g2, b2) {
16
16
  return Math.abs(r1 - r2) + Math.abs(g1 - g2) + Math.abs(b1 - b2) < 50;
17
17
  }
18
+ /**
19
+ * Un-stretch an image that was nearest-neighbor scaled.
20
+ * 1. Crops to non-background bounding box
21
+ * 2. Collapses horizontal runs of identical pixels into single pixels
22
+ * 3. Removes duplicate consecutive rows
23
+ *
24
+ * Returns null if the image doesn't appear to be stretched.
25
+ */
26
+ export function unstretchImage(rawRGB, width, height, tolerance = 0) {
27
+ if (width <= 0 || height <= 0 || rawRGB.length < width * height * 3) {
28
+ return null;
29
+ }
30
+ // Step 1: Find bounding box of non-background pixels
31
+ // Background = white-ish (all channels >= 240)
32
+ let minX = width, maxX = -1, minY = height, maxY = -1;
33
+ for (let y = 0; y < height; y++) {
34
+ const rowBase = y * width * 3;
35
+ for (let x = 0; x < width; x++) {
36
+ const idx = rowBase + x * 3;
37
+ const r = rawRGB[idx], g = rawRGB[idx + 1], b = rawRGB[idx + 2];
38
+ // Skip background: white or near-white
39
+ if (r >= 240 && g >= 240 && b >= 240)
40
+ continue;
41
+ if (x < minX)
42
+ minX = x;
43
+ if (x > maxX)
44
+ maxX = x;
45
+ if (y < minY)
46
+ minY = y;
47
+ if (y > maxY)
48
+ maxY = y;
49
+ }
50
+ }
51
+ if (maxX < minX || maxY < minY)
52
+ return null; // all background
53
+ const cropW = maxX - minX + 1;
54
+ const cropH = maxY - minY + 1;
55
+ // Don't process tiny images
56
+ if (cropW < 2 || cropH < 2)
57
+ return null;
58
+ // Step 2: Collapse horizontal runs per row + deduplicate rows
59
+ const pixelsMatch = (buf, i1, i2) => {
60
+ if (tolerance === 0) {
61
+ return (buf[i1] === buf[i2] &&
62
+ buf[i1 + 1] === buf[i2 + 1] &&
63
+ buf[i1 + 2] === buf[i2 + 2]);
64
+ }
65
+ return (Math.abs(buf[i1] - buf[i2]) +
66
+ Math.abs(buf[i1 + 1] - buf[i2 + 1]) +
67
+ Math.abs(buf[i1 + 2] - buf[i2 + 2]) <=
68
+ tolerance);
69
+ };
70
+ const logicalRows = [];
71
+ let logicalW = -1;
72
+ let prevRowKey = '';
73
+ for (let y = minY; y <= maxY; y++) {
74
+ const rowBase = y * width * 3;
75
+ const pixels = [];
76
+ let prevIdx = -1;
77
+ for (let x = minX; x <= maxX; x++) {
78
+ const idx = rowBase + x * 3;
79
+ if (prevIdx >= 0 && pixelsMatch(rawRGB, idx, prevIdx)) {
80
+ continue; // same as previous pixel, skip (collapse run)
81
+ }
82
+ pixels.push(rawRGB[idx], rawRGB[idx + 1], rawRGB[idx + 2]);
83
+ prevIdx = idx;
84
+ }
85
+ // Compute row key for dedup
86
+ const rowKey = pixels.join(',');
87
+ if (rowKey === prevRowKey) {
88
+ continue; // duplicate row, skip
89
+ }
90
+ prevRowKey = rowKey;
91
+ const rowW = pixels.length / 3;
92
+ if (logicalW === -1) {
93
+ logicalW = rowW;
94
+ }
95
+ else if (rowW !== logicalW) {
96
+ // A uniform-color row collapses to 1 pixel — expand to logicalW
97
+ if (rowW === 1 && logicalW > 1) {
98
+ const r = pixels[0], g = pixels[1], b = pixels[2];
99
+ for (let f = 1; f < logicalW; f++) {
100
+ pixels.push(r, g, b);
101
+ }
102
+ }
103
+ else if (logicalW === 1 && rowW > 1) {
104
+ // First row was uniform, adopt new width and expand it
105
+ const prevRow = logicalRows[logicalRows.length - 1];
106
+ if (prevRow) {
107
+ const pr = prevRow.pixels[0], pg = prevRow.pixels[1], pb = prevRow.pixels[2];
108
+ prevRow.pixels = [];
109
+ for (let f = 0; f < rowW; f++) {
110
+ prevRow.pixels.push(pr, pg, pb);
111
+ }
112
+ }
113
+ logicalW = rowW;
114
+ }
115
+ else {
116
+ // Inconsistent row widths → not a clean stretch
117
+ // Try with tolerance if we haven't already
118
+ if (tolerance === 0) {
119
+ return unstretchImage(rawRGB, width, height, 30);
120
+ }
121
+ return null;
122
+ }
123
+ }
124
+ logicalRows.push({ pixels, key: rowKey });
125
+ }
126
+ if (logicalRows.length === 0 || logicalW <= 0)
127
+ return null;
128
+ const logicalH = logicalRows.length;
129
+ // Sanity: must have actually reduced the image
130
+ if (logicalW >= cropW && logicalH >= cropH)
131
+ return null;
132
+ // Build output buffer
133
+ const data = Buffer.allocUnsafe(logicalW * logicalH * 3);
134
+ let outIdx = 0;
135
+ for (const row of logicalRows) {
136
+ for (let i = 0; i < row.pixels.length; i++) {
137
+ data[outIdx++] = row.pixels[i];
138
+ }
139
+ }
140
+ if (process.env.ROX_DEBUG) {
141
+ console.log(`DEBUG: unstretch ${width}x${height} → crop ${cropW}x${cropH} → logical ${logicalW}x${logicalH}`);
142
+ }
143
+ return { data, width: logicalW, height: logicalH };
144
+ }
18
145
  async function tryDecompress(payload, onProgress) {
19
146
  return await parallelZstdDecompress(payload, onProgress);
20
147
  }
@@ -458,29 +585,56 @@ export async function decodePngToBinary(input, opts = {}) {
458
585
  hasBlockMagic = true;
459
586
  }
460
587
  }
461
- let logicalWidth;
462
- let logicalHeight;
463
- let logicalData;
588
+ let logicalWidth = 0;
589
+ let logicalHeight = 0;
590
+ let logicalData = Buffer.alloc(0);
464
591
  if (hasMarkerStart || hasPixelMagic || hasBlockMagic) {
465
592
  logicalWidth = currentWidth;
466
593
  logicalHeight = currentHeight;
467
594
  logicalData = rawRGB;
468
595
  }
469
596
  else {
470
- if (process.env.ROX_DEBUG || opts.debugDir) {
471
- console.log('DEBUG: about to call cropAndReconstitute, debugDir=', opts.debugDir);
597
+ // Try cropAndReconstitute first (for screenshots with markers)
598
+ let reconSuccess = false;
599
+ try {
600
+ if (process.env.ROX_DEBUG || opts.debugDir) {
601
+ console.log('DEBUG: about to call cropAndReconstitute, debugDir=', opts.debugDir);
602
+ }
603
+ const reconstructed = await cropAndReconstitute(processedBuf, opts.debugDir);
604
+ if (process.env.ROX_DEBUG || opts.debugDir) {
605
+ console.log('DEBUG: cropAndReconstitute returned, reconstructed len=', reconstructed.length);
606
+ }
607
+ const rawData = native.sharpToRaw(reconstructed);
608
+ if (process.env.ROX_DEBUG || opts.debugDir) {
609
+ console.log('DEBUG: rawData from reconstructed:', rawData.width, 'x', rawData.height, 'pixels=', Math.floor(rawData.pixels.length / 3));
610
+ }
611
+ logicalWidth = rawData.width;
612
+ logicalHeight = rawData.height;
613
+ logicalData = Buffer.from(rawData.pixels);
614
+ reconSuccess = true;
472
615
  }
473
- const reconstructed = await cropAndReconstitute(processedBuf, opts.debugDir);
474
- if (process.env.ROX_DEBUG || opts.debugDir) {
475
- console.log('DEBUG: cropAndReconstitute returned, reconstructed len=', reconstructed.length);
616
+ catch (reconErr) {
617
+ if (process.env.ROX_DEBUG) {
618
+ console.log('DEBUG: cropAndReconstitute failed:', reconErr instanceof Error ? reconErr.message : reconErr);
619
+ }
476
620
  }
477
- const rawData = native.sharpToRaw(reconstructed);
478
- if (process.env.ROX_DEBUG || opts.debugDir) {
479
- console.log('DEBUG: rawData from reconstructed:', rawData.width, 'x', rawData.height, 'pixels=', Math.floor(rawData.pixels.length / 3));
621
+ // Fallback: try un-stretching (nearest-neighbor scaled images)
622
+ if (!reconSuccess) {
623
+ const pixelW = isBlockEncoded ? currentWidth / 2 : currentWidth;
624
+ const pixelH = isBlockEncoded ? currentHeight / 2 : currentHeight;
625
+ if (process.env.ROX_DEBUG) {
626
+ console.log(`DEBUG: trying unstretch on ${pixelW}x${pixelH} rawRGB`);
627
+ }
628
+ const unstretched = unstretchImage(rawRGB, pixelW, pixelH);
629
+ if (unstretched) {
630
+ logicalWidth = unstretched.width;
631
+ logicalHeight = unstretched.height;
632
+ logicalData = unstretched.data;
633
+ }
634
+ else {
635
+ throw new Error('No valid markers found and image unstretch failed');
636
+ }
480
637
  }
481
- logicalWidth = rawData.width;
482
- logicalHeight = rawData.height;
483
- logicalData = Buffer.from(rawData.pixels);
484
638
  }
485
639
  if (process.env.ROX_DEBUG) {
486
640
  console.log('DEBUG: Logical grid reconstructed:', logicalWidth, 'x', logicalHeight, '=', logicalWidth * logicalHeight, 'pixels');
@@ -1,21 +1,26 @@
1
1
  import { existsSync } from 'fs';
2
2
  import { createRequire } from 'module';
3
3
  import { arch, platform } from 'os';
4
- import { join, resolve } from 'path';
4
+ import { dirname, join, resolve } from 'path';
5
+ import { fileURLToPath } from 'url';
5
6
  function getNativeModule() {
6
7
  let moduleDir;
7
8
  let nativeRequire;
9
+ // In ESM, __dirname is not available — derive it from import.meta.url
10
+ // which always points to the actual file location on disk.
11
+ const esmFilename = fileURLToPath(import.meta.url);
12
+ const esmDirname = dirname(esmFilename);
8
13
  if (typeof __dirname !== 'undefined') {
9
14
  moduleDir = __dirname;
10
15
  nativeRequire = require;
11
16
  }
12
17
  else {
13
- moduleDir = process.cwd();
18
+ moduleDir = esmDirname;
14
19
  try {
15
20
  nativeRequire = require;
16
21
  }
17
22
  catch {
18
- nativeRequire = createRequire(process.cwd() + '/package.json');
23
+ nativeRequire = createRequire(esmFilename);
19
24
  }
20
25
  }
21
26
  function getNativePath() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roxify",
3
- "version": "1.6.8",
3
+ "version": "1.7.0",
4
4
  "type": "module",
5
5
  "description": "Ultra-lightweight PNG steganography with native Rust acceleration. Encode binary data into PNG images with zstd compression.",
6
6
  "main": "dist/index.js",
Binary file