cum-rs 0.1.1

🧹 A multilanguage crate to remove AI-provider watermarks from text, images, and documents.
Documentation
// Copyright 2026 Mahmoud Harmouch.
//
// Licensed under the MIT license
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
// option. This file may not be copied, modified, or distributed
// except according to those terms.

//! # Node.js Bindings
//!
//! napi-rs native add-on exposing `cum_rs` to Node.js.
//!
//! ## Usage (after `npm install cum-rs`)
//!
//! ```javascript
//! const { cleanText, inspectText, cleanBytes } = require('cum-rs');
//!
//! const result = cleanText("Hello\u200b world\ufeff!");
//! console.log(result.cleaned);       // "Hello world!"
//! console.log(result.removedCount);  // 2
//! ```

use crate::cleaner::clean;
use crate::stochastic::StochasticEnhancer;
use crate::unicode::{CleanOpts, InspectOpts, clean_text, inspect_text};
use napi::bindgen_prelude::*;
use napi_derive::napi;

/// Result of a [`clean_text_node`] call.
#[napi(object)]
pub struct CleanTextResult {
    /// The cleaned text string with all watermark carriers removed.
    pub cleaned: String,
    /// Number of characters removed outright.
    pub removed_count: u32,
    /// Number of characters replaced with canonical equivalents.
    pub replaced_count: u32,
    /// Human-readable summary, one line per operation.
    pub summary: Vec<String>,
}

/// A single Layer-A character finding from [`inspect_text_node`].
#[napi(object)]
pub struct CharHit {
    /// Unicode scalar value of the suspicious codepoint.
    pub codepoint: u32,
    /// The suspicious character as a string.
    pub character: String,
    /// Human-readable label.
    pub label: String,
    /// Number of occurrences.
    pub count: u32,
    /// Watermark kind string (e.g. `"zwj_family"`).
    pub kind: String,
    /// Confidence string (e.g. `"probable"`).
    pub confidence: String,
    /// Up to 10 character offsets.
    pub sample_offsets: Vec<u32>,
}

/// Result of an [`inspect_text_node`] call.
#[napi(object)]
pub struct TextInspectReport {
    /// Number of Unicode scalar values in the inspected text.
    pub length: u32,
    /// Total count of suspicious characters.
    pub suspicious_total: u32,
    /// Per-codepoint findings.
    pub hits: Vec<CharHit>,
    /// Informational notes.
    pub notes: Vec<String>,
}

/// Removes all Layer-A Unicode watermark carriers from a string.
///
/// # Arguments
/// * `text`: the text to clean.
///
/// # Returns
/// A [`CleanTextResult`] with the cleaned string and statistics.
#[napi(js_name = "cleanText")]
pub fn clean_text_node(text: String) -> napi::Result<CleanTextResult> {
    let opts = CleanOpts::safe();
    let (cleaned, stats) =
        clean_text(&text, &opts).map_err(|e| napi::Error::from_reason(e.to_string()))?;
    Ok(CleanTextResult {
        cleaned,
        removed_count: stats.removed_count as u32,
        replaced_count: stats.replaced_count as u32,
        summary: stats.summary,
    })
}

/// Inspects a string for Layer-A Unicode watermark carriers.
///
/// # Arguments
/// * `text`: the text to inspect.
///
/// # Returns
/// A [`TextInspectReport`] with per-codepoint findings.
#[napi(js_name = "inspectText")]
pub fn inspect_text_node(text: String) -> napi::Result<TextInspectReport> {
    let opts = InspectOpts::default();
    let report = inspect_text(&text, &opts).map_err(|e| napi::Error::from_reason(e.to_string()))?;
    let hits = report
        .hits
        .into_iter()
        .map(|h| CharHit {
            codepoint: h.codepoint,
            character: h.character,
            label: h.label,
            count: h.count as u32,
            kind: h.kind.as_str().to_string(),
            confidence: h.confidence.as_str().to_string(),
            sample_offsets: h.sample_offsets.into_iter().map(|o| o as u32).collect(),
        })
        .collect();
    Ok(TextInspectReport {
        length: report.length as u32,
        suspicious_total: report.suspicious_total as u32,
        hits,
        notes: report.notes,
    })
}

/// Removes all detectable watermarks from a `Buffer` (image or document).
///
/// The format is auto-detected from magic bytes.
///
/// # Arguments
/// * `data`: the raw bytes as a Node.js `Buffer`.
///
/// # Returns
/// A `Buffer` containing the cleaned bytes.
#[napi(js_name = "cleanBytes")]
pub fn clean_bytes_node(data: Buffer) -> napi::Result<Buffer> {
    let bytes: &[u8] = &data;
    let out = clean(bytes, None).map_err(|e| napi::Error::from_reason(e.to_string()))?;
    Ok(Buffer::from(out.bytes))
}

/// Result of an [`enhance_text_node`] call.
#[napi(object)]
pub struct EnhanceResult {
    /// The enhanced text with synonym substitutions applied.
    pub enhanced: String,
    /// Per-word substitution probability that was used.
    pub probability: f64,
    /// Number of words that were actually substituted.
    pub words_substituted: u32,
}

/// Applies stochastic synonym substitution to a Node.js string.
///
/// Each non-stop word is replaced by a synonym with probability `probability`
/// (clamped to `[0.0, 1.0]`).  On native Linux/macOS targets the system
/// dictionary is loaded; the curated PHF table is always available.
///
/// # Arguments
/// * `text`:        the plain-text string to enhance.
/// * `probability`: per-word substitution probability in `[0.0, 1.0]`.
///
/// # Returns
/// An [`EnhanceResult`] containing the enhanced text and substitution statistics.
///
/// # Example (JavaScript)
/// ```javascript
/// const { enhanceText } = require('cum-rs');
/// const result = enhanceText("The chaos governs the universe", 0.7);
/// console.log(result.enhanced);         // words replaced stochastically
/// console.log(result.wordsSubstituted); // number of substitutions made
/// ```
#[napi(js_name = "enhanceText")]
pub fn enhance_text_node(text: String, probability: f64) -> napi::Result<EnhanceResult> {
    let enhancer = StochasticEnhancer::new(probability);
    let out = enhancer.enhance(&text);
    Ok(EnhanceResult {
        enhanced: out.text,
        probability: out.probability,
        words_substituted: out.words_substituted as u32,
    })
}