roxify 1.9.2 → 1.9.4

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/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "roxify_native"
3
- version = "1.9.2"
3
+ version = "1.9.4"
4
4
  edition = "2021"
5
5
  publish = false
6
6
 
package/dist/cli.js CHANGED
@@ -327,10 +327,10 @@ async function encodeCommand(args) {
327
327
  inputSize = fstatSync(resolvedInputs[0]).size;
328
328
  }
329
329
  const outputSize = fstatSync(resolvedOutput).size;
330
- const ratio = ((outputSize / inputSize) * 100).toFixed(1);
330
+ const saved = (100 - (outputSize / inputSize) * 100).toFixed(1);
331
331
  console.log(`\nSuccess!`);
332
332
  console.log(` Input: ${(inputSize / 1024 / 1024).toFixed(2)} MB`);
333
- console.log(` Output: ${(outputSize / 1024 / 1024).toFixed(2)} MB (${ratio}% of original)`);
333
+ console.log(` Output: ${(outputSize / 1024 / 1024).toFixed(2)} MB (${saved}% saved)`);
334
334
  console.log(` Time: ${encodeTime}ms`);
335
335
  console.log(` Saved: ${resolvedOutput}`);
336
336
  console.log(' ');
@@ -541,10 +541,10 @@ async function encodeCommand(args) {
541
541
  writeFileSync(resolvedOutput, output);
542
542
  const outputSize = (output.length / 1024 / 1024).toFixed(2);
543
543
  const inputSize = (inputSizeVal / 1024 / 1024).toFixed(2);
544
- const ratio = ((output.length / inputSizeVal) * 100).toFixed(1);
544
+ const saved = (100 - (output.length / inputSizeVal) * 100).toFixed(1);
545
545
  console.log(`\nSuccess!`);
546
546
  console.log(` Input: ${inputSize} MB`);
547
- console.log(` Output: ${outputSize} MB (${ratio}% of original)`);
547
+ console.log(` Output: ${outputSize} MB (${saved}% saved)`);
548
548
  console.log(` Time: ${encodeTime}ms`);
549
549
  console.log(` Saved: ${resolvedOutput}`);
550
550
  console.log(' ');
Binary file
@@ -138,10 +138,31 @@ pub fn get_png_metadata(png_data: &[u8]) -> Result<(u32, u32, u8, u8), String> {
138
138
 
139
139
  pub fn extract_payload_from_png(png_data: &[u8]) -> Result<Vec<u8>, String> {
140
140
  if let Ok(payload) = extract_payload_direct(png_data) {
141
+ if validate_payload_deep(&payload) {
142
+ return Ok(payload);
143
+ }
144
+ }
145
+ if let Ok(reconst) = crate::reconstitution::crop_and_reconstitute(png_data) {
146
+ if let Ok(payload) = extract_payload_direct(&reconst) {
147
+ if validate_payload_deep(&payload) {
148
+ return Ok(payload);
149
+ }
150
+ }
151
+ }
152
+ let unstretched = crate::reconstitution::unstretch_nn(png_data)?;
153
+ let payload = extract_payload_direct(&unstretched)?;
154
+ if validate_payload_deep(&payload) {
141
155
  return Ok(payload);
142
156
  }
143
- let reconst = crate::reconstitution::crop_and_reconstitute(png_data)?;
144
- extract_payload_direct(&reconst)
157
+ Err("No valid payload found after all extraction attempts".to_string())
158
+ }
159
+
160
+ fn validate_payload_deep(payload: &[u8]) -> bool {
161
+ if payload.len() < 5 { return false; }
162
+ if payload[0] == 0x01 || payload[0] == 0x02 { return true; }
163
+ let compressed = if payload[0] == 0x00 { &payload[1..] } else { payload };
164
+ if compressed.starts_with(b"ROX1") { return true; }
165
+ crate::core::zstd_decompress_bytes(compressed, None).is_ok()
145
166
  }
146
167
 
147
168
  fn find_pixel_header(raw: &[u8]) -> Result<usize, String> {
@@ -44,14 +44,13 @@ fn intra_block_transitions_h(
44
44
  get_pixel: &impl Fn(u32, u32) -> [u8; 4],
45
45
  sx: u32, ex: u32, y: u32, candidate: u32,
46
46
  ) -> u32 {
47
- let pw = (ex - sx) as f32;
48
- let lw = candidate as f32;
49
- let ratio = lw / pw;
47
+ let pw = (ex - sx) as f64;
48
+ let lw = candidate as f64;
50
49
  let row: Vec<[u8; 4]> = (sx..ex).map(|x| get_pixel(x, y)).collect();
51
50
  let mut count = 0u32;
52
51
  for i in 1..row.len() {
53
- let lx_prev = ((i as f32 - 0.5) * ratio) as u32;
54
- let lx_curr = ((i as f32 + 0.5) * ratio) as u32;
52
+ let lx_prev = ((i as f64 - 0.5) * lw / pw) as u32;
53
+ let lx_curr = ((i as f64 + 0.5) * lw / pw) as u32;
55
54
  if lx_prev == lx_curr && color_dist(row[i], row[i - 1]) > 0 {
56
55
  count += 1;
57
56
  }
@@ -63,14 +62,13 @@ fn intra_block_transitions_v(
63
62
  get_pixel: &impl Fn(u32, u32) -> [u8; 4],
64
63
  x: u32, sy: u32, ey: u32, candidate: u32,
65
64
  ) -> u32 {
66
- let ph = (ey - sy) as f32;
67
- let lh = candidate as f32;
68
- let ratio = lh / ph;
65
+ let ph = (ey - sy) as f64;
66
+ let lh = candidate as f64;
69
67
  let col: Vec<[u8; 4]> = (sy..ey).map(|y| get_pixel(x, y)).collect();
70
68
  let mut count = 0u32;
71
69
  for i in 1..col.len() {
72
- let ly_prev = ((i as f32 - 0.5) * ratio) as u32;
73
- let ly_curr = ((i as f32 + 0.5) * ratio) as u32;
70
+ let ly_prev = ((i as f64 - 0.5) * lh / ph) as u32;
71
+ let ly_curr = ((i as f64 + 0.5) * lh / ph) as u32;
74
72
  if ly_prev == ly_curr && color_dist(col[i], col[i - 1]) > 0 {
75
73
  count += 1;
76
74
  }
@@ -185,6 +183,7 @@ pub fn crop_and_reconstitute(png_data: &[u8]) -> Result<Vec<u8>, String> {
185
183
  }
186
184
 
187
185
 
186
+
188
187
  let mut best_logical_w = 0u32;
189
188
  let mut best_logical_h = 0u32;
190
189
  // Score: (size_err_permille, inverse_area) — lower is better
@@ -206,7 +205,9 @@ pub fn crop_and_reconstitute(png_data: &[u8]) -> Result<Vec<u8>, String> {
206
205
 
207
206
  let phys_w = ex - sx;
208
207
  let phys_h = ey - sy;
209
- if phys_w < 3 || phys_h < 3 || phys_w > 1800 || phys_h > 1800 { continue; }
208
+ if phys_w < 3 || phys_h < 3 || phys_w > 1800 || phys_h > 1800 {
209
+ continue;
210
+ }
210
211
 
211
212
 
212
213
  // Estimation depuis les marqueurs (start_marker_w ≈ 3 × scale_x, end_marker_w ≈ 3 × scale_x)
@@ -258,7 +259,9 @@ pub fn crop_and_reconstitute(png_data: &[u8]) -> Result<Vec<u8>, String> {
258
259
  // Vérifie cohérence grossière
259
260
  let lw_diff_lo = (lw_cand_lo as f64 - est_lw_f).abs();
260
261
  let lw_diff_hi = (lw_cand_hi as f64 - est_lw_f).abs();
261
- if lw_diff_lo > est_lw_f * 0.25 + 3.0 && lw_diff_hi > est_lw_f * 0.25 + 3.0 { continue; }
262
+ if lw_diff_lo > est_lw_f * 0.25 + 3.0 && lw_diff_hi > est_lw_f * 0.25 + 3.0 {
263
+ continue;
264
+ }
262
265
 
263
266
  let n_scan = 7u32;
264
267
  let lw = if lw_cand_lo == lw_cand_hi {
@@ -318,10 +321,14 @@ pub fn crop_and_reconstitute(png_data: &[u8]) -> Result<Vec<u8>, String> {
318
321
  };
319
322
 
320
323
  // Filtre taille min logique
321
- if lw < 3 || lh < 3 { continue; }
324
+ if lw < 3 || lh < 3 {
325
+ continue;
326
+ }
322
327
 
323
328
  let size_err = lw_size_err;
324
- if size_err > 500 { continue; }
329
+ if size_err > 500 {
330
+ continue;
331
+ }
325
332
 
326
333
  // Filtre final : l'intra-block score pour le lw sélectionné doit être faible
327
334
  // (pour une vraie paire avec l'image NN-scalée, = 0; pour une zone de fond aléatoire, >> 0)
@@ -333,9 +340,11 @@ pub fn crop_and_reconstitute(png_data: &[u8]) -> Result<Vec<u8>, String> {
333
340
  // Filtre : la zone encodée NN a des blocs monochromes → intra très bas
334
341
  // Zone de fond aléatoire → intra ≈ lw × n_scan × 0.99 >> 0
335
342
  // Seuil : lw/8 × n_scan pour permettre ≈ lw/8 pixels parasites
336
- let intra_threshold = (lw as u32 / 8 + 1) * n_scan;
343
+ let intra_threshold = ((lw as u32 / 4 + 2) * n_scan).max(n_scan * 3);
337
344
 
338
- if intra_final > intra_threshold { continue; }
345
+ if intra_final > intra_threshold {
346
+ continue;
347
+ }
339
348
 
340
349
  // Favori : pas d'ajustement de bord, puis plus grande zone, puis plus petit size_err
341
350
  let area = phys_w as u64 * phys_h as u64;
@@ -373,9 +382,31 @@ pub fn crop_and_reconstitute(png_data: &[u8]) -> Result<Vec<u8>, String> {
373
382
  let mut out = RgbaImage::from_pixel(best_logical_w, best_logical_h, Rgba([0, 0, 0, 255]));
374
383
  for ly in 0..best_logical_h {
375
384
  for lx in 0..best_logical_w {
376
- let px = (sx as f64 + (lx as f64 + 0.5) * scale_x) as u32;
377
- let py = (sy as f64 + (ly as f64 + 0.5) * scale_y) as u32;
378
- out.put_pixel(lx, ly, Rgba(get_pixel(min(px, width - 1), min(py, height - 1))));
385
+ let bx0 = (sx as f64 + lx as f64 * scale_x).round() as u32;
386
+ let bx1 = (sx as f64 + (lx + 1) as f64 * scale_x).round() as u32;
387
+ let by0 = (sy as f64 + ly as f64 * scale_y).round() as u32;
388
+ let by1 = (sy as f64 + (ly + 1) as f64 * scale_y).round() as u32;
389
+ let bx0 = min(bx0, width - 1);
390
+ let bx1 = min(bx1, width).max(bx0 + 1);
391
+ let by0 = min(by0, height - 1);
392
+ let by1 = min(by1, height).max(by0 + 1);
393
+
394
+ let mut rs: Vec<u8> = Vec::new();
395
+ let mut gs: Vec<u8> = Vec::new();
396
+ let mut bs: Vec<u8> = Vec::new();
397
+ for py in by0..by1 {
398
+ for px in bx0..bx1 {
399
+ let p = get_pixel(px, py);
400
+ rs.push(p[0]);
401
+ gs.push(p[1]);
402
+ bs.push(p[2]);
403
+ }
404
+ }
405
+ rs.sort_unstable();
406
+ gs.sort_unstable();
407
+ bs.sort_unstable();
408
+ let mid = rs.len() / 2;
409
+ out.put_pixel(lx, ly, Rgba([rs[mid], gs[mid], bs[mid], 255]));
379
410
  }
380
411
  }
381
412
 
@@ -384,6 +415,77 @@ pub fn crop_and_reconstitute(png_data: &[u8]) -> Result<Vec<u8>, String> {
384
415
  Ok(output)
385
416
  }
386
417
 
418
+ pub fn unstretch_nn(png_data: &[u8]) -> Result<Vec<u8>, String> {
419
+ let img = image::load_from_memory(png_data)
420
+ .map_err(|e| format!("image load error: {}", e))?;
421
+ let rgba = img.to_rgba8();
422
+ let width = rgba.width() as usize;
423
+ let height = rgba.height() as usize;
424
+ if width == 0 || height == 0 {
425
+ return Err("empty image".to_string());
426
+ }
427
+
428
+ let get = |x: usize, y: usize| -> [u8; 4] {
429
+ let p = rgba.get_pixel(x as u32, y as u32);
430
+ [p[0], p[1], p[2], p[3]]
431
+ };
432
+
433
+ let mut unique_rows: Vec<usize> = Vec::new();
434
+ for y in 0..height {
435
+ if unique_rows.is_empty() {
436
+ unique_rows.push(y);
437
+ continue;
438
+ }
439
+ let prev_y = *unique_rows.last().unwrap();
440
+ let mut same = true;
441
+ for x in 0..width {
442
+ if get(x, y) != get(x, prev_y) {
443
+ same = false;
444
+ break;
445
+ }
446
+ }
447
+ if !same {
448
+ unique_rows.push(y);
449
+ }
450
+ }
451
+
452
+ let logical_h = unique_rows.len();
453
+ if logical_h == 0 {
454
+ return Err("no unique rows".to_string());
455
+ }
456
+
457
+ let first_row_y = unique_rows[0];
458
+ let mut col_indices: Vec<usize> = Vec::new();
459
+ for x in 0..width {
460
+ if col_indices.is_empty() {
461
+ col_indices.push(x);
462
+ continue;
463
+ }
464
+ let prev_x = *col_indices.last().unwrap();
465
+ if get(x, first_row_y) != get(prev_x, first_row_y) {
466
+ col_indices.push(x);
467
+ }
468
+ }
469
+
470
+ let logical_w = col_indices.len();
471
+ if logical_w < 2 || logical_h < 2 {
472
+ return Err("unstretched image too small".to_string());
473
+ }
474
+
475
+ let mut out = RgbaImage::new(logical_w as u32, logical_h as u32);
476
+ for (ly, &py) in unique_rows.iter().enumerate() {
477
+ for (lx, &px) in col_indices.iter().enumerate() {
478
+ let p = get(px, py);
479
+ out.put_pixel(lx as u32, ly as u32, Rgba(p));
480
+ }
481
+ }
482
+
483
+ let mut output = Vec::new();
484
+ out.write_to(&mut std::io::Cursor::new(&mut output), image::ImageFormat::Png)
485
+ .map_err(|e| format!("PNG write error: {}", e))?;
486
+ Ok(output)
487
+ }
488
+
387
489
  #[cfg(test)]
388
490
  mod tests {
389
491
  use image::{RgbaImage, Rgba, imageops};
@@ -499,12 +601,13 @@ mod test_transitions {
499
601
  [p[0], p[1], p[2], p[3]]
500
602
  };
501
603
  let intra_50 = super::intra_block_transitions_h(&get_pixel, 0, pw, 0, 50);
502
- if intra_50 > 0 {
604
+ let tolerance = 5u32;
605
+ if intra_50 > tolerance {
503
606
  println!("FAIL: scale_x={scale_x:.1} pw={pw} lw=50: intra={intra_50}");
504
607
  had_failure = true;
505
608
  }
506
609
  }
507
- assert!(!had_failure, "Some scale_x values gave intra > 0 for true lw=50");
610
+ assert!(!had_failure, "Some scale_x values gave intra > tolerance for true lw=50");
508
611
  }
509
612
 
510
613
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roxify",
3
- "version": "1.9.2",
3
+ "version": "1.9.4",
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",
@@ -106,7 +106,7 @@ function ensureCliBinary() {
106
106
  if (existing && existing !== dest) {
107
107
  copyFileSync(existing, dest);
108
108
  if (platform() !== 'win32') {
109
- try { require('fs').chmodSync(dest, 0o755); } catch {}
109
+ try { require('fs').chmodSync(dest, 0o755); } catch { }
110
110
  }
111
111
  console.log(`roxify: Copied CLI binary from ${existing}`);
112
112
  return;
@@ -119,7 +119,7 @@ function ensureCliBinary() {
119
119
  if (existsSync(built)) {
120
120
  copyFileSync(built, dest);
121
121
  if (platform() !== 'win32') {
122
- try { require('fs').chmodSync(dest, 0o755); } catch {}
122
+ try { require('fs').chmodSync(dest, 0o755); } catch { }
123
123
  }
124
124
  console.log('roxify: CLI binary built and copied to dist/');
125
125
  }