تفاصيل المشروع
Signed-off-by: Mohamed-Abdelhalim2 <codepen.io.dropper985@passinbox.com>
هذا الالتزام موجود في:
908
src/App.tsx
Normal file
908
src/App.tsx
Normal file
@@ -0,0 +1,908 @@
|
||||
/**
|
||||
* نقطة الإدخال وواجهة المستخدم (Main Application Component) - SteganoPixel
|
||||
* =========================================================================
|
||||
* الجزء الأول من الشرح الهيكلي:
|
||||
* يمثل هذا الملف الواجهة التفاعلية الشاملة لتطبيق "SteganoPixel"، حيث يجمع بين طبقة العرض (React & Tailwind CSS)
|
||||
* وطبقة المعالجة المنطقية (Steganography Engine). يتيح المكون للمستخدمين التنقل بسلاسة بين نمطين أساسيين: نمط "الإخفاء"
|
||||
* لدمج النصوص السرية داخل الصور المرفوعة، ونمط "الاستخراج/التنظيف" لقراءة النصوص المضمنة مسبقاً أو تدميرها نهائياً.
|
||||
* يتم إدارة جميع الحالات التفاعلية (معاينة الصور، أخطاء الإدخال، تقدم العمليات، وحالة النسخ إلى الحافظة) محلياً داخل المتصفح.
|
||||
*
|
||||
* الجزء الثاني من الشرح الهيكلي:
|
||||
* يتفاعل المكون مع محرك الإخفاء الرقمي (`src/utils/steganography.ts`) من خلال تحويل ملفات الصور المرفوعة (File API)
|
||||
* إلى عناصر مرئية (HTMLImageElement) ثم رسمها على كائن `<canvas>` في الذاكرة لاستخلاص مصفوفة البكسلات الخام (`ImageData`).
|
||||
* يتم تمرير هذه المصفوفة إلى دوال التشفير أو فك التشفير، ثم يعاد تجميع النتيجة في ملف `Blob` بصيغة `image/png` وتوليد
|
||||
* رابط محلي (`URL.createObjectURL`) لتمكين المستخدم من تنزيل الصورة الناتجة بأمان تام ودون أي اتصال بخوادم خارجية.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState, type ChangeEvent, type DragEvent } from "react";
|
||||
import {
|
||||
embedTextInImageData,
|
||||
extractHiddenTextFromImageData,
|
||||
getImageCapacityBytes,
|
||||
removeHiddenTextFromImageData,
|
||||
type ExtractedHiddenText,
|
||||
type HiddenTextMetadata,
|
||||
} from "./utils/steganography";
|
||||
|
||||
type Mode = "hide" | "extract";
|
||||
|
||||
type SelectedImage = {
|
||||
file: File;
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type GeneratedAsset = {
|
||||
url: string;
|
||||
fileName: string;
|
||||
size: number;
|
||||
width: number;
|
||||
height: number;
|
||||
messageBytes?: number;
|
||||
};
|
||||
|
||||
type UploaderProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
selectedImage: SelectedImage | null;
|
||||
onSelectFile: (file: File) => void;
|
||||
onClear: () => void;
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* تنسيق حجم البايتات إلى نصوص مقروءة للبشر
|
||||
*/
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} بايت`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} كيلوبايت`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} ميجابايت`;
|
||||
}
|
||||
|
||||
/**
|
||||
* استخراج الاسم الأساسي للملف بدون الامتداد
|
||||
*/
|
||||
function baseName(fileName: string) {
|
||||
return fileName.replace(/\.[^.]+$/, "") || "image";
|
||||
}
|
||||
|
||||
/**
|
||||
* إطلاق عملية التنزيل برمجياً لضمان توافقية جميع المتصفحات
|
||||
*/
|
||||
function triggerDownload(url: string, fileName: string) {
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.rel = "noopener";
|
||||
link.style.display = "none";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
/**
|
||||
* تحميل الصورة ككائن HTMLImageElement لاستخلاص أبعادها بدقة
|
||||
*/
|
||||
function loadImage(src: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error("تعذر تحميل الصورة المحددة."));
|
||||
image.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* إعداد وتحليل الصورة المحددة بواسطة المستخدم
|
||||
*/
|
||||
async function prepareSelectedImage(file: File): Promise<SelectedImage> {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
throw new Error("الملف المحدد ليس صورة صالحة.");
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
try {
|
||||
const image = await loadImage(url);
|
||||
return {
|
||||
file,
|
||||
url,
|
||||
width: image.naturalWidth,
|
||||
height: image.naturalHeight,
|
||||
};
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(url);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* إنشاء عنصر Canvas في الذاكرة لنقل بيانات الصورة إليه
|
||||
*/
|
||||
function createCanvasFromImage(image: HTMLImageElement) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
|
||||
const context = canvas.getContext("2d");
|
||||
|
||||
if (!context) {
|
||||
throw new Error("تعذر الوصول إلى محرك الرسم داخل المتصفح.");
|
||||
}
|
||||
|
||||
context.drawImage(image, 0, 0);
|
||||
return { canvas, context };
|
||||
}
|
||||
|
||||
/**
|
||||
* تحويل محتوى Canvas إلى Blob بصيغة PNG لضمان حفظ البتات بدون ضياع
|
||||
*/
|
||||
function canvasToPngBlob(canvas: HTMLCanvasElement) {
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error("تعذر إنشاء ملف الصورة النهائي."));
|
||||
return;
|
||||
}
|
||||
resolve(blob);
|
||||
},
|
||||
"image/png",
|
||||
1,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* مكون عرض الإحصائيات المصغرة (Pill Component)
|
||||
*/
|
||||
function StatPill({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/70 bg-white/80 px-4 py-3 shadow-sm shadow-slate-200/60 backdrop-blur">
|
||||
<div className="text-xs font-medium text-slate-500">{label}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-slate-900" dir="ltr">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* مكون رفع و اختيار الصور (Drag and Drop Uploader)
|
||||
*/
|
||||
function UploadCard({ title, description, selectedImage, onSelectFile, onClear, hint }: UploaderProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const inputId = `${title.replace(/\s+/g, "-")}-input`;
|
||||
|
||||
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) onSelectFile(file);
|
||||
event.currentTarget.value = "";
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent<HTMLLabelElement>) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(false);
|
||||
|
||||
const file = event.dataTransfer.files?.[0];
|
||||
if (file) onSelectFile(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-3xl border border-slate-200 bg-white/85 p-4 shadow-lg shadow-slate-200/50">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-slate-900">{title}</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-600">{description}</p>
|
||||
</div>
|
||||
{selectedImage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="rounded-full border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:border-rose-200 hover:text-rose-600"
|
||||
>
|
||||
إزالة الصورة
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-[1fr_220px]">
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
className={`group flex min-h-[220px] cursor-pointer flex-col items-center justify-center rounded-3xl border-2 border-dashed px-6 py-8 text-center transition ${
|
||||
isDragging
|
||||
? "border-violet-400 bg-violet-50"
|
||||
: "border-slate-200 bg-slate-50/80 hover:border-violet-300 hover:bg-violet-50/70"
|
||||
}`}
|
||||
>
|
||||
<input id={inputId} type="file" accept="image/*" className="hidden" onChange={handleChange} />
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-violet-500 to-indigo-600 text-white shadow-lg shadow-violet-200/80">
|
||||
<svg viewBox="0 0 24 24" className="h-7 w-7 fill-none stroke-current" strokeWidth="1.8">
|
||||
<path d="M12 16V5" strokeLinecap="round" />
|
||||
<path d="m7 10 5-5 5 5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M5 19h14" strokeLinecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="mt-4 text-lg font-semibold text-slate-900">
|
||||
{selectedImage ? "استبدال الصورة الحالية" : "اسحب الصورة هنا أو اضغط للاختيار"}
|
||||
</div>
|
||||
<div className="mt-2 max-w-md text-sm leading-6 text-slate-500">
|
||||
يدعم التطبيق صور PNG وJPG وWEBP وغيرها. تتم المعالجة محليًا داخل المتصفح فقط.
|
||||
</div>
|
||||
{hint ? <div className="mt-3 text-xs font-medium text-violet-700">{hint}</div> : null}
|
||||
</label>
|
||||
|
||||
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-slate-50/90">
|
||||
{selectedImage ? (
|
||||
<>
|
||||
<img
|
||||
src={selectedImage.url}
|
||||
alt={selectedImage.file.name}
|
||||
className="h-[220px] w-full bg-white object-contain"
|
||||
/>
|
||||
<div className="space-y-2 border-t border-slate-200 px-4 py-4 text-sm text-slate-600">
|
||||
<div className="font-medium text-slate-800">{selectedImage.file.name}</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span>الأبعاد</span>
|
||||
<span dir="ltr">
|
||||
{selectedImage.width} × {selectedImage.height}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span>الحجم</span>
|
||||
<span dir="ltr">{formatBytes(selectedImage.file.size)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span>النوع</span>
|
||||
<span dir="ltr">{selectedImage.file.type || "image/*"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full min-h-[220px] flex-col items-center justify-center gap-3 px-5 text-center text-sm text-slate-500">
|
||||
<div className="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<svg viewBox="0 0 24 24" className="h-8 w-8 fill-none stroke-slate-400" strokeWidth="1.7">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||
<path d="m7 15 3-3 3 3 4-5 2 3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<circle cx="9" cy="9" r="1.2" fill="currentColor" className="text-slate-400" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>ستظهر هنا معاينة الصورة بعد اختيارها.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* مكون عرض النتيجة وتنزيلها (Download and Preview Card)
|
||||
*/
|
||||
function DownloadCard({
|
||||
title,
|
||||
description,
|
||||
asset,
|
||||
metadata,
|
||||
buttonLabel,
|
||||
onDownload,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
asset: GeneratedAsset | null;
|
||||
metadata?: HiddenTextMetadata | ExtractedHiddenText | null;
|
||||
buttonLabel: string;
|
||||
onDownload: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-3xl border border-slate-200 bg-white/85 p-4 shadow-lg shadow-slate-200/50">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-slate-900">{title}</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-600">{description}</p>
|
||||
</div>
|
||||
|
||||
{asset ? (
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-[1fr_260px]">
|
||||
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-slate-50/80">
|
||||
<img src={asset.url} alt={asset.fileName} className="max-h-[340px] w-full bg-white object-contain" />
|
||||
</div>
|
||||
<div className="space-y-4 rounded-3xl border border-slate-200 bg-slate-50/80 p-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-slate-900">{asset.fileName}</div>
|
||||
<div className="mt-1 text-sm text-slate-500">تم إنشاء نسخة PNG جديدة جاهزة للتنزيل والاستخدام.</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
|
||||
<StatPill label="الحجم النهائي" value={formatBytes(asset.size)} />
|
||||
<StatPill label="الأبعاد" value={`${asset.width} × ${asset.height}`} />
|
||||
{typeof asset.messageBytes === "number" ? (
|
||||
<StatPill label="النص المضمّن" value={formatBytes(asset.messageBytes)} />
|
||||
) : null}
|
||||
{metadata ? <StatPill label="المساحة المستخدمة" value={formatBytes(metadata.usedBytes)} /> : null}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDownload}
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-2xl bg-slate-900 px-4 py-3 text-sm font-semibold text-white transition hover:bg-slate-800"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4 fill-none stroke-current" strokeWidth="1.8">
|
||||
<path d="M12 4v11" strokeLinecap="round" />
|
||||
<path d="m7 10 5 5 5-5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M5 20h14" strokeLinecap="round" />
|
||||
</svg>
|
||||
{buttonLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 rounded-3xl border border-dashed border-slate-200 bg-slate-50/80 px-6 py-12 text-center text-sm leading-7 text-slate-500">
|
||||
لا توجد نتيجة حتى الآن. عند تنفيذ العملية ستظهر هنا معاينة النسخة الجديدة مع زر التنزيل.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [mode, setMode] = useState<Mode>("hide");
|
||||
|
||||
// حالات الإخفاء (Embedding states)
|
||||
const [encodeImage, setEncodeImage] = useState<SelectedImage | null>(null);
|
||||
const [encodeText, setEncodeText] = useState("");
|
||||
const [encodeStatus, setEncodeStatus] = useState("");
|
||||
const [encodeError, setEncodeError] = useState("");
|
||||
const [encodeBusy, setEncodeBusy] = useState(false);
|
||||
const [encodedAsset, setEncodedAsset] = useState<GeneratedAsset | null>(null);
|
||||
const [encodedMetadata, setEncodedMetadata] = useState<HiddenTextMetadata | null>(null);
|
||||
|
||||
// حالات الاستخراج والتنظيف (Extracting & Cleaning states)
|
||||
const [decodeImage, setDecodeImage] = useState<SelectedImage | null>(null);
|
||||
const [decodeStatus, setDecodeStatus] = useState("");
|
||||
const [decodeError, setDecodeError] = useState("");
|
||||
const [decodeBusy, setDecodeBusy] = useState(false);
|
||||
const [cleanBusy, setCleanBusy] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [extracted, setExtracted] = useState<ExtractedHiddenText | null>(null);
|
||||
const [cleanedAsset, setCleanedAsset] = useState<GeneratedAsset | null>(null);
|
||||
|
||||
// تنظيف عناوين الذاكرة عند تغيير الصور
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (encodeImage?.url) URL.revokeObjectURL(encodeImage.url);
|
||||
};
|
||||
}, [encodeImage]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (decodeImage?.url) URL.revokeObjectURL(decodeImage.url);
|
||||
};
|
||||
}, [decodeImage]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (encodedAsset?.url) URL.revokeObjectURL(encodedAsset.url);
|
||||
};
|
||||
}, [encodedAsset]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (cleanedAsset?.url) URL.revokeObjectURL(cleanedAsset.url);
|
||||
};
|
||||
}, [cleanedAsset]);
|
||||
|
||||
// مؤقت إعادة تعيين حالة النسخ
|
||||
useEffect(() => {
|
||||
if (!copied) return;
|
||||
const timeout = window.setTimeout(() => setCopied(false), 1800);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [copied]);
|
||||
|
||||
const messageBytes = useMemo(() => new TextEncoder().encode(encodeText).length, [encodeText]);
|
||||
const capacityBytes = encodeImage ? getImageCapacityBytes(encodeImage.width, encodeImage.height) : 0;
|
||||
const usagePercent = capacityBytes > 0 ? Math.min(100, (messageBytes / capacityBytes) * 100) : 0;
|
||||
|
||||
const handleEncodeImageSelect = async (file: File) => {
|
||||
setEncodeError("");
|
||||
setEncodeStatus("");
|
||||
setEncodedAsset(null);
|
||||
setEncodedMetadata(null);
|
||||
|
||||
try {
|
||||
const selected = await prepareSelectedImage(file);
|
||||
setEncodeImage(selected);
|
||||
} catch (error) {
|
||||
setEncodeError(error instanceof Error ? error.message : "حدث خطأ أثناء تحميل الصورة.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDecodeImageSelect = async (file: File) => {
|
||||
setDecodeError("");
|
||||
setDecodeStatus("");
|
||||
setExtracted(null);
|
||||
setCleanedAsset(null);
|
||||
setCopied(false);
|
||||
|
||||
try {
|
||||
const selected = await prepareSelectedImage(file);
|
||||
setDecodeImage(selected);
|
||||
} catch (error) {
|
||||
setDecodeError(error instanceof Error ? error.message : "حدث خطأ أثناء تحميل الصورة.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEncode = async () => {
|
||||
if (!encodeImage) {
|
||||
setEncodeError("اختر صورة أولًا قبل محاولة تضمين النص.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!encodeText) {
|
||||
setEncodeError("أدخل النص الذي تريد تضمينه داخل الصورة.");
|
||||
return;
|
||||
}
|
||||
|
||||
setEncodeBusy(true);
|
||||
setEncodeError("");
|
||||
setEncodeStatus("جارٍ تضمين النص داخل الصورة...");
|
||||
|
||||
try {
|
||||
const image = await loadImage(encodeImage.url);
|
||||
const { canvas, context } = createCanvasFromImage(image);
|
||||
const source = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const { imageData, metadata } = embedTextInImageData(source, encodeText);
|
||||
|
||||
context.putImageData(imageData, 0, 0);
|
||||
const blob = await canvasToPngBlob(canvas);
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
setEncodedMetadata(metadata);
|
||||
setEncodedAsset({
|
||||
url,
|
||||
fileName: `${baseName(encodeImage.file.name)}-hidden.png`,
|
||||
size: blob.size,
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
messageBytes: metadata.messageBytes,
|
||||
});
|
||||
setEncodeStatus("تم إنشاء نسخة PNG جديدة تحمل النص المخفي بنجاح.");
|
||||
} catch (error) {
|
||||
setEncodeError(error instanceof Error ? error.message : "تعذر تضمين النص داخل الصورة.");
|
||||
setEncodeStatus("");
|
||||
} finally {
|
||||
setEncodeBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExtract = async () => {
|
||||
if (!decodeImage) {
|
||||
setDecodeError("اختر صورة أولًا لاستخراج النص المخفي.");
|
||||
return;
|
||||
}
|
||||
|
||||
setDecodeBusy(true);
|
||||
setDecodeError("");
|
||||
setDecodeStatus("جارٍ فحص الصورة واستخراج النص...");
|
||||
|
||||
try {
|
||||
const image = await loadImage(decodeImage.url);
|
||||
const { canvas, context } = createCanvasFromImage(image);
|
||||
const source = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const result = extractHiddenTextFromImageData(source);
|
||||
|
||||
setExtracted(result);
|
||||
setDecodeStatus("تم العثور على النص المخفي واستخراجه بنجاح.");
|
||||
} catch (error) {
|
||||
setExtracted(null);
|
||||
setDecodeError(error instanceof Error ? error.message : "تعذر استخراج النص من الصورة.");
|
||||
setDecodeStatus("");
|
||||
} finally {
|
||||
setDecodeBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClean = async () => {
|
||||
if (!decodeImage) {
|
||||
setDecodeError("اختر صورة أولًا ثم قم بتنظيفها من النص المخفي.");
|
||||
return;
|
||||
}
|
||||
|
||||
setCleanBusy(true);
|
||||
setDecodeError("");
|
||||
setDecodeStatus("جارٍ إنشاء نسخة نظيفة من الصورة...");
|
||||
|
||||
try {
|
||||
const image = await loadImage(decodeImage.url);
|
||||
const { canvas, context } = createCanvasFromImage(image);
|
||||
const source = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const { imageData, metadata } = removeHiddenTextFromImageData(source);
|
||||
|
||||
context.putImageData(imageData, 0, 0);
|
||||
const blob = await canvasToPngBlob(canvas);
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
setExtracted(metadata);
|
||||
setCleanedAsset({
|
||||
url,
|
||||
fileName: `${baseName(decodeImage.file.name)}-clean.png`,
|
||||
size: blob.size,
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
});
|
||||
setDecodeStatus("تم إنشاء نسخة نظيفة بدون النص المخفي. يمكنك تنزيلها الآن.");
|
||||
} catch (error) {
|
||||
setCleanedAsset(null);
|
||||
setDecodeError(error instanceof Error ? error.message : "تعذر تنظيف الصورة.");
|
||||
setDecodeStatus("");
|
||||
} finally {
|
||||
setCleanBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!extracted?.message) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(extracted.message);
|
||||
setCopied(true);
|
||||
} catch {
|
||||
setDecodeError("تعذر نسخ النص تلقائيًا. يمكنك نسخه يدويًا من مربع النص.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
dir="rtl"
|
||||
className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(124,58,237,0.16),_transparent_34%),linear-gradient(180deg,_#f8fafc,_#eef2ff_45%,_#ffffff)] text-slate-900"
|
||||
>
|
||||
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<header className="overflow-hidden rounded-[2rem] border border-white/70 bg-white/80 p-6 shadow-2xl shadow-violet-100/80 backdrop-blur sm:p-8">
|
||||
<div className="grid gap-8 lg:grid-cols-[1.3fr_0.9fr] lg:items-center">
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2.5 rounded-full border border-violet-200 bg-violet-50 px-4 py-2 text-sm font-semibold text-violet-700">
|
||||
<span className="flex h-3 w-3 items-center justify-center rounded-full bg-violet-500 text-[8px] text-white">
|
||||
S
|
||||
</span>
|
||||
<span>ستيجانو بكسل (SteganoPixel)</span>
|
||||
</div>
|
||||
<h1 className="mt-5 text-3xl font-black tracking-tight text-slate-950 sm:text-5xl">
|
||||
ضع النص داخل أي صورة واستخرجه أو أزله من نفس التطبيق
|
||||
</h1>
|
||||
<p className="mt-4 max-w-3xl text-base leading-8 text-slate-600 sm:text-lg">
|
||||
تطبيق ويب عربي متقدم لإخفاء النصوص السرية داخل الصور الرقمية بأسلوب غير ملحوظ بصرياً (LSB Steganography)،
|
||||
مع إمكانية قراءة النصوص المضمنة لاحقاً أو محوها بالكامل لتأمين خصوصيتك. تعمل جميع خوارزميات التشفير
|
||||
مباشرة داخل متصفحك دون إرسال أي ملفات إلى خوادم خارجية.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3 text-sm text-slate-600">
|
||||
<div className="rounded-full border border-slate-200 bg-slate-50 px-4 py-2">
|
||||
حفظ بصيغة PNG Lossless للحفاظ على البتات الدقيقة
|
||||
</div>
|
||||
<div className="rounded-full border border-slate-200 bg-slate-50 px-4 py-2">
|
||||
يدعم التضمين · الاستخراج · إزالة الدمج (التنظيف)
|
||||
</div>
|
||||
<div className="rounded-full border border-slate-200 bg-slate-50 px-4 py-2">
|
||||
خصوصية تامة 100% بدون اتصال بالإنترنت
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<StatPill label="خوارزمية الدمج" value="3-Channel LSB" />
|
||||
<StatPill label="بيئة المعالجة" value="Client-Side Browser" />
|
||||
<StatPill label="دعم التصدير" value="PNG TrueColor (Lossless)" />
|
||||
<StatPill label="مستوى الأمان" value="Zero Server Uploads" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mt-8 grid gap-6 xl:grid-cols-[1.55fr_0.85fr]">
|
||||
<section className="rounded-[2rem] border border-white/70 bg-white/80 p-4 shadow-2xl shadow-slate-200/60 backdrop-blur sm:p-6">
|
||||
<div className="flex flex-wrap gap-3 rounded-3xl bg-slate-100/80 p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("hide")}
|
||||
className={`flex-1 rounded-2xl px-4 py-3 text-sm font-semibold transition sm:flex-none sm:min-w-[180px] ${
|
||||
mode === "hide"
|
||||
? "bg-slate-900 text-white shadow-lg shadow-slate-300"
|
||||
: "bg-transparent text-slate-600 hover:bg-white"
|
||||
}`}
|
||||
>
|
||||
تضمين نص سري داخل صورة
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("extract")}
|
||||
className={`flex-1 rounded-2xl px-4 py-3 text-sm font-semibold transition sm:flex-none sm:min-w-[180px] ${
|
||||
mode === "extract"
|
||||
? "bg-slate-900 text-white shadow-lg shadow-slate-300"
|
||||
: "bg-transparent text-slate-600 hover:bg-white"
|
||||
}`}
|
||||
>
|
||||
استخراج النص أو تنظيف الصورة
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === "hide" ? (
|
||||
<div className="mt-6 space-y-6">
|
||||
<UploadCard
|
||||
title="1) اختر الصورة الأصلية (الناقل)"
|
||||
description="ارفع الصورة التي تريد دمج النص داخلها. سيقوم التطبيق بقراءة أبعادها وحساب السعة التخزينية المتاحة لتضمين رسالتك."
|
||||
selectedImage={encodeImage}
|
||||
onSelectFile={(file) => {
|
||||
void handleEncodeImageSelect(file);
|
||||
}}
|
||||
onClear={() => {
|
||||
setEncodeImage(null);
|
||||
setEncodedAsset(null);
|
||||
setEncodedMetadata(null);
|
||||
setEncodeStatus("");
|
||||
setEncodeError("");
|
||||
}}
|
||||
hint={
|
||||
encodeImage?.file.type.includes("jpeg")
|
||||
? "تنبيه: سيتم تحويل صورتك الـ JPG إلى PNG عند الحفظ لضمان عدم تلف البيانات المخفية بسبب ضغط الـ JPG الضياعي."
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="rounded-3xl border border-slate-200 bg-white/85 p-4 shadow-lg shadow-slate-200/50">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-slate-900">2) اكتب النص السري المراد إخفاؤه</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-600">
|
||||
تُشفّر الرسالة رقمياً وتُوزّع بانتظام على البتات الأقل أهمية للألوان (RGB)، ما يحافظ على النقاء
|
||||
البصري.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-2 text-sm text-slate-600"
|
||||
dir="ltr"
|
||||
>
|
||||
{messageBytes} / {capacityBytes || 0} bytes
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={encodeText}
|
||||
onChange={(event) => setEncodeText(event.target.value)}
|
||||
placeholder="اكتب رسالتك السرية هنا..."
|
||||
className="mt-4 min-h-[180px] w-full rounded-3xl border border-slate-200 bg-slate-50/80 px-4 py-4 text-sm leading-7 text-slate-800 outline-none transition placeholder:text-slate-400 focus:border-violet-400 focus:bg-white focus:ring-4 focus:ring-violet-100"
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex items-center justify-between text-xs font-medium text-slate-500">
|
||||
<span>نسبة استهلاك السعة التخزينية</span>
|
||||
<span dir="ltr">{usagePercent.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="h-3 overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
messageBytes > capacityBytes && capacityBytes > 0
|
||||
? "bg-rose-500"
|
||||
: "bg-gradient-to-r from-violet-500 to-indigo-600"
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, usagePercent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<StatPill label="السعة الكلية المتاحة" value={formatBytes(capacityBytes)} />
|
||||
<StatPill label="حجم الرسالة الحالي" value={formatBytes(messageBytes)} />
|
||||
<StatPill label="صيغة التصدير" value="PNG Lossless" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={encodeBusy}
|
||||
onClick={() => {
|
||||
void handleEncode();
|
||||
}}
|
||||
className="inline-flex items-center justify-center rounded-2xl bg-violet-600 px-5 py-3 text-sm font-semibold text-white shadow-lg shadow-violet-200 transition hover:bg-violet-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{encodeBusy ? "جارٍ التشفير والدمج..." : "إنشاء الصورة المضمّنة"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEncodeText("");
|
||||
setEncodeStatus("");
|
||||
setEncodeError("");
|
||||
}}
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-slate-200 bg-white px-5 py-3 text-sm font-semibold text-slate-700 transition hover:border-slate-300 hover:bg-slate-50"
|
||||
>
|
||||
مسح النص
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{encodeStatus ? (
|
||||
<div className="mt-4 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-700">
|
||||
{encodeStatus}
|
||||
</div>
|
||||
) : null}
|
||||
{encodeError ? (
|
||||
<div className="mt-4 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-medium text-rose-700">
|
||||
{encodeError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DownloadCard
|
||||
title="3) النتيجة النهائية الجاهزة"
|
||||
description="انقر أدناه لتنزيل نسختك الجديدة من الصورة. تحتوي هذه الصورة الآن على النص السري المضمن وبأعلى جودة بصرية."
|
||||
asset={encodedAsset}
|
||||
metadata={encodedMetadata}
|
||||
buttonLabel="تنزيل الصورة المضمّنة (PNG)"
|
||||
onDownload={() => {
|
||||
if (encodedAsset) triggerDownload(encodedAsset.url, encodedAsset.fileName);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 space-y-6">
|
||||
<UploadCard
|
||||
title="1) اختر الصورة المراد فحصها أو تنظيفها"
|
||||
description="ارفع الصورة المشتبه باحتوائها على نص مخفي. يمكنك استخراج وقراءة محتواها، أو محو البيانات السرية لإنتاج نسخة نظيفة."
|
||||
selectedImage={decodeImage}
|
||||
onSelectFile={(file) => {
|
||||
void handleDecodeImageSelect(file);
|
||||
}}
|
||||
onClear={() => {
|
||||
setDecodeImage(null);
|
||||
setExtracted(null);
|
||||
setCleanedAsset(null);
|
||||
setDecodeStatus("");
|
||||
setDecodeError("");
|
||||
setCopied(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="rounded-3xl border border-slate-200 bg-white/85 p-4 shadow-lg shadow-slate-200/50">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={decodeBusy}
|
||||
onClick={() => {
|
||||
void handleExtract();
|
||||
}}
|
||||
className="inline-flex items-center justify-center rounded-2xl bg-indigo-600 px-5 py-3 text-sm font-semibold text-white shadow-lg shadow-indigo-200 transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{decodeBusy ? "جارٍ الفحص والاستخراج..." : "استخراج وقراءة النص السري"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={cleanBusy}
|
||||
onClick={() => {
|
||||
void handleClean();
|
||||
}}
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-slate-200 bg-white px-5 py-3 text-sm font-semibold text-slate-700 transition hover:border-slate-300 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{cleanBusy ? "جارٍ محو البيانات..." : "إنشاء نسخة نظيفة خالية من النصوص"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{decodeStatus ? (
|
||||
<div className="mt-4 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-700">
|
||||
{decodeStatus}
|
||||
</div>
|
||||
) : null}
|
||||
{decodeError ? (
|
||||
<div className="mt-4 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-medium text-rose-700">
|
||||
{decodeError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-5 rounded-3xl border border-slate-200 bg-slate-50/80 p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-slate-900">محتوى النص المستخرج</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-600">
|
||||
إذا تم العثور على حزمة بيانات صحيحة في الصورة، سيتم تفكيكها وعرضها هنا.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!extracted?.message}
|
||||
onClick={() => {
|
||||
void handleCopy();
|
||||
}}
|
||||
className="rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-300 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{copied ? "تم النسخ بنجاح" : "نسخ النص"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
readOnly
|
||||
value={extracted?.message ?? ""}
|
||||
placeholder="بعد رفع الصورة والنقر على «استخراج وقراءة النص السري»، ستظهر الرسالة المستخرجة هنا إن وجدت."
|
||||
className="mt-4 min-h-[180px] w-full rounded-3xl border border-slate-200 bg-white px-4 py-4 text-sm leading-7 text-slate-800 outline-none placeholder:text-slate-400"
|
||||
/>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<StatPill label="حجم النص المستخرج" value={formatBytes(extracted?.messageBytes ?? 0)} />
|
||||
<StatPill label="حجم الحزمة المشفرة" value={formatBytes(extracted?.usedBytes ?? 0)} />
|
||||
<StatPill label="أبعاد الصورة" value={extracted ? `${extracted.width} × ${extracted.height}` : "—"} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DownloadCard
|
||||
title="2) النسخة النظيفة المُعقمة"
|
||||
description="إذا قمت بتنظيف الصورة، يمكنك تنزيل النسخة الجديدة أدناه. تم تصفير جميع بتات البيانات المضمنة فيها لضمان عدم إمكانية استخراجها مجدداً."
|
||||
asset={cleanedAsset}
|
||||
metadata={extracted}
|
||||
buttonLabel="تنزيل الصورة النظيفة (PNG)"
|
||||
onDownload={() => {
|
||||
if (cleanedAsset) triggerDownload(cleanedAsset.url, cleanedAsset.fileName);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="space-y-6">
|
||||
<div className="rounded-[2rem] border border-white/70 bg-white/80 p-6 shadow-2xl shadow-slate-200/60 backdrop-blur">
|
||||
<h2 className="text-xl font-bold text-slate-950">آلية عمل التطبيق (LSB Architecture)</h2>
|
||||
<div className="mt-5 space-y-4">
|
||||
<div className="rounded-3xl border border-slate-200 bg-slate-50/80 p-4">
|
||||
<div className="text-sm font-bold text-slate-900">1. التشفير والتضمين (Embedding)</div>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-600">
|
||||
يحول التطبيق حروفك إلى بايتات رقمية، ويضيف إليها توقيعاً سحرياً (`TXTIMG1`) وطول الرسالة، ثم يضعها
|
||||
بانتظام في البت الأقل أهمية (LSB) لقنوات الألوان (RGB) لكل بكسل، ما يحافظ على دقة الألوان الأصلية.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-slate-200 bg-slate-50/80 p-4">
|
||||
<div className="text-sm font-bold text-slate-900">2. القراءة والاستخراج (Extracting)</div>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-600">
|
||||
عند الفحص، يقرأ المحرك البكسلات بالترتيب باحثاً عن التوقيع السحري. إذا تطابق التوقيع، يقرأ طول
|
||||
الرسالة ويستخرج البتات المتبقية ليعيد تكوين النص الأصلي بدقة متناهية.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-slate-200 bg-slate-50/80 p-4">
|
||||
<div className="text-sm font-bold text-slate-900">3. محو البيانات (Sanitization)</div>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-600">
|
||||
أداة التنظيف تقوم بحساب البتات التي تم استخدامها في الصورة وتصفرها تماماً (`& 0xFE`)، ما يعيد
|
||||
البكسلات لحالة عشوائية طبيعية ويمنع أي محاولة مستقبلية لاستعادة النص.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[2rem] border border-white/70 bg-white/80 p-6 shadow-2xl shadow-violet-100/70 backdrop-blur">
|
||||
<h2 className="text-xl font-bold text-slate-950">ضوابط الحفاظ على الجودة</h2>
|
||||
<ul className="mt-4 space-y-3 text-sm leading-7 text-slate-600">
|
||||
<li className="rounded-2xl border border-slate-200 bg-slate-50/80 px-4 py-3">
|
||||
يتم تصدير الصور حصراً بصيغة <span className="font-semibold text-slate-900">PNG Lossless</span>.
|
||||
حفظها كـ JPG أو إرسالها عبر الواتساب/تليجرام بوضعية الضغط العادي يؤدي إلى تدمير البتات المخفية.
|
||||
</li>
|
||||
<li className="rounded-2xl border border-slate-200 bg-slate-50/80 px-4 py-3">
|
||||
كلما كانت دقة الصورة وأبعادها أكبر، زادت سعتها التخزينية لاحتواء نصوص سرية أطول ومقالات كاملة.
|
||||
</li>
|
||||
<li className="rounded-2xl border border-slate-200 bg-slate-50/80 px-4 py-3">
|
||||
التلاعب اللوني لا يتجاوز 1/255 من قيمة كل لون، وهو تغيير مستحيل الإدراك بالعين البشرية المجردة.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[2rem] border border-white/70 bg-slate-950 p-6 text-white shadow-2xl shadow-slate-300/60">
|
||||
<h2 className="text-xl font-bold">نصيحة أمنية واحترافية</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">
|
||||
لتبادل الرسائل السرية بنجاح، أرسل الصورة النهائية كـ «ملف» (File / Document) في برامج المحادثة أو البريد
|
||||
الإلكتروني، لضمان عدم قيام تلك المنصات بضغط الصورة وإتلاف البيانات.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
src/cn.ts
Normal file
6
src/cn.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
45
src/index.css
Normal file
45
src/index.css
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* الأنماط العامة وإعدادات الخطوط (Global Styles & Typography) - SteganoPixel
|
||||
* =========================================================================
|
||||
* الجزء الأول من الشرح الهيكلي:
|
||||
* يوفر هذا الملف التهيئة الأساسية للمشروع بالاعتماد على إطار عمل Tailwind CSS (عبر التوجيه @import "tailwindcss").
|
||||
* نقوم بضبط المتغيرات الجذرية (:root) لتهيئة بيئة العرض التلقائية، وتحديد عائلة الخطوط الافتراضية (Inter و System-UI)
|
||||
* لضمان عرض النصوص العربية والإنجليزية بأبهى حلة وبأقصى درجات الوضوح على جميع أنظمة التشغيل (Windows, macOS, iOS, Android).
|
||||
*
|
||||
* الجزء الثاني من الشرح الهيكلي:
|
||||
* يعمل هذا الملف كطبقة تنسيق أساسية تمنع التفاوت البصري بين المتصفحات (Cross-Browser Normalization). نقوم بتصفير
|
||||
* الهوامش الافتراضية (margin: 0)، وضبط السلوك التفاعلي للتمرير (scroll-behavior)، وتحديد الحد الأدنى لعرض الشاشة (320px)
|
||||
* لضمان عدم انكسار التخطيط الشبكي (Grid/Flex) على الهواتف الذكية القديمة أو الشاشات الضيقة، مع دعم العرض التلقائي للصور.
|
||||
*/
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
24
src/main.tsx
Normal file
24
src/main.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* نقطة الإطلاق الجذرية (Application Bootstrapper) - SteganoPixel
|
||||
* =================================================================
|
||||
* الجزء الأول من الشرح الهيكلي:
|
||||
* يقوم هذا الملف بدور المشغل الأساسي (Bootstrapper) لتطبيق React. يعتمد على أحدث واجهات البرمجة في React 18+ (`createRoot`)
|
||||
* لتفعيل آلية التصيير المتزامن (Concurrent Rendering). كما يغلف شجرة المكونات بالكامل داخل مكون `<StrictMode>` الذي
|
||||
* يساعد المطورين خلال بيئة التطوير على اكتشاف أي مشاكل خفية في دورة حياة المكونات، أو استخدام أدوات قديمة، أو آثار جانبية غير مرغوبة.
|
||||
*
|
||||
* الجزء الثاني من الشرح الهيكلي:
|
||||
* يعمل هذا الملف كجسر يربط بين المستند الفعلي (`index.html`) ومنطق التطبيق المرئي (`App.tsx`). يقوم باستيراد ملف الأنماط
|
||||
* الأساسي (`index.css`) لضمان حقن قواعد Tailwind CSS الجذرية قبل تصيير أي عنصر. إذا تعذر العثور على عنصر `#root` في
|
||||
* المستند، سيتوقف المحرك فوراً، ما يمثل آلية حماية ضد تشغيل التطبيق في بيئة HTML غير متوافقة.
|
||||
*/
|
||||
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
237
src/steganography.ts
Normal file
237
src/steganography.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* محرك الإخفاء الرقمي (Steganography Engine) - SteganoPixel
|
||||
* ==========================================================
|
||||
* الجزء الأول من الشرح الهيكلي:
|
||||
* يعمل هذا الملف كمحرك أساسي لمعالجة الصور والتلاعب الدقيق في البتات (Bitwise Operations) على مستوى مصفوفة البكسلات (Uint8ClampedArray).
|
||||
* يعتمد المنطق على تقنية Least Significant Bit (LSB)، حيث يتم استبدال البت الأقل أهمية في القنوات اللونية (أحمر، أخضر، أزرق) بالبتات المكونة للنص المراد إخفاؤه.
|
||||
* هذا يضمن عدم تأثر الجودة البصرية للصورة الأصلية، حيث أن التغير في قيمة أي قناة لونية لا يتجاوز درجة واحدة (±1)، وهو ما تعجز العين البشرية عن تمييزه.
|
||||
*
|
||||
* الجزء الثاني من الشرح الهيكلي:
|
||||
* يتفاعل هذا المحرك بشكل مباشر مع واجهة المستخدم في `App.tsx` من خلال ثلاث وظائف رئيسية: `embedTextInImageData` لتضمين النص،
|
||||
* و `extractHiddenTextFromImageData` لقراءة النص وتفكيك التشفير، و `removeHiddenTextFromImageData` لتنظيف الصورة وتصفير البتات المستخدمة.
|
||||
* يجب على المطورين مراعاة أن هذا المحرك يفترض أن مصفوفة البيانات (ImageData) غير مضغوطة ضغطاً ضياعياً (Lossless) أثناء دورة حياتها،
|
||||
* لذا نستخدم دائماً صيغة PNG عند التصدير لضمان عدم ضياع البتات الدقيقة.
|
||||
*/
|
||||
|
||||
const MAGIC_TEXT = "TXTIMG1";
|
||||
const MAGIC_BYTES = new TextEncoder().encode(MAGIC_TEXT);
|
||||
const LENGTH_BYTES = 4;
|
||||
const HEADER_BYTES = MAGIC_BYTES.length + LENGTH_BYTES;
|
||||
const TEXT_ENCODER = new TextEncoder();
|
||||
const TEXT_DECODER = new TextDecoder();
|
||||
|
||||
export type HiddenTextMetadata = {
|
||||
width: number;
|
||||
height: number;
|
||||
capacityBytes: number;
|
||||
usedBytes: number;
|
||||
messageBytes: number;
|
||||
};
|
||||
|
||||
export type ExtractedHiddenText = HiddenTextMetadata & {
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* حساب السعة الإجمالية للناقل (الصورة) بالبايت
|
||||
*/
|
||||
function getTotalCarrierBytes(width: number, height: number) {
|
||||
// نستخدم 3 قنوات لونية (RGB) لكل بكسل، متجاهلين قناة الشفافية (Alpha) للحفاظ على المظهر.
|
||||
return Math.floor((width * height * 3) / 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* تحديد فهرس القناة اللونية الدقيق لمؤشر بت معين
|
||||
*/
|
||||
function channelIndexForBit(bitIndex: number) {
|
||||
const pixelIndex = Math.floor(bitIndex / 3);
|
||||
const channelOffset = bitIndex % 3;
|
||||
// يتم تخزين ImageData بتنسيق RGBA، لذا نضرب في 4 للوصول للبكسل، ونضيف إزاحة القناة (0=R, 1=G, 2=B)
|
||||
return pixelIndex * 4 + channelOffset;
|
||||
}
|
||||
|
||||
/**
|
||||
* تعيين البت الأقل أهمية (LSB) في قناة لونية محددة
|
||||
*/
|
||||
function setBit(data: Uint8ClampedArray, bitIndex: number, bit: number) {
|
||||
const channelIndex = channelIndexForBit(bitIndex);
|
||||
// 0xfe == 11111110 (تصفير البت الأخير ثم دمجه مع البت الجديد)
|
||||
data[channelIndex] = (data[channelIndex] & 0xfe) | bit;
|
||||
}
|
||||
|
||||
/**
|
||||
* قراءة البت الأقل أهمية (LSB) من قناة لونية محددة
|
||||
*/
|
||||
function getBit(data: Uint8ClampedArray, bitIndex: number) {
|
||||
return data[channelIndexForBit(bitIndex)] & 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* كتابة مصفوفة من البايتات داخل البتات الأقل أهمية في الصورة
|
||||
*/
|
||||
function writeBytes(data: Uint8ClampedArray, startBit: number, bytes: Uint8Array) {
|
||||
let bitCursor = startBit;
|
||||
|
||||
for (const byte of bytes) {
|
||||
for (let shift = 7; shift >= 0; shift -= 1) {
|
||||
setBit(data, bitCursor, (byte >> shift) & 1);
|
||||
bitCursor += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return bitCursor;
|
||||
}
|
||||
|
||||
/**
|
||||
* قراءة عدد محدد من البايتات من البتات الأقل أهمية في الصورة
|
||||
*/
|
||||
function readBytes(data: Uint8ClampedArray, startBit: number, byteCount: number) {
|
||||
const bytes = new Uint8Array(byteCount);
|
||||
let bitCursor = startBit;
|
||||
|
||||
for (let index = 0; index < byteCount; index += 1) {
|
||||
let value = 0;
|
||||
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
value = (value << 1) | getBit(data, bitCursor);
|
||||
bitCursor += 1;
|
||||
}
|
||||
|
||||
bytes[index] = value;
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* التحقق من مطابقة مصفوفتين من البايتات (للتأكد من التوقيع السحري Magic Bytes)
|
||||
*/
|
||||
function bytesMatch(left: Uint8Array, right: Uint8Array) {
|
||||
if (left.length !== right.length) return false;
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index] !== right[index]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* تحويل طول الرسالة (رقم صحيح 32-بت) إلى 4 بايتات
|
||||
*/
|
||||
function lengthToBytes(length: number) {
|
||||
const bytes = new Uint8Array(LENGTH_BYTES);
|
||||
new DataView(bytes.buffer).setUint32(0, length);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* تحويل 4 بايتات إلى رقم صحيح 32-بت يعبر عن طول الرسالة
|
||||
*/
|
||||
function bytesToLength(bytes: Uint8Array) {
|
||||
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* استنساخ بيانات الصورة لضمان عدم التعديل المباشر على الأصل (Immutability)
|
||||
*/
|
||||
function cloneImageData(imageData: ImageData) {
|
||||
return new ImageData(new Uint8ClampedArray(imageData.data), imageData.width, imageData.height);
|
||||
}
|
||||
|
||||
/**
|
||||
* حساب السعة التخزينية الصافية للصورة بالبايت (بعد استقطاع التوقيع والرأس)
|
||||
*/
|
||||
export function getImageCapacityBytes(width: number, height: number) {
|
||||
return Math.max(0, getTotalCarrierBytes(width, height) - HEADER_BYTES);
|
||||
}
|
||||
|
||||
/**
|
||||
* تضمين نص سري داخل كائن ImageData وإرجاع النسخة المعدلة مع البيانات الوصفية
|
||||
*/
|
||||
export function embedTextInImageData(imageData: ImageData, text: string) {
|
||||
const payload = TEXT_ENCODER.encode(text);
|
||||
const capacityBytes = getImageCapacityBytes(imageData.width, imageData.height);
|
||||
|
||||
if (payload.length > capacityBytes) {
|
||||
throw new Error("النص أطول من السعة المتاحة داخل هذه الصورة.");
|
||||
}
|
||||
|
||||
const output = cloneImageData(imageData);
|
||||
const packet = new Uint8Array(HEADER_BYTES + payload.length);
|
||||
|
||||
// بناء حزمة البيانات: [Magic Bytes] + [Payload Length (4 bytes)] + [Payload]
|
||||
packet.set(MAGIC_BYTES, 0);
|
||||
packet.set(lengthToBytes(payload.length), MAGIC_BYTES.length);
|
||||
packet.set(payload, HEADER_BYTES);
|
||||
|
||||
writeBytes(output.data, 0, packet);
|
||||
|
||||
return {
|
||||
imageData: output,
|
||||
metadata: {
|
||||
width: imageData.width,
|
||||
height: imageData.height,
|
||||
capacityBytes,
|
||||
usedBytes: packet.length,
|
||||
messageBytes: payload.length,
|
||||
} satisfies HiddenTextMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* فحص ImageData واستخراج النص السري المضمن مع البيانات الوصفية
|
||||
*/
|
||||
export function extractHiddenTextFromImageData(imageData: ImageData): ExtractedHiddenText {
|
||||
const totalCarrierBytes = getTotalCarrierBytes(imageData.width, imageData.height);
|
||||
|
||||
if (totalCarrierBytes < HEADER_BYTES) {
|
||||
throw new Error("أبعاد الصورة صغيرة جدًا ولا تحتوي على مساحة كافية للبيانات المخفية.");
|
||||
}
|
||||
|
||||
// قراءة التوقيع السحري للتأكد من وجود بيانات مضمنة
|
||||
const magic = readBytes(imageData.data, 0, MAGIC_BYTES.length);
|
||||
|
||||
if (!bytesMatch(magic, MAGIC_BYTES)) {
|
||||
throw new Error("لم يتم العثور على نص مخفي داخل هذه الصورة.");
|
||||
}
|
||||
|
||||
// قراءة طول الرسالة
|
||||
const lengthBytes = readBytes(imageData.data, MAGIC_BYTES.length * 8, LENGTH_BYTES);
|
||||
const messageBytes = bytesToLength(lengthBytes);
|
||||
const capacityBytes = getImageCapacityBytes(imageData.width, imageData.height);
|
||||
|
||||
if (messageBytes > capacityBytes) {
|
||||
throw new Error("البيانات المخفية داخل الصورة تبدو تالفة أو غير مكتملة.");
|
||||
}
|
||||
|
||||
// قراءة جسم الرسالة
|
||||
const payload = readBytes(imageData.data, HEADER_BYTES * 8, messageBytes);
|
||||
|
||||
return {
|
||||
width: imageData.width,
|
||||
height: imageData.height,
|
||||
capacityBytes,
|
||||
usedBytes: HEADER_BYTES + messageBytes,
|
||||
messageBytes,
|
||||
message: TEXT_DECODER.decode(payload),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* تنظيف الصورة وإزالة أي نص مخفي منها عبر تصفير البتات المستخدمة
|
||||
*/
|
||||
export function removeHiddenTextFromImageData(imageData: ImageData) {
|
||||
const extracted = extractHiddenTextFromImageData(imageData);
|
||||
const output = cloneImageData(imageData);
|
||||
const bitsToClear = extracted.usedBytes * 8;
|
||||
|
||||
// مسح البتات الأقل أهمية التي كانت تحمل البيانات
|
||||
for (let bitIndex = 0; bitIndex < bitsToClear; bitIndex += 1) {
|
||||
const channelIndex = channelIndexForBit(bitIndex);
|
||||
output.data[channelIndex] = output.data[channelIndex] & 0xfe;
|
||||
}
|
||||
|
||||
return {
|
||||
imageData: output,
|
||||
metadata: extracted,
|
||||
};
|
||||
}
|
||||
المرجع في مشكلة جديدة
حظر مستخدم