Spaces:
Running
Running
Katip Yerel demo
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +35 -0
- README.md +4 -3
- app.js +262 -124
- config.js +1 -1
- engine.js +313 -11
- hero/dilekce_maskeli.png +3 -0
- hero/dilekce_orijinal.png +3 -0
- index.html +111 -102
- katip_py.zip +2 -2
- models/registry.json +6 -6
- paddle_ocr.js +145 -0
- samples/aydinlatma_01.pdf +2 -2
- samples/aydinlatma_01.scan.pdf +2 -2
- samples/aydinlatma_02.docx +0 -0
- samples/aydinlatma_02.pdf +0 -0
- samples/aydinlatma_02.txt +14 -0
- samples/aydinlatma_02.udf +0 -0
- samples/aydinlatma_03.docx +0 -0
- samples/aydinlatma_03.pdf +0 -0
- samples/aydinlatma_03.png +3 -0
- samples/aydinlatma_03.scan.pdf +3 -0
- samples/aydinlatma_03.txt +14 -0
- samples/aydinlatma_03.udf +0 -0
- samples/aydinlatma_04.docx +0 -0
- samples/aydinlatma_04.pdf +0 -0
- samples/aydinlatma_04.txt +14 -0
- samples/aydinlatma_04.udf +0 -0
- samples/aydinlatma_05.docx +0 -0
- samples/aydinlatma_05.pdf +0 -0
- samples/aydinlatma_05.png +3 -0
- samples/aydinlatma_05.scan.pdf +3 -0
- samples/aydinlatma_05.txt +14 -0
- samples/aydinlatma_05.udf +0 -0
- samples/bilirkisi_01.pdf +2 -2
- samples/bilirkisi_01.scan.pdf +2 -2
- samples/bilirkisi_02.docx +0 -0
- samples/bilirkisi_02.pdf +0 -0
- samples/bilirkisi_02.txt +3 -0
- samples/bilirkisi_02.udf +0 -0
- samples/bilirkisi_03.docx +0 -0
- samples/bilirkisi_03.pdf +0 -0
- samples/bilirkisi_03.png +3 -0
- samples/bilirkisi_03.scan.pdf +3 -0
- samples/bilirkisi_03.txt +3 -0
- samples/bilirkisi_03.udf +0 -0
- samples/bilirkisi_04.docx +0 -0
- samples/bilirkisi_04.pdf +0 -0
- samples/bilirkisi_04.txt +3 -0
- samples/bilirkisi_04.udf +0 -0
- samples/bilirkisi_05.docx +0 -0
.gitattributes
CHANGED
|
@@ -81,3 +81,38 @@ samples/vekaletname_01.scan.pdf filter=lfs diff=lfs merge=lfs -text
|
|
| 81 |
samples/zabit_01.pdf filter=lfs diff=lfs merge=lfs -text
|
| 82 |
samples/zabit_01.png filter=lfs diff=lfs merge=lfs -text
|
| 83 |
samples/zabit_01.scan.pdf filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
samples/zabit_01.pdf filter=lfs diff=lfs merge=lfs -text
|
| 82 |
samples/zabit_01.png filter=lfs diff=lfs merge=lfs -text
|
| 83 |
samples/zabit_01.scan.pdf filter=lfs diff=lfs merge=lfs -text
|
| 84 |
+
hero/dilekce_maskeli.png filter=lfs diff=lfs merge=lfs -text
|
| 85 |
+
hero/dilekce_orijinal.png filter=lfs diff=lfs merge=lfs -text
|
| 86 |
+
samples/aydinlatma_03.png filter=lfs diff=lfs merge=lfs -text
|
| 87 |
+
samples/aydinlatma_03.scan.pdf filter=lfs diff=lfs merge=lfs -text
|
| 88 |
+
samples/aydinlatma_05.png filter=lfs diff=lfs merge=lfs -text
|
| 89 |
+
samples/aydinlatma_05.scan.pdf filter=lfs diff=lfs merge=lfs -text
|
| 90 |
+
samples/bilirkisi_03.png filter=lfs diff=lfs merge=lfs -text
|
| 91 |
+
samples/bilirkisi_03.scan.pdf filter=lfs diff=lfs merge=lfs -text
|
| 92 |
+
samples/bilirkisi_05.png filter=lfs diff=lfs merge=lfs -text
|
| 93 |
+
samples/bilirkisi_05.scan.pdf filter=lfs diff=lfs merge=lfs -text
|
| 94 |
+
samples/dilekce_03.png filter=lfs diff=lfs merge=lfs -text
|
| 95 |
+
samples/dilekce_03.scan.pdf filter=lfs diff=lfs merge=lfs -text
|
| 96 |
+
samples/dilekce_05.png filter=lfs diff=lfs merge=lfs -text
|
| 97 |
+
samples/dilekce_05.scan.pdf filter=lfs diff=lfs merge=lfs -text
|
| 98 |
+
samples/epikriz_03.png filter=lfs diff=lfs merge=lfs -text
|
| 99 |
+
samples/epikriz_03.scan.pdf filter=lfs diff=lfs merge=lfs -text
|
| 100 |
+
samples/epikriz_05.png filter=lfs diff=lfs merge=lfs -text
|
| 101 |
+
samples/epikriz_05.scan.pdf filter=lfs diff=lfs merge=lfs -text
|
| 102 |
+
samples/eposta_03.png filter=lfs diff=lfs merge=lfs -text
|
| 103 |
+
samples/eposta_03.scan.pdf filter=lfs diff=lfs merge=lfs -text
|
| 104 |
+
samples/eposta_05.png filter=lfs diff=lfs merge=lfs -text
|
| 105 |
+
samples/eposta_05.scan.pdf filter=lfs diff=lfs merge=lfs -text
|
| 106 |
+
samples/fatura_03.png filter=lfs diff=lfs merge=lfs -text
|
| 107 |
+
samples/fatura_03.scan.pdf filter=lfs diff=lfs merge=lfs -text
|
| 108 |
+
samples/fatura_05.png filter=lfs diff=lfs merge=lfs -text
|
| 109 |
+
samples/fatura_05.scan.pdf filter=lfs diff=lfs merge=lfs -text
|
| 110 |
+
samples/icra_03.png filter=lfs diff=lfs merge=lfs -text
|
| 111 |
+
samples/icra_03.scan.pdf filter=lfs diff=lfs merge=lfs -text
|
| 112 |
+
samples/icra_05.png filter=lfs diff=lfs merge=lfs -text
|
| 113 |
+
samples/icra_05.scan.pdf filter=lfs diff=lfs merge=lfs -text
|
| 114 |
+
samples/ihtarname_03.png filter=lfs diff=lfs merge=lfs -text
|
| 115 |
+
samples/ihtarname_03.scan.pdf filter=lfs diff=lfs merge=lfs -text
|
| 116 |
+
samples/ihtarname_05.png filter=lfs diff=lfs merge=lfs -text
|
| 117 |
+
samples/ihtarname_05.scan.pdf filter=lfs diff=lfs merge=lfs -text
|
| 118 |
+
samples/is_sozlesmesi_03.png filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
|
@@ -32,9 +32,10 @@ running entirely client-side: Python rules in Pyodide, true PDF redaction with M
|
|
| 32 |
OCR with Tesseract.js, and an optional 2.6 MB on-device name tagger
|
| 33 |
([katip-tr-name-tagger-tiny](https://huggingface.co/f1k/katip-tr-name-tagger-tiny)) via transformers.js.
|
| 34 |
|
| 35 |
-
-
|
| 36 |
-
-
|
| 37 |
-
-
|
|
|
|
| 38 |
|
| 39 |
Source: https://github.com/fikrifirat/katip · Licence: AGPL-3.0, with a commercial licence
|
| 40 |
available for use in products or real operations (contact fikrifirat@gmail.com or open an issue).
|
|
|
|
| 32 |
OCR with Tesseract.js, and an optional 2.6 MB on-device name tagger
|
| 33 |
([katip-tr-name-tagger-tiny](https://huggingface.co/f1k/katip-tr-name-tagger-tiny)) via transformers.js.
|
| 34 |
|
| 35 |
+
- Drop a file and get the masked copy: scanning, masking and the leak check run automatically.
|
| 36 |
+
- Untick anything that should stay visible, or type a missed term; then download.
|
| 37 |
+
- 16 synthetic samples with answer keys; model, OCR, threshold, data types and mask style under "Gelişmiş ayarlar".
|
| 38 |
+
- "Görüş bildir" opens a ready e-mail for feedback.
|
| 39 |
|
| 40 |
Source: https://github.com/fikrifirat/katip · Licence: AGPL-3.0, with a commercial licence
|
| 41 |
available for use in products or real operations (contact fikrifirat@gmail.com or open an issue).
|
app.js
CHANGED
|
@@ -12,108 +12,122 @@ const DEV = new URLSearchParams(location.search).has("dev");
|
|
| 12 |
const I18N = {
|
| 13 |
tr: {
|
| 14 |
tagline: "Türkçe KVKK maskeleme, tamamen tarayıcınızda. Belge hiçbir sunucuya yüklenmez.",
|
| 15 |
-
netLbl: "Belge
|
| 16 |
cspLbl: "Dışarı gönderim", blocked: "engelli", cspTip: "Bu sayfa belgenizi başka bir yere gönderemez.||Sayfanın güvenlik politikası (CSP), tarayıcının başka bir adrese veri göndermesini engeller.",
|
| 17 |
storeLbl: "Kayıt", none: "yok", storeTip: "Hiçbir şey kaydedilmez; sekmeyi kapatınca belge silinir.||Belge yalnızca bu sekmenin belleğinde tutulur.",
|
| 18 |
docH: "Belge", sampleTab: "Örnek belge", fileTab: "Kendi belgem",
|
| 19 |
sampleHint: "Sentetik belgeler, cevap anahtarıyla puanlanır.",
|
| 20 |
sampleTip: "Örneklerdeki kişiler uydurmadır. Doğru cevaplar bilindiği için kaçanları da görürsünüz.||Bulunan, kaçan ve fazladan işaretlenen değerler cevap anahtarına göre canlı sayılır.",
|
| 21 |
-
dropH: "Belgeyi bırakın veya
|
| 22 |
fileTip: "Dosyanız bilgisayarınızdan çıkmaz.||Açma, tarama, maskeleme ve kontrol bu sekmede yapılır; sekmeyi kapatınca silinir.",
|
| 23 |
detectH: "Algılama", ocrH: "OCR (tarama ve fotoğraf)",
|
| 24 |
ocrTip: "Taranmış belge ve fotoğrafları okuyan model. Önerilen yeterlidir.||Türkçe Tesseract modeli. 96 taranmış örnekte ikisi neredeyse aynı değerleri buldu; önerilen 4 kat küçüktür çünkü yalnızca yeni (LSTM) okuyucuyu içerir.",
|
| 25 |
-
thrH: "Güven eşiği", thrTip: "Düşük: daha çok yakalar, daha çok yanlış alarm. Yüksek: tersi.||T.C. kimlik, VKN, IBAN gibi sağlama kontrollü numaralar her zaman yakalanır.",
|
| 26 |
-
typesH: "Veri türleri", maskH: "Maske", colorH: "
|
| 27 |
styleTip: "Değerin yerine ne konacağı: kutu, ████ veya [AD SOYAD-1] gibi etiket.||PDF ve görüntülerde seçilen renkte kutu çizilir. Word, UDF ve metinde değer ██████ veya etiketle değiştirilir.",
|
| 28 |
styleBox: "██████ blok", styleLabel: "[AD SOYAD-1] etiket",
|
|
|
|
|
|
|
| 29 |
tipMore: "Ayrıntılar",
|
| 30 |
markH: "Ortaya Katip logosu", markTip: "Her sayfanın ortasına soluk bir Katip logosu basar.||PDF ve görüntülerde. Kapalıyken alttaki tek satırlık filigran yine eklenir.",
|
| 31 |
-
wmH: "Filigran ekle", wmTip: "Çıktının altına küçük bir \"Katip
|
| 32 |
-
viewReview: "
|
| 33 |
copy: "Etiketli metni kopyala", forget: "Belgeyi unut",
|
| 34 |
empty: "Soldan bir örnek seçin veya kendi belgenizi açın.",
|
| 35 |
-
termPh: "Kaçan bir
|
| 36 |
findTip: "İşaretini kaldırdığınız değer maskelenmez.||Belgedeki işarete tıklamak da aç/kapa yapar. ⚑ o değeri hata bildirimine ekler.",
|
| 37 |
-
found: "{n}
|
| 38 |
details: "Ayrıntılar",
|
| 39 |
sRecall: "bulundu", sFp: "fazla", sLeak: "çıktıda okunabilir", sNotApplied: "Değişiklikler henüz uygulanmadı",
|
| 40 |
sPreview: "Henüz maskelenmedi: işaretleri kontrol edin, sonra Maskeleri uygula'ya basın.",
|
| 41 |
-
leakBad: "Dikkat: {n} değer çıktıda hâlâ okunabiliyor.
|
| 42 |
-
leakOk: "
|
| 43 |
-
loading: "
|
| 44 |
-
waiting: "
|
| 45 |
error: "Hata: ", page: "Sayfa", ok: "doğru", fpL: "fazla", missL: "kaçan",
|
| 46 |
tType: "Tür", tGold: "Doğru cevap", tFound: "Bulunan", tMissed: "Kaçan", tFp: "Fazla", partial: "kısmen: yalnız \"{p}\"",
|
| 47 |
goldNote: "Doğru cevaplar belgeyi üreten betikten gelir. Taranmış belgelerde OCR hataları da puanı etkiler.",
|
| 48 |
kModel: "Model", kOcr: "OCR modeli", kPages: "Sayfa", kParse: "Okuma ve OCR", kRules: "Kurallar", kRedact: "Maskeleme ve kontrol", kVersion: "Motor sürümü", unused: "kullanılmadı",
|
| 49 |
-
rWhatWrong: "
|
| 50 |
-
rWhere: "Hangi değer veya yer? (isteğe bağlı)", rDesc: "
|
| 51 |
rIncSettings: "Ayarlar, sürüm, süreler, puanlar", rIncMasked: "Maskeli çıktı", rIncDet: "Bulunan değerler", rIncOrig: "Orijinal belge",
|
| 52 |
-
rPersonal: "(kişisel veri içerir)", rPersonalOk: "(kişisel veri içerir; yalnızca izniniz varsa)", cancel: "Vazgeç", rSend: "Doğrudan gönder", rMail: "
|
| 53 |
-
rHowMail: "
|
| 54 |
rHowSend: "\"Doğrudan gönder\" raporu {host} üzerinden iletir. Ya da raporu indirip e-postayla kendiniz gönderin.",
|
| 55 |
rDownloaded: "{f} indirildi. E-posta açılmadıysa {to} adresine dosyayı ekleyerek yazın.", rSending: "Gönderiliyor…", rSent: "Gönderildi, teşekkürler.", rFailed: "Gönderilemedi",
|
| 56 |
-
foot: "
|
| 57 |
onSpace: "Bu sayfa için çok büyük (110 MB) · Hugging Face'te deneyin →",
|
|
|
|
|
|
|
| 58 |
ocrBest: "Küçük", ocrBestMeta: "Aynı doğruluk, 4 kat küçük · 2 MB", ocrFast: "Standart", ocrFastMeta: "Eski motor verisiyle · 7,7 MB",
|
| 59 |
origAlt: "Orijinal sayfa", maskAlt: "Maskeli sayfa", ocrText: "Okunan metin",
|
|
|
|
|
|
|
| 60 |
},
|
| 61 |
en: {
|
| 62 |
tagline: "Turkish KVKK redaction that runs entirely in your browser. Nothing is uploaded.",
|
| 63 |
-
netLbl: "
|
| 64 |
cspLbl: "Sending out", blocked: "blocked", cspTip: "This page cannot send your document anywhere.||The page's Content-Security-Policy makes the browser block any request to another address.",
|
| 65 |
storeLbl: "Stored", none: "nothing", storeTip: "Nothing is saved; closing the tab deletes the document.||The document lives only in this tab's memory.",
|
| 66 |
docH: "Document", sampleTab: "Sample", fileTab: "My file",
|
| 67 |
sampleHint: "Synthetic documents, scored against an answer key.",
|
| 68 |
sampleTip: "The people in the samples are made up. The right answers are known, so you also see what was missed.||Found, missed and extra values are counted live against the answer key.",
|
| 69 |
-
dropH: "Drop a
|
| 70 |
fileTip: "Your file never leaves your computer.||Opening, scanning, masking and checking all happen in this tab; closing the tab deletes it.",
|
| 71 |
detectH: "Detection", ocrH: "OCR (scans and photos)",
|
| 72 |
ocrTip: "The model that reads scans and photos. The recommended one is enough.||Turkish Tesseract model. On 96 scanned samples both found almost the same values; the recommended one is 4× smaller because it holds only the newer (LSTM) reader.",
|
| 73 |
-
thrH: "Confidence threshold", thrTip: "Lower catches more but raises more false alarms. Higher does the opposite.||Numbers with a checksum (T.C. kimlik, VKN, IBAN) are always caught.",
|
| 74 |
-
typesH: "Data types", maskH: "Mask", colorH: "
|
| 75 |
styleTip: "What replaces each value: a box, ████ or a label such as [AD SOYAD-1].||PDFs and images get a box in the chosen colour. In Word, UDF and text the value is replaced with ██████ or a label.",
|
| 76 |
styleBox: "██████ block", styleLabel: "[AD SOYAD-1] label",
|
|
|
|
|
|
|
| 77 |
tipMore: "Details",
|
| 78 |
markH: "Katip logo in the centre", markTip: "Stamps a faint Katip logo across the middle of each page.||PDFs and images only. Off, the one-line watermark at the bottom is still added.",
|
| 79 |
-
wmH: "Add watermark", wmTip: "Adds a small \"Redacted with Katip
|
| 80 |
-
viewReview: "
|
| 81 |
copy: "Copy labelled text", forget: "Forget document",
|
| 82 |
empty: "Pick a sample on the left or open your own file.",
|
| 83 |
-
termPh: "
|
| 84 |
findTip: "Untick a value and it will not be masked.||Clicking a highlight in the document toggles it too. ⚑ adds the value to a mistake report.",
|
| 85 |
-
found: "{n}
|
| 86 |
details: "Details",
|
| 87 |
sRecall: "found", sFp: "extra", sLeak: "readable in output", sNotApplied: "Changes not applied yet",
|
| 88 |
sPreview: "Nothing is masked yet: check the highlights, then press Apply masks.",
|
| 89 |
-
leakBad: "Warning: {n} values are still readable in the output. Do not send
|
| 90 |
-
leakOk: "
|
| 91 |
-
loading: "
|
| 92 |
-
waiting: "
|
| 93 |
error: "Error: ", page: "Page", ok: "correct", fpL: "extra", missL: "missed",
|
| 94 |
tType: "Type", tGold: "Answer key", tFound: "Found", tMissed: "Missed", tFp: "Extra", partial: "partly: only \"{p}\"",
|
| 95 |
goldNote: "The answer key comes from the script that generated the document. On scans, OCR errors also affect the score.",
|
| 96 |
kModel: "Model", kOcr: "OCR model", kPages: "Pages", kParse: "Reading and OCR", kRules: "Rules", kRedact: "Masking and check", kVersion: "Engine version", unused: "not used",
|
| 97 |
-
rWhatWrong: "
|
| 98 |
-
rWhere: "Which value or place? (optional)", rDesc: "
|
| 99 |
rIncSettings: "Settings, version, timings, scores", rIncMasked: "Masked output", rIncDet: "Detected values", rIncOrig: "Original document",
|
| 100 |
-
rPersonal: "(contains personal data)", rPersonalOk: "(contains personal data; only with permission)", cancel: "Cancel", rSend: "Send directly", rMail: "
|
| 101 |
-
rHowMail: "
|
| 102 |
rHowSend: "\"Send directly\" delivers the report through {host}. Or download it and e-mail it yourself.",
|
| 103 |
rDownloaded: "{f} downloaded. If no e-mail opened, write to {to} and attach the file.", rSending: "Sending…", rSent: "Sent, thank you.", rFailed: "Could not send",
|
| 104 |
-
foot: "
|
| 105 |
onSpace: "Too big for this page (110 MB) · try it on Hugging Face →",
|
|
|
|
|
|
|
| 106 |
ocrBest: "Compact", ocrBestMeta: "Same accuracy, 4× smaller · 2 MB", ocrFast: "Standard", ocrFastMeta: "Includes old engine data · 7.7 MB",
|
| 107 |
origAlt: "Original page", maskAlt: "Masked page", ocrText: "Recognised text",
|
|
|
|
|
|
|
| 108 |
},
|
| 109 |
};
|
| 110 |
const LABELS = {
|
| 111 |
tr: { TCKN: "T.C. Kimlik No", VKN: "Vergi Kimlik No", IBAN: "IBAN", CREDIT_CARD: "Kredi kartı", PHONE: "Telefon", EMAIL: "E-posta", PLATE: "Araç plakası",
|
| 112 |
-
PERSON: "Ad soyad", ADDRESS: "Adres", BIRTH_DATE: "Doğum tarihi", IP_ADDRESS: "IP adresi", HEALTH: "Sağlık", RELIGION: "Din/inanç", POLITICAL: "Siyasi görüş",
|
| 113 |
-
UNION: "Sendika", ETHNICITY: "Etnik köken", SEXUAL_LIFE: "Cinsel hayat", BIOMETRIC_GENETIC: "Biyometrik/genetik", CRIMINAL_RECORD: "Ceza mahkumiyeti",
|
|
|
|
| 114 |
en: { TCKN: "Turkish ID no.", VKN: "Tax ID", IBAN: "IBAN", CREDIT_CARD: "Card number", PHONE: "Phone", EMAIL: "E-mail", PLATE: "Licence plate",
|
| 115 |
-
PERSON: "Name", ADDRESS: "Address", BIRTH_DATE: "Birth date", IP_ADDRESS: "IP address", HEALTH: "Health", RELIGION: "Religion/belief", POLITICAL: "Political opinion",
|
| 116 |
-
UNION: "Trade union", ETHNICITY: "Ethnic origin", SEXUAL_LIFE: "Sex life", BIOMETRIC_GENETIC: "Biometric/genetic", CRIMINAL_RECORD: "Criminal record",
|
|
|
|
| 117 |
};
|
| 118 |
const FORMATS = { tr: { txt: "Metin", docx: "Word", udf: "UYAP .udf", pdf: "PDF", "scan.pdf": "Taranmış PDF", png: "Fotoğraf" },
|
| 119 |
en: { txt: "Text", docx: "Word", udf: "UYAP .udf", pdf: "PDF", "scan.pdf": "Scanned PDF", png: "Photo" } };
|
|
@@ -121,8 +135,18 @@ const SAMPLE_EN = { aydinlatma: "Privacy notice", bilirkisi: "Expert report", di
|
|
| 121 |
fatura: "Invoice", icra: "Enforcement request", ihtarname: "Formal notice", is_sozlesmesi: "Employment contract", karar: "Court judgment", kira_sozlesmesi: "Lease",
|
| 122 |
mektup: "Letter", mesaj: "Chat log", tutanak: "Minutes", vekaletname: "Power of attorney", zabit: "Hearing record" };
|
| 123 |
const PROGRESS_EN = [[/^NER modeli yükleniyor/, "Loading name model"], [/^NER çalışıyor/, "Running name model"], [/^OCR sayfa (\d+)\/(\d+)/, "OCR page $1/$2"], [/^Sayfa (\d+)\/(\d+)/, "Page $1/$2"],
|
| 124 |
-
[/^OCR$/, "OCR"], [/^Kişisel veriler aranıyor/, "Looking for personal data"], [/^Maskeleniyor/, "Masking"], [/tekrar okunuyor/, "Re-reading output (leak check)"]];
|
| 125 |
const ART6 = ["HEALTH", "RELIGION", "POLITICAL", "UNION", "ETHNICITY", "SEXUAL_LIFE", "BIOMETRIC_GENETIC", "CRIMINAL_RECORD"];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
const COLORS = [["#000000", "Siyah / Black"], ["#3a3a3a", "Grafit / Graphite"], ["#1f3a5f", "Lacivert / Navy"], ["#1f5f4a", "Yeşil / Green"], ["#7a1f1f", "Bordo / Maroon"], ["#ffffff", "Beyaz / White"]];
|
| 127 |
|
| 128 |
let lang = (() => {
|
|
@@ -145,7 +169,7 @@ let loaded = null; // { name, bytes, gold, isSample }
|
|
| 145 |
|
| 146 |
// ---------------------------------------------------------------- network watch
|
| 147 |
let docOpenedAt = Infinity, netCount = 0;
|
| 148 |
-
const runtimeFile = u => /\/(vendor|models|samples)\/|katip_py\.zip|\.(m?js|css|wasm|json)(\?|$)/.test(u);
|
| 149 |
new PerformanceObserver(list => {
|
| 150 |
for (const e of list.getEntries()) if (e.startTime >= docOpenedAt && !runtimeFile(e.name)) netCount++;
|
| 151 |
$("netCount").textContent = netCount; $("netPill").classList.toggle("bad", netCount > 0);
|
|
@@ -210,10 +234,12 @@ function applyLang() {
|
|
| 210 |
document.documentElement.lang = lang;
|
| 211 |
document.querySelectorAll("[data-i18n]").forEach(e => (e.textContent = t(e.dataset.i18n)));
|
| 212 |
document.querySelectorAll("[data-i18n-ph]").forEach(e => (e.placeholder = t(e.dataset.i18nPh)));
|
|
|
|
|
|
|
| 213 |
document.querySelectorAll("[data-i18n-tip]").forEach(e => { const s = t(e.dataset.i18nTip, { wm: wmText() }); e.dataset.tip = s; e.setAttribute("aria-label", s.split("||")[0]); e.setAttribute("role", "button"); });
|
| 214 |
document.querySelectorAll("[data-lang]").forEach(b => b.setAttribute("aria-pressed", b.dataset.lang === lang));
|
| 215 |
-
document.title = lang === "tr" ? "Katip
|
| 216 |
-
if (samples.length) {
|
| 217 |
updateButtons(); if (res?.spans) render(); else if (preview) renderPreview();
|
| 218 |
}
|
| 219 |
document.querySelectorAll("[data-lang]").forEach(b => (b.onclick = () => {
|
|
@@ -224,12 +250,12 @@ document.querySelectorAll("[data-lang]").forEach(b => (b.onclick = () => {
|
|
| 224 |
|
| 225 |
// ---------------------------------------------------------------- settings
|
| 226 |
const selModel = () => document.querySelector("#models input:checked")?.value ?? models.find(m => !m.dev && !m.disabled && !m.elsewhere)?.id;
|
| 227 |
-
const selOcr = () => document.querySelector("#ocrModels input:checked")?.value ?? "
|
| 228 |
function settings() {
|
| 229 |
const m = models.find(x => x.id === selModel()) ?? models[0];
|
| 230 |
return {
|
| 231 |
engine: m?.id, engineName: loc(m?.name), ner: m?.ner ?? null, baseline: m?.baseline ?? null, nerDtype: m?.dtype ?? "q8", trust: m?.trust ?? null, ocrLang: selOcr(),
|
| 232 |
-
threshold: Number($("threshold").value), style: $("style").value,
|
| 233 |
entities: [...document.querySelectorAll("#entities input:checked")].map(i => i.value), mask: maskTerms,
|
| 234 |
};
|
| 235 |
}
|
|
@@ -248,7 +274,8 @@ function buildModelUI() {
|
|
| 248 |
$("models").append(card);
|
| 249 |
}
|
| 250 |
$("ocrModels").textContent = "";
|
| 251 |
-
$("ocrModels").append(optCard("ocr", "
|
|
|
|
| 252 |
optCard("ocr", "fast", t("ocrFast"), t("ocrFastMeta"), t("ocrTip"), curOcr === "fast"));
|
| 253 |
}
|
| 254 |
$("models").onchange = () => res?.spans && redetect();
|
|
@@ -258,12 +285,35 @@ function buildEntityUI() {
|
|
| 258 |
$("entities").textContent = "";
|
| 259 |
for (const k of Object.keys(LABELS.tr)) {
|
| 260 |
if (k === "CUSTOM") continue;
|
| 261 |
-
const cb = el("input", { type: "checkbox", value: k, checked: prev.length ? on.has(k) : k
|
| 262 |
$("entities").append(el("label", {}, cb, " " + L(k) + (ART6.includes(k) ? " ·6" : "")));
|
| 263 |
}
|
| 264 |
entCount();
|
| 265 |
}
|
| 266 |
-
$("entities").onchange = () => { entCount(); res?.spans && redetect(); };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
function entCount() { $("entCount").textContent = `(${document.querySelectorAll("#entities input:checked").length}/${Object.keys(LABELS.tr).length - 1})`; }
|
| 268 |
function buildColorUI() {
|
| 269 |
for (const [hex, name] of COLORS) {
|
|
@@ -284,49 +334,93 @@ const rgb = hex => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16) / 255);
|
|
| 284 |
$("threshold").oninput = () => ($("thresholdVal").textContent = Number($("threshold").value).toFixed(2));
|
| 285 |
$("threshold").onchange = () => res?.spans && redetect();
|
| 286 |
$("style").onchange = () => maskOptionsChanged();
|
|
|
|
| 287 |
$("watermark").onchange = () => maskOptionsChanged();
|
| 288 |
$("centerMark").onchange = () => maskOptionsChanged();
|
| 289 |
function maskOptionsChanged() { if (res?.spans && applied && !busy) redactNow(); }
|
| 290 |
|
| 291 |
// ---------------------------------------------------------------- sources
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
function buildSampleOptions() {
|
| 293 |
-
const cur = $("sampleType").value;
|
| 294 |
$("sampleType").textContent = "";
|
| 295 |
-
for (const
|
| 296 |
-
if (
|
| 297 |
-
}
|
| 298 |
-
function
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
|
|
|
|
|
|
| 304 |
}
|
| 305 |
function drawFormats() {
|
| 306 |
const f = $("formatBtns"); f.textContent = "";
|
| 307 |
-
if (!src.sample.formats.includes(src.format)) src.format = src.sample.formats[0];
|
| 308 |
for (const fmt of src.sample.formats) {
|
| 309 |
const b = el("button", { type: "button", textContent: FORMATS[lang][fmt] ?? fmt });
|
| 310 |
b.dataset.fmt = fmt; b.setAttribute("aria-pressed", fmt === src.format);
|
| 311 |
-
b.onclick = () => { if (src.format === fmt) return; src.format = fmt; drawFormats();
|
| 312 |
f.append(b);
|
| 313 |
}
|
| 314 |
}
|
| 315 |
-
function
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
|
|
|
|
|
|
|
|
|
| 319 |
}
|
| 320 |
-
function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
$("drop").onclick = () => $("file").click();
|
| 322 |
$("drop").onkeydown = e => (e.key === "Enter" || e.key === " ") && $("file").click();
|
| 323 |
$("file").onchange = e => { pickFile(e.target.files[0]); e.target.value = ""; };
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
|
| 328 |
// Selecting a document shows it at once; analysis starts as soon as the engine is ready.
|
| 329 |
-
|
|
|
|
|
|
|
| 330 |
const my = ++seq, isSample = src.mode === "sample";
|
| 331 |
let name, bytes, gold = null;
|
| 332 |
status(t("preparing"));
|
|
@@ -340,22 +434,24 @@ async function loadSource() {
|
|
| 340 |
} catch (e) { if (my === seq) status(t("error") + (e?.message ?? e)); console.error(e); return; }
|
| 341 |
if (my !== seq) return;
|
| 342 |
loaded = { name, bytes, gold, isSample };
|
|
|
|
|
|
|
| 343 |
res = null; applied = false; view = "review"; outBlob = null; busy = false;
|
| 344 |
preview = { seq: my, kind: /\.(png|jpe?g|tiff?|bmp|webp)$/i.test(name) ? "image" : null, text: null, pages: [], imageUrl: null };
|
| 345 |
if (preview.kind === "image") preview.imageUrl = URL.createObjectURL(new Blob([bytes]));
|
| 346 |
renderPreview();
|
| 347 |
if (preview.kind !== "image") send({ type: "preview", name, bytes: bytes.slice(), seq: my });
|
| 348 |
updateButtons();
|
| 349 |
-
|
| 350 |
}
|
| 351 |
|
| 352 |
// ---------------------------------------------------------------- run
|
| 353 |
-
const status = m =>
|
| 354 |
const progressText = m => { if (lang === "tr") return m; for (const [re, en] of PROGRESS_EN) if (re.test(m)) return m.replace(re, en); return m; };
|
| 355 |
function analyze() {
|
| 356 |
if (!loaded) return;
|
| 357 |
const s = settings(), { name, gold, isSample } = loaded, bytes = loaded.bytes.slice();
|
| 358 |
-
status(t("
|
| 359 |
docOpenedAt = performance.now(); netCount = 0; $("netCount").textContent = "0"; $("netPill").classList.remove("bad");
|
| 360 |
res = { name, isSample, gold, settings: s, imageUrl: preview?.imageUrl ?? null, maskUrl: null, seq };
|
| 361 |
off = new Set(); applied = false; view = "review"; busy = true; updateButtons();
|
|
@@ -363,34 +459,36 @@ function analyze() {
|
|
| 363 |
}
|
| 364 |
function redetect() { if (!res?.spans || busy) return; res.settings = settings(); busy = true; updateButtons(); worker.postMessage({ type: "redetect", settings: res.settings }); }
|
| 365 |
function redactNow() {
|
| 366 |
-
busy = true; updateButtons(); status(
|
| 367 |
worker.postMessage({ type: "redact", spans: chosen(), style: $("style").value, verifyOcr: true, ocrLang: res.settings.ocrLang, color: rgb(maskColor),
|
| 368 |
watermark: $("watermark").checked && CONFIG.watermark ? { text: wmText(), url: CONFIG.watermark.url } : null,
|
| 369 |
mark: $("centerMark").checked });
|
| 370 |
}
|
| 371 |
-
|
|
|
|
| 372 |
$("scan").onclick = () => ready && loaded && !busy && analyze();
|
| 373 |
$("addTerm").onclick = () => addTerm($("term").value);
|
| 374 |
$("term").onkeydown = e => e.key === "Enter" && $("addTerm").click();
|
| 375 |
function addTerm(v) { v = (v ?? "").trim(); if (!v || !res?.spans) return; maskTerms.push(v); $("term").value = ""; redetect(); }
|
| 376 |
-
document.querySelectorAll("#viewSeg [data-view]").forEach(b => (b.onclick = () => {
|
|
|
|
|
|
|
|
|
|
| 377 |
|
| 378 |
function updateButtons() {
|
| 379 |
const has = !!res?.spans;
|
| 380 |
-
$("scan").disabled = !ready || !loaded || busy;
|
| 381 |
-
$("
|
| 382 |
-
$("apply").disabled = !has || busy; $("apply").textContent = applied ? t("reapply") : t("apply");
|
| 383 |
-
$("apply").classList.toggle("primary", !applied || dirty());
|
| 384 |
-
$("download").disabled = !applied || busy; $("download").classList.toggle("primary", applied && !dirty());
|
| 385 |
-
$("reportBtn").disabled = !has;
|
| 386 |
document.querySelector('#viewSeg [data-view="result"]').disabled = !applied;
|
| 387 |
document.querySelectorAll("#viewSeg [data-view]").forEach(b => b.setAttribute("aria-selected", b.dataset.view === view));
|
|
|
|
| 388 |
}
|
| 389 |
|
| 390 |
worker.onmessage = async ({ data: m }) => {
|
| 391 |
if (m.type === "alive") { workerAlive = true; for (const [msg, tr] of outbox.splice(0)) worker.postMessage(msg, tr); return; }
|
| 392 |
if (m.type === "ready") {
|
| 393 |
-
ready = true;
|
|
|
|
| 394 |
} else if (m.type === "progress") status(progressText(m.msg) + "…");
|
| 395 |
else if (m.type === "preview") {
|
| 396 |
if (!preview || m.seq !== preview.seq) return;
|
|
@@ -403,13 +501,13 @@ worker.onmessage = async ({ data: m }) => {
|
|
| 403 |
if (!res || m.seq !== res.seq || m.seq !== seq) return;
|
| 404 |
Object.assign(res, { kind: m.kind, text: m.text, spans: m.spans, pages: m.pages, pageSizes: m.pageSizes, ocrPages: m.ocrPages, timing: m.timing, nerCount: m.nerCount,
|
| 405 |
origPreviews: m.previews.map(p => URL.createObjectURL(new Blob([p], { type: "image/png" }))) });
|
| 406 |
-
off = new Set(m.spans.filter(s =>
|
| 407 |
-
busy = false;
|
| 408 |
} else if (m.type === "redetected") {
|
| 409 |
if (!res?.spans) return;
|
| 410 |
const wasOff = new Set([...off].filter(k => res.spans.some(s => key(s) === k && !s.flag_only)));
|
| 411 |
res.spans = m.spans; Object.assign(res.timing, m.timing); res.nerCount = m.nerCount;
|
| 412 |
-
off = new Set(m.spans.filter(s =>
|
| 413 |
busy = false; if (applied) redactNow(); else { status(t("found", { n: chosen().length }) + "."); render(); }
|
| 414 |
} else if (m.type === "redacted") {
|
| 415 |
if (!res?.spans) return;
|
|
@@ -417,14 +515,15 @@ worker.onmessage = async ({ data: m }) => {
|
|
| 417 |
Object.assign(res, { outText: m.outText, leaks: m.leaks, unchecked: m.unchecked, redactMs: m.ms,
|
| 418 |
maskPreviews: m.previews.map(p => URL.createObjectURL(new Blob([p], { type: "image/png" }))), appliedKeys: new Set(chosen().map(key)) });
|
| 419 |
if (res.kind === "image") res.maskUrl = URL.createObjectURL(outBlob);
|
| 420 |
-
busy = false; applied = true; view = "result";
|
| 421 |
-
status(
|
|
|
|
| 422 |
} else if (m.type === "maskedText") { await navigator.clipboard.writeText(m.text); status(t("copied")); }
|
| 423 |
else if (m.type === "original") { pendingOriginal?.(m); }
|
| 424 |
else if (m.type === "forgotten") {
|
| 425 |
res = null; outBlob = null; loaded = null; preview = null; applied = false; busy = false; seq++;
|
| 426 |
-
$("
|
| 427 |
-
updateButtons(); status(t("
|
| 428 |
} else if (m.type === "error") { busy = false; updateButtons(); status(t("error") + m.msg); console.error(m.msg, m.stack); }
|
| 429 |
};
|
| 430 |
worker.onerror = e => { status(t("error") + (e.message || "worker")); console.error(e); };
|
|
@@ -440,12 +539,16 @@ function editDist(a, b) {
|
|
| 440 |
return prev[b.length];
|
| 441 |
}
|
| 442 |
const ocrMatches = (a, b) => { if (exactMatches(a, b)) return true; const x = sq(a), y = sq(b); return y.length >= 5 && editDist(x, y) <= Math.max(1, Math.floor(y.length * 0.15)); };
|
| 443 |
-
function goldEval(spans, gold, ocr = false) {
|
|
|
|
|
|
|
| 444 |
const matches = ocr ? ocrMatches : exactMatches;
|
| 445 |
const g = gold.filter(x => x.entity_type !== "CRIMINAL_RECORD");
|
| 446 |
-
|
|
|
|
|
|
|
| 447 |
const partial = g.map((x, i) => found[i] ? null : spans.find(s => sq(s.text).length >= 2 && sq(x.text).includes(sq(s.text)))?.text ?? null);
|
| 448 |
-
const fps = spans.filter(s => !g.some(x => matches(s.text, x.text) || matches(x.text, s.text)));
|
| 449 |
const per = {};
|
| 450 |
g.forEach((x, i) => { const p = per[x.entity_type] ??= { gold: 0, found: 0, fp: 0 }; p.gold++; if (found[i]) p.found++; });
|
| 451 |
for (const s of fps) (per[s.entity_type] ??= { gold: 0, found: 0, fp: 0 }).fp++;
|
|
@@ -454,14 +557,14 @@ function goldEval(spans, gold, ocr = false) {
|
|
| 454 |
}
|
| 455 |
|
| 456 |
// ---------------------------------------------------------------- rendering
|
| 457 |
-
const key = s => `${s.entity_type}:${s.start}:${s.end}`;
|
| 458 |
const chosen = () => (res?.spans ?? []).filter(s => !off.has(key(s)));
|
| 459 |
const dirty = () => !!(applied && res?.appliedKeys && (res.appliedKeys.size !== chosen().length || chosen().some(s => !res.appliedKeys.has(key(s)))));
|
| 460 |
-
function toggle(s) { off.has(key(s)) ? off.delete(key(s)) : off.add(key(s)); if (view === "result")
|
| 461 |
|
| 462 |
function renderPreview() {
|
| 463 |
// before analysis: the document as it is, no highlights yet
|
| 464 |
-
$("
|
| 465 |
const D = $("docView"); D.textContent = ""; D.className = "docview";
|
| 466 |
if (preview.kind === "image" && preview.imageUrl) D.append(el("div", { className: "pg" }, el("img", { src: preview.imageUrl, alt: t("origAlt") })));
|
| 467 |
else if (preview.pages.length) for (const u of preview.pages) D.append(el("div", { className: "pg" }, el("img", { src: u, alt: t("origAlt") })));
|
|
@@ -469,31 +572,33 @@ function renderPreview() {
|
|
| 469 |
else D.append(el("div", { className: "skel" }));
|
| 470 |
$("list").textContent = ""; $("fCount").textContent = "";
|
| 471 |
document.querySelector(".fhead").classList.add("hidden");
|
| 472 |
-
$("list").append(el("
|
| 473 |
}
|
| 474 |
|
| 475 |
function render() {
|
| 476 |
if (!res?.spans) { if (preview) renderPreview(); updateButtons(); return; }
|
| 477 |
if (!applied && view === "result") view = "review";
|
| 478 |
-
|
| 479 |
-
res.eval = res.gold ? goldEval(chosen(), res.gold, res.kind === "image" || res.ocrPages > 0) : null;
|
| 480 |
updateButtons(); renderScore(); renderDoc(); renderList(); renderGold(); renderTiming();
|
| 481 |
}
|
| 482 |
|
| 483 |
function renderScore() {
|
| 484 |
const S = $("scorebar"); S.textContent = "";
|
| 485 |
const ev = res.eval, items = [];
|
| 486 |
-
if (
|
| 487 |
-
items.push(el("span", { className: "sc" }, el("b", { textContent: `${ev.found.filter(Boolean).length}/${ev.gold.length}` }), " " + t("sRecall")));
|
| 488 |
-
items.push(el("span", { className: "sc" + (ev.fps.length ? " warn-t" : "") }, el("b", { textContent: String(ev.fps.length) }), " " + t("sFp")));
|
| 489 |
-
}
|
| 490 |
-
if (applied) {
|
| 491 |
const n = res.leaks.length, cls = n ? "bad-t" : res.unchecked ? "warn-t" : "ok-t";
|
| 492 |
-
items.push(el("span", { className: "
|
| 493 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 494 |
}
|
| 495 |
if (dirty()) items.push(el("span", { className: "msg warn-t", textContent: t("sNotApplied") }));
|
| 496 |
-
|
|
|
|
|
|
|
| 497 |
S.append(...items); S.classList.toggle("hidden", !items.length);
|
| 498 |
}
|
| 499 |
|
|
@@ -534,7 +639,7 @@ function textWithMarks() {
|
|
| 534 |
const box = el("div", { className: "txt" });
|
| 535 |
const tx = res.text, ev = res.eval, miss = [];
|
| 536 |
if (ev) ev.gold.forEach((g, i) => { if (!ev.found[i]) { const j = tx.toLocaleLowerCase("tr").indexOf(g.text.toLocaleLowerCase("tr")); if (j >= 0) miss.push({ start: j, end: j + g.text.length, miss: true, g }); } });
|
| 537 |
-
const items = [...res.spans, ...miss].sort((a, b) => a.start - b.start);
|
| 538 |
let pos = 0;
|
| 539 |
for (const s of items) {
|
| 540 |
if (s.start < pos) continue;
|
|
@@ -560,14 +665,19 @@ function renderList() {
|
|
| 560 |
const on = spans.filter(s => !off.has(key(s))).length;
|
| 561 |
const all = el("input", { type: "checkbox", checked: on === spans.length, indeterminate: on > 0 && on < spans.length, ariaLabel: L(type) });
|
| 562 |
all.onchange = () => { for (const s of spans) all.checked ? off.delete(key(s)) : off.add(key(s)); render(); };
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 566 |
const flag = el("button", { type: "button", className: "flag", textContent: "⚑", title: t("flag"), ariaLabel: t("flag") });
|
| 567 |
-
flag.onclick = e => { e.preventDefault(); openReport(
|
| 568 |
-
const v = el("span", { className: "v" + (fp ? " fp-t" : ""), textContent: s.text.replace(/\s+/g, " "), title: `${s.recognizer} · ${s.score.toFixed(2)}${s.flag_only ? " · " + t("warnOnly") : ""}` });
|
| 569 |
v.onclick = e => { e.preventDefault(); document.querySelector(`#docView [data-k="${CSS.escape(key(s))}"]`)?.scrollIntoView({ block: "center", behavior: "smooth" }); };
|
| 570 |
-
return el("div", { className: "it" }, cb, v, flag);
|
| 571 |
});
|
| 572 |
box.append(el("div", { className: "grp" }, el("label", { className: "gh" }, all, el("b", { textContent: L(type) }), el("span", { textContent: `${on}/${spans.length}` })), ...rows));
|
| 573 |
}
|
|
@@ -590,19 +700,27 @@ function renderGold() {
|
|
| 590 |
el("p", { className: "hint", textContent: t("goldNote") }));
|
| 591 |
}
|
| 592 |
function renderTiming() {
|
| 593 |
-
const tm = res.timing ?? {}, rows = [[t("kModel"), res.settings.engineName], [t("kOcr"), res.ocrPages ? (
|
| 594 |
[t("kPages"), `${res.pages}${res.ocrPages ? ` (${res.ocrPages} OCR)` : ""}`], [t("kParse"), `${tm.parse} ms`], ...(res.settings.ner ? [["NER", `${tm.ner} ms · ${res.nerCount}`]] : []),
|
| 595 |
[t("kRules"), `${tm.rules} ms`], ...(applied ? [[t("kRedact"), `${res.redactMs} ms`]] : []), [t("kVersion"), version.rules ?? ""]];
|
| 596 |
$("timing").textContent = ""; for (const [a, b] of rows) $("timing").append(el("tr", {}, el("td", { textContent: a }), el("td", { textContent: b })));
|
| 597 |
}
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
$("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 601 |
|
| 602 |
// ---------------------------------------------------------------- report a mistake
|
| 603 |
let pendingOriginal = null;
|
| 604 |
-
function openReport(kind = "
|
| 605 |
-
|
|
|
|
|
|
|
| 606 |
document.querySelector(`#reportForm input[name=kind][value=${kind}]`).checked = true;
|
| 607 |
$("rWhat").value = what; $("rStatus").textContent = "";
|
| 608 |
$("incMasked").checked = applied; $("incMasked").disabled = !applied;
|
|
@@ -611,8 +729,16 @@ function openReport(kind = "missed", what = "") {
|
|
| 611 |
$("rSend").classList.toggle("hidden", !CONFIG.reportEndpoint);
|
| 612 |
$("rHow").textContent = CONFIG.reportEndpoint ? t("rHowSend", { host: new URL(CONFIG.reportEndpoint).host }) : t("rHowMail", { to: CONFIG.reportEmail });
|
| 613 |
$("reportDlg").showModal();
|
|
|
|
| 614 |
}
|
| 615 |
$("reportBtn").onclick = () => openReport();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 616 |
|
| 617 |
async function buildReport() {
|
| 618 |
const kind = document.querySelector("#reportForm input[name=kind]:checked").value;
|
|
@@ -638,8 +764,19 @@ async function buildReport() {
|
|
| 638 |
}
|
| 639 |
return { id, report, blob: await zip.generateAsync({ type: "blob", compression: "DEFLATE" }) };
|
| 640 |
}
|
| 641 |
-
const KIND_TR = { missed: "Kaçan veri", false_positive: "Gereksiz maskeleme", broken: "Bozuk çıktı", other: "Diğer" };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 642 |
$("rMail").onclick = async () => {
|
|
|
|
| 643 |
const { id, report, blob } = await buildReport();
|
| 644 |
const fname = `katip-rapor-${id}.zip`;
|
| 645 |
const a = el("a", { href: URL.createObjectURL(blob), download: fname }); a.click();
|
|
@@ -648,7 +785,7 @@ $("rMail").onclick = async () => {
|
|
| 648 |
`Belge: ${report.document.name} (${report.document.kind}, ${report.document.pages} sayfa)`, `Model: ${report.settings.engineName} · eşik ${report.settings.threshold} · OCR ${report.settings.ocrLang}`,
|
| 649 |
sc && `Puan: yakalama ${(sc.recall * 100).toFixed(1)}%, kesinlik ${(sc.precision * 100).toFixed(1)}%`, `Sürüm: ${version.rules} (${version.built})`, "",
|
| 650 |
`Lütfen indirilen ${fname} dosyasını bu e-postaya ekleyin. / Please attach ${fname}.`].filter(x => x !== null && x !== undefined && x !== false).join("\n");
|
| 651 |
-
const subject = `Katip
|
| 652 |
location.href = `mailto:${CONFIG.reportEmail}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body.slice(0, 1800))}`;
|
| 653 |
$("rStatus").textContent = t("rDownloaded", { f: fname, to: CONFIG.reportEmail });
|
| 654 |
window.__lastMailto = { subject, body, fname };
|
|
@@ -658,7 +795,7 @@ $("rSend").onclick = async () => {
|
|
| 658 |
try {
|
| 659 |
const { id, report, blob } = await buildReport();
|
| 660 |
const fd = new FormData();
|
| 661 |
-
fd.append("_subject", `Katip
|
| 662 |
fd.append("message", `${KIND_TR[report.feedback.kind]}: ${report.feedback.what}\n\n${report.feedback.text}`);
|
| 663 |
fd.append("report", JSON.stringify(report)); fd.append("attachment", blob, `katip-rapor-${id}.zip`);
|
| 664 |
const r = await fetch(CONFIG.reportEndpoint, { method: "POST", body: fd, headers: { Accept: "application/json" } });
|
|
@@ -673,8 +810,9 @@ $("rSend").onclick = async () => {
|
|
| 673 |
[samples, models, version] = await Promise.all(["samples/manifest.json", "models/registry.json", "version.json"].map(async u => {
|
| 674 |
const r = await fetch(u); if (!r.ok) throw new Error(`${u}: HTTP ${r.status}`); return r.json(); }));
|
| 675 |
} catch (e) { status(t("error") + e.message); return; }
|
| 676 |
-
$("version").textContent = `Katip
|
| 677 |
if (version.source) $("srcLink").href = version.source;
|
| 678 |
-
buildColorUI(); buildSampleUI(); buildModelUI(); buildEntityUI(); applyLang();
|
| 679 |
-
|
|
|
|
| 680 |
})();
|
|
|
|
| 12 |
const I18N = {
|
| 13 |
tr: {
|
| 14 |
tagline: "Türkçe KVKK maskeleme, tamamen tarayıcınızda. Belge hiçbir sunucuya yüklenmez.",
|
| 15 |
+
netLbl: "Belge açıldıktan sonra ağ isteği", netTip: "Belgeniz bu sekmede açılır, taranır ve maskelenir; hiçbir sunucuya yüklenmez.||Sayfanın güvenlik politikası (CSP) başka bir adrese veri gönderilmesini engeller ve hiçbir şey kaydedilmez: sekmeyi kapatınca belge silinir. Belge açıldıktan sonraki ağ isteği sayacı Gelişmiş ayarlarda görünür ve 0 kalmalıdır.",
|
| 16 |
cspLbl: "Dışarı gönderim", blocked: "engelli", cspTip: "Bu sayfa belgenizi başka bir yere gönderemez.||Sayfanın güvenlik politikası (CSP), tarayıcının başka bir adrese veri göndermesini engeller.",
|
| 17 |
storeLbl: "Kayıt", none: "yok", storeTip: "Hiçbir şey kaydedilmez; sekmeyi kapatınca belge silinir.||Belge yalnızca bu sekmenin belleğinde tutulur.",
|
| 18 |
docH: "Belge", sampleTab: "Örnek belge", fileTab: "Kendi belgem",
|
| 19 |
sampleHint: "Sentetik belgeler, cevap anahtarıyla puanlanır.",
|
| 20 |
sampleTip: "Örneklerdeki kişiler uydurmadır. Doğru cevaplar bilindiği için kaçanları da görürsünüz.||Bulunan, kaçan ve fazladan işaretlenen değerler cevap anahtarına göre canlı sayılır.",
|
| 21 |
+
dropH: "Belgeyi buraya bırakın veya seçin", fileHint: "Dosya yalnızca bu sekmenin belleğinde kalır.",
|
| 22 |
fileTip: "Dosyanız bilgisayarınızdan çıkmaz.||Açma, tarama, maskeleme ve kontrol bu sekmede yapılır; sekmeyi kapatınca silinir.",
|
| 23 |
detectH: "Algılama", ocrH: "OCR (tarama ve fotoğraf)",
|
| 24 |
ocrTip: "Taranmış belge ve fotoğrafları okuyan model. Önerilen yeterlidir.||Türkçe Tesseract modeli. 96 taranmış örnekte ikisi neredeyse aynı değerleri buldu; önerilen 4 kat küçüktür çünkü yalnızca yeni (LSTM) okuyucuyu içerir.",
|
| 25 |
+
thrH: "Güven eşiği", thrTip: "Düşük: daha çok yakalar, daha çok yanlış alarm. Yüksek: tersi. 0,70 önerilir.||T.C. kimlik, VKN, IBAN gibi sağlama kontrollü numaralar her zaman yakalanır.",
|
| 26 |
+
typesH: "Veri türleri", maskH: "Maske", colorH: "Maske rengi", styleH: "Word ve metinde",
|
| 27 |
styleTip: "Değerin yerine ne konacağı: kutu, ████ veya [AD SOYAD-1] gibi etiket.||PDF ve görüntülerde seçilen renkte kutu çizilir. Word, UDF ve metinde değer ██████ veya etiketle değiştirilir.",
|
| 28 |
styleBox: "██████ blok", styleLabel: "[AD SOYAD-1] etiket",
|
| 29 |
+
unreadItem: "Sayfa {p} · alan {i}", addrH: "Adresler", addrStreet: "Sokak görünür, bina/daire gizli", addrFull: "Adresin tamamı gizli",
|
| 30 |
+
addrTip: "Mahalle, cadde/sokak, ilçe ve il kişisel veri değildir; bina adı, blok, kapı ve daire numarası kişiyi gösterir.||Varsayılan: Alparslan Türkeş Cad. gibi sokak adları görünür kalır, yalnızca bina, blok, kat, kapı ve daire maskelenir. Sokak adları hiçbir zaman kişi adı sayılmaz. Tüm adresi gizlemek için \"Adresin tamamı gizli\"yi seçin.",
|
| 31 |
tipMore: "Ayrıntılar",
|
| 32 |
markH: "Ortaya Katip logosu", markTip: "Her sayfanın ortasına soluk bir Katip logosu basar.||PDF ve görüntülerde. Kapalıyken alttaki tek satırlık filigran yine eklenir.",
|
| 33 |
+
wmH: "Filigran ekle", wmTip: "Çıktının altına küçük bir \"Katip Mask ile maskelendi\" satırı ekler.||Tam metin: \"{wm}\". PDF'te bağlantılıdır. UYAP .udf dosyalarına eklenmez.",
|
| 34 |
+
viewReview: "Orijinal", viewResult: "Maskeli", apply: "Maskeleri uygula", reapply: "Yeniden uygula", download: "Maskeli belgeyi indir", report: "Hata bildir",
|
| 35 |
copy: "Etiketli metni kopyala", forget: "Belgeyi unut",
|
| 36 |
empty: "Soldan bir örnek seçin veya kendi belgenizi açın.",
|
| 37 |
+
termPh: "Kaçan bir şey mi var? Buraya yazın", add: "Ekle",
|
| 38 |
findTip: "İşaretini kaldırdığınız değer maskelenmez.||Belgedeki işarete tıklamak da aç/kapa yapar. ⚑ o değeri hata bildirimine ekler.",
|
| 39 |
+
found: "{n} kişisel veri karartıldı", missedH: "Kaçanlar", addMissed: "Maskeye ekle", flag: "Hata bildirimine ekle", warnOnly: "uyarı",
|
| 40 |
details: "Ayrıntılar",
|
| 41 |
sRecall: "bulundu", sFp: "fazla", sLeak: "çıktıda okunabilir", sNotApplied: "Değişiklikler henüz uygulanmadı",
|
| 42 |
sPreview: "Henüz maskelenmedi: işaretleri kontrol edin, sonra Maskeleri uygula'ya basın.",
|
| 43 |
+
leakBad: "Dikkat: {n} değer çıktıda hâlâ okunabiliyor. Bu dosyayı göndermeyin.", leakUnchecked: "Taranmış sayfalar tekrar okunmadı; indirmeden önce gözle kontrol edin.",
|
| 44 |
+
leakOk: "Kontrol edildi: karartılan değerlerin hiçbiri çıktıda okunamıyor.",
|
| 45 |
+
loading: "Katip hazırlanıyor… (ilk açılışta birkaç saniye)", ready: "Katip hazır.", preparing: "Hazırlanıyor…", done: "{n} kişisel veri karartıldı.", copied: "Etiketli metin kopyalandı.", forgotten: "Belge bellekten silindi.",
|
| 46 |
+
waiting: "Katip hazırlanıyor; hazır olunca belge kendiliğinden taranır.", toScan: "Önizleme hazır. Kişisel verileri bul'a basın.", listHint: "Kişisel verileri bul'a basınca bulunan değerler burada türlerine göre listelenir; her birini açıp kapatabilirsiniz.", scan: "Kişisel verileri bul", rescan: "Yeniden tara",
|
| 47 |
error: "Hata: ", page: "Sayfa", ok: "doğru", fpL: "fazla", missL: "kaçan",
|
| 48 |
tType: "Tür", tGold: "Doğru cevap", tFound: "Bulunan", tMissed: "Kaçan", tFp: "Fazla", partial: "kısmen: yalnız \"{p}\"",
|
| 49 |
goldNote: "Doğru cevaplar belgeyi üreten betikten gelir. Taranmış belgelerde OCR hataları da puanı etkiler.",
|
| 50 |
kModel: "Model", kOcr: "OCR modeli", kPages: "Sayfa", kParse: "Okuma ve OCR", kRules: "Kurallar", kRedact: "Maskeleme ve kontrol", kVersion: "Motor sürümü", unused: "kullanılmadı",
|
| 51 |
+
rWhatWrong: "Konu", rMissed: "Bir şey açık kaldı", rFp: "Gereksiz karartıldı", rBroken: "Dosya açılmıyor", rOther: "Diğer",
|
| 52 |
+
rWhere: "Hangi değer veya yer? (isteğe bağlı)", rDesc: "Mesajınız", rDescPh: "Ne işe yaradı, ne eksik, neyi farklı yapardınız?", rInclude: "Rapora eklenecekler",
|
| 53 |
rIncSettings: "Ayarlar, sürüm, süreler, puanlar", rIncMasked: "Maskeli çıktı", rIncDet: "Bulunan değerler", rIncOrig: "Orijinal belge",
|
| 54 |
+
rPersonal: "(kişisel veri içerir)", rPersonalOk: "(kişisel veri içerir; yalnızca izniniz varsa)", cancel: "Vazgeç", rSend: "Doğrudan gönder", rMail: "E-postayla gönder",
|
| 55 |
+
rHowMail: "E-posta uygulamanız {to} adresine hazır bir mesajla açılır. Belgeyle ilgili bir sorunsa küçük bir rapor (.zip) da indirilir; onu ekleyin. Kişisel veri içeren hiçbir şey siz seçmeden eklenmez.",
|
| 56 |
rHowSend: "\"Doğrudan gönder\" raporu {host} üzerinden iletir. Ya da raporu indirip e-postayla kendiniz gönderin.",
|
| 57 |
rDownloaded: "{f} indirildi. E-posta açılmadıysa {to} adresine dosyayı ekleyerek yazın.", rSending: "Gönderiliyor…", rSent: "Gönderildi, teşekkürler.", rFailed: "Gönderilemedi",
|
| 58 |
+
foot: "Açık kaynak · tamamen cihazınızda çalışır", source: "Kaynak kod", issues: "Soru ve öneriler",
|
| 59 |
onSpace: "Bu sayfa için çok büyük (110 MB) · Hugging Face'te deneyin →",
|
| 60 |
+
ocrAuto: "Otomatik (el yazısı için PaddleOCR)", ocrAutoMeta: "El yazılı ve fotoğraflı sayfalar PaddleOCR, basılı taramalar Tesseract · +13 MB",
|
| 61 |
+
ocrAutoTip: "Mavi tükenmez kalem izi olan ya da fotoğraftan kesilmiş sayfalar PaddleOCR ile, diğerleri K��çük Tesseract ile okunur.||36 sentetik el yazılı kira sözleşmesi fotoğrafında PaddleOCR hiç TC kimlik numarası ve isim kaçırmadı (Tesseract: 72 TC numarasından 12'si). PaddleOCR basılı taramalarda Tesseract'tan iyi değil, o yüzden onlara karışmaz. Model ilk el yazılı sayfada bu siteden bir kez yüklenir.",
|
| 62 |
ocrBest: "Küçük", ocrBestMeta: "Aynı doğruluk, 4 kat küçük · 2 MB", ocrFast: "Standart", ocrFastMeta: "Eski motor verisiyle · 7,7 MB",
|
| 63 |
origAlt: "Orijinal sayfa", maskAlt: "Maskeli sayfa", ocrText: "Okunan metin",
|
| 64 |
+
trust: "Cihazınızda çalışır · belgeniz bilgisayarınızdan çıkmaz", feedback: "Görüş bildir", feedbackH: "Görüşünüz bizim için çok değerli", hero: "Belgedeki kişisel verileri saniyeler içinde maskeleyin.", lead: "Ad, T.C. kimlik, adres, telefon, IBAN, sağlık bilgisi… Katip bulur ve karartır; siz kontrol edip indirirsiniz.", dropFormats: "PDF · fotoğraf · Word · UYAP .udf · metin", tryH: "Elinizde belge yok mu? Örnekle deneyin:", allSamples: "Tüm örnekler", openSample: "Aç", step1: "Belgeyi bırakın", step2: "Karartılanları kontrol edin", step3: "Maskeli halini indirin", newDoc: "← Yeni belge", listHint2: "Tik kaldırdığınız değer açık kalır. Belgedeki işarete tıklamak da aynı işi görür.", advH: "Gelişmiş ayarlar", modelH: "Algılama modeli", scanning: "Kişisel veriler aranıyor…", applying: "Karartılıyor…", sampleNote: "Örnek belge (kişiler uydurma). Cevap anahtarı:", leakFix: "Bunları da karart", qKira: "Kira sözleşmesi · fotoğraf", qDilekce: "Dava dilekçesi · UYAP .udf", qBilirkisi: "Bilirkişi raporu · tarama", qIs: "İş sözleşmesi · Word", rGeneral: "Genel görüş", rHowGeneral: "E-posta uygulamanız {to} adresine hazır bir mesajla açılır. Siz göndermeden hiçbir şey çıkmaz.",
|
| 65 |
+
profH: "Ne için maskeliyorsunuz?", pCourt: "Mahkemeye sunum", pAi: "Yapay zekâ ile paylaşım", pAnon: "Tam anonim", pCustom: "Özel seçim", pCourtHint: "Kişisel verilerin tamamı karartılır; sokak, dosya numarası ve tarihler okunur kalır. Delil ve dilekçe ekleri için.", pAiHint: "Buna ek olarak adresin tamamı, esas/karar ve UYAP numaraları, baro sicil numaraları ve sabıka bilgisi karartılır.", pAnonHint: "Buna ek olarak tarihler, il ve ilçeler, mahkeme, şirket ve kurum adları da karartılır. Metin okunur, kim olduğu çıkmaz.", pCustomHint: "Veri türlerini kendiniz seçtiniz.", pickTypes: "Türleri tek tek seç", baCap: "Örnek dava dilekçesi (kişiler uydurma). Çizgiyi kaydırın.", baOpen: "Bu örneği aç →", sampleN: "örnek", baMadeUp: "kişiler uydurma", pickType: "Örnek belge türü", pickVariant: "Örnek numarası", baOrigAlt: "Örnek dilekçe, orijinal", baMaskAlt: "Aynı dilekçe, Katip ile maskelenmiş", baAria: "Orijinal ile maskeli arasında kaydır",
|
| 66 |
},
|
| 67 |
en: {
|
| 68 |
tagline: "Turkish KVKK redaction that runs entirely in your browser. Nothing is uploaded.",
|
| 69 |
+
netLbl: "Network requests after opening the document", netTip: "Your document is opened, scanned and masked in this tab; it is never uploaded.||The page's Content-Security-Policy blocks sending data anywhere else, and nothing is stored: closing the tab deletes it. The counter of requests after opening a document is under Advanced settings and should stay 0.",
|
| 70 |
cspLbl: "Sending out", blocked: "blocked", cspTip: "This page cannot send your document anywhere.||The page's Content-Security-Policy makes the browser block any request to another address.",
|
| 71 |
storeLbl: "Stored", none: "nothing", storeTip: "Nothing is saved; closing the tab deletes the document.||The document lives only in this tab's memory.",
|
| 72 |
docH: "Document", sampleTab: "Sample", fileTab: "My file",
|
| 73 |
sampleHint: "Synthetic documents, scored against an answer key.",
|
| 74 |
sampleTip: "The people in the samples are made up. The right answers are known, so you also see what was missed.||Found, missed and extra values are counted live against the answer key.",
|
| 75 |
+
dropH: "Drop a document here or choose one", fileHint: "The file stays in this tab's memory only.",
|
| 76 |
fileTip: "Your file never leaves your computer.||Opening, scanning, masking and checking all happen in this tab; closing the tab deletes it.",
|
| 77 |
detectH: "Detection", ocrH: "OCR (scans and photos)",
|
| 78 |
ocrTip: "The model that reads scans and photos. The recommended one is enough.||Turkish Tesseract model. On 96 scanned samples both found almost the same values; the recommended one is 4× smaller because it holds only the newer (LSTM) reader.",
|
| 79 |
+
thrH: "Confidence threshold", thrTip: "Lower catches more but raises more false alarms. Higher does the opposite. 0.70 is recommended.||Numbers with a checksum (T.C. kimlik, VKN, IBAN) are always caught.",
|
| 80 |
+
typesH: "Data types", maskH: "Mask", colorH: "Mask colour", styleH: "In Word and text",
|
| 81 |
styleTip: "What replaces each value: a box, ████ or a label such as [AD SOYAD-1].||PDFs and images get a box in the chosen colour. In Word, UDF and text the value is replaced with ██████ or a label.",
|
| 82 |
styleBox: "██████ block", styleLabel: "[AD SOYAD-1] label",
|
| 83 |
+
unreadItem: "Page {p} · area {i}", addrH: "Addresses", addrStreet: "Street visible, building/flat hidden", addrFull: "Whole address hidden",
|
| 84 |
+
addrTip: "Neighbourhood, street, district and province are not personal data; building name, block, door and flat number point to a person.||Default: street names such as Alparslan Türkeş Cad. stay readable and only the building, block, floor, door and flat are masked. Street names are never treated as people's names. Pick \"Whole address hidden\" to mask all of it.",
|
| 85 |
tipMore: "Details",
|
| 86 |
markH: "Katip logo in the centre", markTip: "Stamps a faint Katip logo across the middle of each page.||PDFs and images only. Off, the one-line watermark at the bottom is still added.",
|
| 87 |
+
wmH: "Add watermark", wmTip: "Adds a small \"Redacted with Katip Mask\" line at the bottom.||Full text: \"{wm}\". Linked in PDFs. Not added to UYAP .udf files.",
|
| 88 |
+
viewReview: "Original", viewResult: "Masked", apply: "Apply masks", reapply: "Apply again", download: "Download masked file", report: "Report a mistake",
|
| 89 |
copy: "Copy labelled text", forget: "Forget document",
|
| 90 |
empty: "Pick a sample on the left or open your own file.",
|
| 91 |
+
termPh: "Something missed? Type it here", add: "Add",
|
| 92 |
findTip: "Untick a value and it will not be masked.||Clicking a highlight in the document toggles it too. ⚑ adds the value to a mistake report.",
|
| 93 |
+
found: "{n} personal data items masked", missedH: "Missed", addMissed: "Add to masks", flag: "Add to mistake report", warnOnly: "flag",
|
| 94 |
details: "Details",
|
| 95 |
sRecall: "found", sFp: "extra", sLeak: "readable in output", sNotApplied: "Changes not applied yet",
|
| 96 |
sPreview: "Nothing is masked yet: check the highlights, then press Apply masks.",
|
| 97 |
+
leakBad: "Warning: {n} values are still readable in the output. Do not send this file.", leakUnchecked: "Scanned pages were not re-read; check by eye before downloading.",
|
| 98 |
+
leakOk: "Checked: none of the masked values can be read in the output.",
|
| 99 |
+
loading: "Getting Katip ready… (a few seconds the first time)", ready: "Katip is ready.", preparing: "Preparing…", done: "{n} personal data items masked.", copied: "Labelled text copied.", forgotten: "Document removed from memory.",
|
| 100 |
+
waiting: "Getting Katip ready; the document is scanned as soon as it is.", toScan: "Preview ready. Press Find personal data.", listHint: "After Find personal data, the values found are listed here by type; you can switch each one on or off.", scan: "Find personal data", rescan: "Scan again",
|
| 101 |
error: "Error: ", page: "Page", ok: "correct", fpL: "extra", missL: "missed",
|
| 102 |
tType: "Type", tGold: "Answer key", tFound: "Found", tMissed: "Missed", tFp: "Extra", partial: "partly: only \"{p}\"",
|
| 103 |
goldNote: "The answer key comes from the script that generated the document. On scans, OCR errors also affect the score.",
|
| 104 |
kModel: "Model", kOcr: "OCR model", kPages: "Pages", kParse: "Reading and OCR", kRules: "Rules", kRedact: "Masking and check", kVersion: "Engine version", unused: "not used",
|
| 105 |
+
rWhatWrong: "Topic", rMissed: "Something was left visible", rFp: "Masked by mistake", rBroken: "File won't open", rOther: "Other",
|
| 106 |
+
rWhere: "Which value or place? (optional)", rDesc: "Your message", rDescPh: "What helped, what is missing, what would you do differently?", rInclude: "Include in the report",
|
| 107 |
rIncSettings: "Settings, version, timings, scores", rIncMasked: "Masked output", rIncDet: "Detected values", rIncOrig: "Original document",
|
| 108 |
+
rPersonal: "(contains personal data)", rPersonalOk: "(contains personal data; only with permission)", cancel: "Cancel", rSend: "Send directly", rMail: "Send by e-mail",
|
| 109 |
+
rHowMail: "Your e-mail app opens a ready message to {to}. For a problem with a document a small report (.zip) is also downloaded; attach it. Nothing with personal data is added unless you tick it.",
|
| 110 |
rHowSend: "\"Send directly\" delivers the report through {host}. Or download it and e-mail it yourself.",
|
| 111 |
rDownloaded: "{f} downloaded. If no e-mail opened, write to {to} and attach the file.", rSending: "Sending…", rSent: "Sent, thank you.", rFailed: "Could not send",
|
| 112 |
+
foot: "Open source · runs entirely on your device", source: "Source code", issues: "Questions and ideas",
|
| 113 |
onSpace: "Too big for this page (110 MB) · try it on Hugging Face →",
|
| 114 |
+
ocrAuto: "Automatic (PaddleOCR for handwriting)", ocrAutoMeta: "Handwritten and photographed pages: PaddleOCR; printed scans: Tesseract · +13 MB",
|
| 115 |
+
ocrAutoTip: "Pages with blue ballpoint ink, or a sheet cut out of a photo, are read with PaddleOCR; the rest with Compact Tesseract.||On 36 synthetic photos of hand-filled lease contracts PaddleOCR missed no TC number and no name (Tesseract: 12 of 72 TC numbers). PaddleOCR is not better than Tesseract on printed scans, so it leaves those alone. The model loads once from this site, on the first handwritten page.",
|
| 116 |
ocrBest: "Compact", ocrBestMeta: "Same accuracy, 4× smaller · 2 MB", ocrFast: "Standard", ocrFastMeta: "Includes old engine data · 7.7 MB",
|
| 117 |
origAlt: "Original page", maskAlt: "Masked page", ocrText: "Recognised text",
|
| 118 |
+
trust: "Runs on your device · your file never leaves it", feedback: "Send feedback", feedbackH: "We'd love your feedback", hero: "Mask personal data in legal documents in seconds.", lead: "Names, ID numbers, addresses, phones, IBANs, health details… Katip finds and blacks them out; you check and download.", dropFormats: "PDF · photo · Word · UYAP .udf · text", tryH: "No document at hand? Try a sample:", allSamples: "All samples", openSample: "Open", step1: "Drop the document", step2: "Check what's masked", step3: "Download the masked copy", newDoc: "← New document", listHint2: "Untick a value to leave it visible. Clicking a highlight in the document does the same.", advH: "Advanced settings", modelH: "Detection model", scanning: "Looking for personal data…", applying: "Masking…", sampleNote: "Sample document (made-up people). Answer key:", leakFix: "Mask these too", qKira: "Lease · photo", qDilekce: "Petition · UYAP .udf", qBilirkisi: "Expert report · scan", qIs: "Employment contract · Word", rGeneral: "General feedback", rHowGeneral: "Your e-mail app opens a ready message to {to}. Nothing leaves until you send it.",
|
| 119 |
+
profH: "What are you masking it for?", pCourt: "Court filing", pAi: "Sharing with AI", pAnon: "Fully anonymous", pCustom: "Custom", pCourtHint: "All personal data is masked; streets, case numbers and dates stay readable. For evidence and petition attachments.", pAiHint: "On top of that, the whole address, case and UYAP numbers, bar registry numbers and criminal records are masked.", pAnonHint: "On top of that, dates, provinces and districts, and court, company and institution names are masked too. The text reads, the people can't be traced.", pCustomHint: "You picked the data types yourself.", pickTypes: "Pick types one by one", baCap: "Sample petition (made-up people). Drag the line.", baOpen: "Open this sample →", sampleN: "sample", baMadeUp: "made-up people", pickType: "Sample document type", pickVariant: "Sample number", baOrigAlt: "Sample petition, original", baMaskAlt: "The same petition masked by Katip", baAria: "Slide between original and masked",
|
| 120 |
},
|
| 121 |
};
|
| 122 |
const LABELS = {
|
| 123 |
tr: { TCKN: "T.C. Kimlik No", VKN: "Vergi Kimlik No", IBAN: "IBAN", CREDIT_CARD: "Kredi kartı", PHONE: "Telefon", EMAIL: "E-posta", PLATE: "Araç plakası",
|
| 124 |
+
PERSON: "Ad soyad", ADDRESS: "Adres", BIRTH_DATE: "Doğum tarihi", IP_ADDRESS: "IP adresi", YKN: "Yabancı kimlik no", PASSPORT: "Pasaport no", FOREIGN_ID: "Yabancı ülke kimlik no", HEALTH: "Sağlık", RELIGION: "Din/inanç", POLITICAL: "Siyasi görüş",
|
| 125 |
+
UNION: "Sendika", ETHNICITY: "Etnik köken", SEXUAL_LIFE: "Cinsel hayat", BIOMETRIC_GENETIC: "Biyometrik/genetik", CRIMINAL_RECORD: "Ceza mahkumiyeti", UNREAD: "El yazısı alanı",
|
| 126 |
+
CASE_NO: "Esas/karar/UYAP no", BAR_NO: "Baro/sicil no", DATE: "Tarih", PLACE: "İl/ilçe", INSTITUTION: "Mahkeme/kurum/şirket", CUSTOM: "Eklenen terim" },
|
| 127 |
en: { TCKN: "Turkish ID no.", VKN: "Tax ID", IBAN: "IBAN", CREDIT_CARD: "Card number", PHONE: "Phone", EMAIL: "E-mail", PLATE: "Licence plate",
|
| 128 |
+
PERSON: "Name", ADDRESS: "Address", BIRTH_DATE: "Birth date", IP_ADDRESS: "IP address", YKN: "Foreigner ID no.", PASSPORT: "Passport no.", FOREIGN_ID: "Foreign national ID", HEALTH: "Health", RELIGION: "Religion/belief", POLITICAL: "Political opinion",
|
| 129 |
+
UNION: "Trade union", ETHNICITY: "Ethnic origin", SEXUAL_LIFE: "Sex life", BIOMETRIC_GENETIC: "Biometric/genetic", CRIMINAL_RECORD: "Criminal record", UNREAD: "Handwritten area",
|
| 130 |
+
CASE_NO: "Case/UYAP no.", BAR_NO: "Bar/registry no.", DATE: "Date", PLACE: "Province/district", INSTITUTION: "Court/institution/company", CUSTOM: "Added term" },
|
| 131 |
};
|
| 132 |
const FORMATS = { tr: { txt: "Metin", docx: "Word", udf: "UYAP .udf", pdf: "PDF", "scan.pdf": "Taranmış PDF", png: "Fotoğraf" },
|
| 133 |
en: { txt: "Text", docx: "Word", udf: "UYAP .udf", pdf: "PDF", "scan.pdf": "Scanned PDF", png: "Photo" } };
|
|
|
|
| 135 |
fatura: "Invoice", icra: "Enforcement request", ihtarname: "Formal notice", is_sozlesmesi: "Employment contract", karar: "Court judgment", kira_sozlesmesi: "Lease",
|
| 136 |
mektup: "Letter", mesaj: "Chat log", tutanak: "Minutes", vekaletname: "Power of attorney", zabit: "Hearing record" };
|
| 137 |
const PROGRESS_EN = [[/^NER modeli yükleniyor/, "Loading name model"], [/^NER çalışıyor/, "Running name model"], [/^OCR sayfa (\d+)\/(\d+)/, "OCR page $1/$2"], [/^Sayfa (\d+)\/(\d+)/, "Page $1/$2"],
|
| 138 |
+
[/^OCR$/, "OCR"], [/^Kişisel veriler aranıyor/, "Looking for personal data"], [/^Maskeleniyor/, "Masking"], [/tekrar okunuyor/, "Re-reading output (leak check)"], [/^El yazısı OCR modeli yükleniyor/, "Loading handwriting OCR model"]];
|
| 139 |
const ART6 = ["HEALTH", "RELIGION", "POLITICAL", "UNION", "ETHNICITY", "SEXUAL_LIFE", "BIOMETRIC_GENETIC", "CRIMINAL_RECORD"];
|
| 140 |
+
// Masking profiles: one click sets the data types and the address rule; the checkboxes stay for fine control.
|
| 141 |
+
// A ladder: each profile masks everything the one before it does, plus more.
|
| 142 |
+
const CASE = ["CASE_NO", "BAR_NO"], ANON = ["DATE", "PLACE", "INSTITUTION"];
|
| 143 |
+
const PERSONAL = () => Object.keys(LABELS.tr).filter(k => k !== "CUSTOM" && !CASE.includes(k) && !ANON.includes(k));
|
| 144 |
+
const PROFILES = {
|
| 145 |
+
court: { name: "pCourt", hint: "pCourtHint", addr: "street", types: () => PERSONAL().filter(k => k !== "CRIMINAL_RECORD") },
|
| 146 |
+
ai: { name: "pAi", hint: "pAiHint", addr: "full", types: () => [...PERSONAL(), ...CASE] },
|
| 147 |
+
anon: { name: "pAnon", hint: "pAnonHint", addr: "full", types: () => [...PERSONAL(), ...CASE, ...ANON] },
|
| 148 |
+
};
|
| 149 |
+
let profile = "court";
|
| 150 |
const COLORS = [["#000000", "Siyah / Black"], ["#3a3a3a", "Grafit / Graphite"], ["#1f3a5f", "Lacivert / Navy"], ["#1f5f4a", "Yeşil / Green"], ["#7a1f1f", "Bordo / Maroon"], ["#ffffff", "Beyaz / White"]];
|
| 151 |
|
| 152 |
let lang = (() => {
|
|
|
|
| 169 |
|
| 170 |
// ---------------------------------------------------------------- network watch
|
| 171 |
let docOpenedAt = Infinity, netCount = 0;
|
| 172 |
+
const runtimeFile = u => /\/(vendor|models|samples|hero)\/|katip_py\.zip|\.(m?js|css|wasm|json)(\?|$)/.test(u);
|
| 173 |
new PerformanceObserver(list => {
|
| 174 |
for (const e of list.getEntries()) if (e.startTime >= docOpenedAt && !runtimeFile(e.name)) netCount++;
|
| 175 |
$("netCount").textContent = netCount; $("netPill").classList.toggle("bad", netCount > 0);
|
|
|
|
| 234 |
document.documentElement.lang = lang;
|
| 235 |
document.querySelectorAll("[data-i18n]").forEach(e => (e.textContent = t(e.dataset.i18n)));
|
| 236 |
document.querySelectorAll("[data-i18n-ph]").forEach(e => (e.placeholder = t(e.dataset.i18nPh)));
|
| 237 |
+
document.querySelectorAll("[data-i18n-alt]").forEach(e => (e.alt = t(e.dataset.i18nAlt)));
|
| 238 |
+
document.querySelectorAll("[data-i18n-aria]").forEach(e => e.setAttribute("aria-label", t(e.dataset.i18nAria)));
|
| 239 |
document.querySelectorAll("[data-i18n-tip]").forEach(e => { const s = t(e.dataset.i18nTip, { wm: wmText() }); e.dataset.tip = s; e.setAttribute("aria-label", s.split("||")[0]); e.setAttribute("role", "button"); });
|
| 240 |
document.querySelectorAll("[data-lang]").forEach(b => b.setAttribute("aria-pressed", b.dataset.lang === lang));
|
| 241 |
+
document.title = lang === "tr" ? "Katip Mask: belgedeki kişisel verileri maskeleyin" : "Katip Mask: mask personal data in documents";
|
| 242 |
+
if (samples.length) { drawPicker(); buildModelUI(); buildEntityUI(); buildProfileUI(); }
|
| 243 |
updateButtons(); if (res?.spans) render(); else if (preview) renderPreview();
|
| 244 |
}
|
| 245 |
document.querySelectorAll("[data-lang]").forEach(b => (b.onclick = () => {
|
|
|
|
| 250 |
|
| 251 |
// ---------------------------------------------------------------- settings
|
| 252 |
const selModel = () => document.querySelector("#models input:checked")?.value ?? models.find(m => !m.dev && !m.disabled && !m.elsewhere)?.id;
|
| 253 |
+
const selOcr = () => document.querySelector("#ocrModels input:checked")?.value ?? "auto";
|
| 254 |
function settings() {
|
| 255 |
const m = models.find(x => x.id === selModel()) ?? models[0];
|
| 256 |
return {
|
| 257 |
engine: m?.id, engineName: loc(m?.name), ner: m?.ner ?? null, baseline: m?.baseline ?? null, nerDtype: m?.dtype ?? "q8", trust: m?.trust ?? null, ocrLang: selOcr(),
|
| 258 |
+
threshold: Number($("threshold").value), style: $("style").value, addressDetail: $("addrDetail").value,
|
| 259 |
entities: [...document.querySelectorAll("#entities input:checked")].map(i => i.value), mask: maskTerms,
|
| 260 |
};
|
| 261 |
}
|
|
|
|
| 274 |
$("models").append(card);
|
| 275 |
}
|
| 276 |
$("ocrModels").textContent = "";
|
| 277 |
+
$("ocrModels").append(optCard("ocr", "auto", t("ocrAuto"), t("ocrAutoMeta"), t("ocrAutoTip"), curOcr === "auto", lang === "tr" ? "önerilen" : "recommended"),
|
| 278 |
+
optCard("ocr", "best_int", t("ocrBest"), t("ocrBestMeta"), t("ocrTip"), curOcr === "best_int"),
|
| 279 |
optCard("ocr", "fast", t("ocrFast"), t("ocrFastMeta"), t("ocrTip"), curOcr === "fast"));
|
| 280 |
}
|
| 281 |
$("models").onchange = () => res?.spans && redetect();
|
|
|
|
| 285 |
$("entities").textContent = "";
|
| 286 |
for (const k of Object.keys(LABELS.tr)) {
|
| 287 |
if (k === "CUSTOM") continue;
|
| 288 |
+
const cb = el("input", { type: "checkbox", value: k, checked: prev.length ? on.has(k) : PROFILES.court.types().includes(k) });
|
| 289 |
$("entities").append(el("label", {}, cb, " " + L(k) + (ART6.includes(k) ? " ·6" : "")));
|
| 290 |
}
|
| 291 |
entCount();
|
| 292 |
}
|
| 293 |
+
$("entities").onchange = () => { entCount(); syncProfile(); res?.spans && redetect(); };
|
| 294 |
+
function buildProfileUI() {
|
| 295 |
+
$("profiles").textContent = "";
|
| 296 |
+
for (const [id, p] of Object.entries(PROFILES)) {
|
| 297 |
+
const b = el("button", { type: "button", textContent: t(p.name) }); b.dataset.p = id; b.setAttribute("role", "radio");
|
| 298 |
+
b.onclick = () => setProfile(id); $("profiles").append(b);
|
| 299 |
+
}
|
| 300 |
+
syncProfile();
|
| 301 |
+
}
|
| 302 |
+
function setProfile(id) {
|
| 303 |
+
const p = PROFILES[id], types = new Set(p.types());
|
| 304 |
+
document.querySelectorAll("#entities input").forEach(i => (i.checked = types.has(i.value)));
|
| 305 |
+
$("addrDetail").value = p.addr; entCount(); syncProfile();
|
| 306 |
+
if (res?.spans) redetect();
|
| 307 |
+
}
|
| 308 |
+
function syncProfile() {
|
| 309 |
+
const on = new Set([...document.querySelectorAll("#entities input:checked")].map(i => i.value));
|
| 310 |
+
profile = Object.keys(PROFILES).find(id => { const ty = PROFILES[id].types(); return PROFILES[id].addr === $("addrDetail").value && ty.length === on.size && ty.every(k => on.has(k)); }) ?? "custom";
|
| 311 |
+
document.querySelectorAll("#profiles button").forEach(b => b.setAttribute("aria-checked", b.dataset.p === profile));
|
| 312 |
+
const h = $("profHint"); h.textContent = (profile === "custom" ? t("pCustomHint") : t(PROFILES[profile].hint)) + " ";
|
| 313 |
+
const pick = el("button", { type: "button", className: "linkbtn", textContent: t("pickTypes") });
|
| 314 |
+
pick.onclick = () => { $("adv").open = true; document.querySelector("#adv details.ents").open = true; document.querySelector("#adv details.ents").scrollIntoView({ block: "center", behavior: "smooth" }); };
|
| 315 |
+
h.append(pick);
|
| 316 |
+
}
|
| 317 |
function entCount() { $("entCount").textContent = `(${document.querySelectorAll("#entities input:checked").length}/${Object.keys(LABELS.tr).length - 1})`; }
|
| 318 |
function buildColorUI() {
|
| 319 |
for (const [hex, name] of COLORS) {
|
|
|
|
| 334 |
$("threshold").oninput = () => ($("thresholdVal").textContent = Number($("threshold").value).toFixed(2));
|
| 335 |
$("threshold").onchange = () => res?.spans && redetect();
|
| 336 |
$("style").onchange = () => maskOptionsChanged();
|
| 337 |
+
$("addrDetail").onchange = () => { syncProfile(); res?.spans && redetect(); };
|
| 338 |
$("watermark").onchange = () => maskOptionsChanged();
|
| 339 |
$("centerMark").onchange = () => maskOptionsChanged();
|
| 340 |
function maskOptionsChanged() { if (res?.spans && applied && !busy) redactNow(); }
|
| 341 |
|
| 342 |
// ---------------------------------------------------------------- sources
|
| 343 |
+
// ---- home: sample picker next to the before/after preview. Picking a type, variant or format runs the real pipeline
|
| 344 |
+
// in the background (same worker, nothing leaves the tab) and swaps the slider; "Bu örneği aç" then opens it instantly.
|
| 345 |
+
const sampleTitle = sm => lang === "en" ? SAMPLE_EN[sm.type] ?? sm.title : sm.title;
|
| 346 |
+
const types = () => [...new Map(samples.map(x => [x.type, x])).values()];
|
| 347 |
function buildSampleOptions() {
|
|
|
|
| 348 |
$("sampleType").textContent = "";
|
| 349 |
+
for (const sm of types()) $("sampleType").append(el("option", { value: sm.type, textContent: sampleTitle(sm) }));
|
| 350 |
+
if (src.sample) $("sampleType").value = src.sample.type;
|
| 351 |
+
}
|
| 352 |
+
function drawVariants() {
|
| 353 |
+
const v = $("variantBtns"); v.textContent = "";
|
| 354 |
+
for (const sm of samples.filter(x => x.type === src.sample.type)) {
|
| 355 |
+
const b = el("button", { type: "button", textContent: String(sm.n ?? sm.id.split("_").pop()), title: `${t("sampleN")} ${sm.n}` });
|
| 356 |
+
b.dataset.id = sm.id; b.setAttribute("aria-pressed", sm.id === src.sample.id);
|
| 357 |
+
b.onclick = () => { if (sm.id === src.sample.id) return; src.sample = sm; drawPicker(); previewSample(); };
|
| 358 |
+
v.append(b);
|
| 359 |
+
}
|
| 360 |
}
|
| 361 |
function drawFormats() {
|
| 362 |
const f = $("formatBtns"); f.textContent = "";
|
| 363 |
+
if (!src.sample.formats.includes(src.format)) src.format = src.sample.formats.includes("pdf") ? "pdf" : src.sample.formats[0];
|
| 364 |
for (const fmt of src.sample.formats) {
|
| 365 |
const b = el("button", { type: "button", textContent: FORMATS[lang][fmt] ?? fmt });
|
| 366 |
b.dataset.fmt = fmt; b.setAttribute("aria-pressed", fmt === src.format);
|
| 367 |
+
b.onclick = () => { if (src.format === fmt) return; src.format = fmt; drawFormats(); previewSample(); };
|
| 368 |
f.append(b);
|
| 369 |
}
|
| 370 |
}
|
| 371 |
+
function drawPicker() { buildSampleOptions(); drawVariants(); drawFormats(); baCaption(); }
|
| 372 |
+
function baCaption() { $("baCap").textContent = `${sampleTitle(src.sample)} · ${t("sampleN")} ${src.sample.n} · ${FORMATS[lang][src.format] ?? src.format} — ${t("baMadeUp")}`; }
|
| 373 |
+
const sameAsLoaded = () => loaded?.isSample && loaded.name === `${src.sample.id}.${src.format}`;
|
| 374 |
+
function previewSample() { baCaption(); src.mode = "sample"; loadSource({ stay: true }); }
|
| 375 |
+
function openSample(sm, fmt) {
|
| 376 |
+
src.mode = "sample"; src.sample = sm; src.format = fmt; drawPicker();
|
| 377 |
+
if (sameAsLoaded() && res) { showWork(true); render(); updateButtons(); status(busy ? $("status").textContent : ""); } else loadSource();
|
| 378 |
}
|
| 379 |
+
function buildSampleUI() {
|
| 380 |
+
src.sample = samples.find(x => x.id === "dilekce_01") ?? samples[0]; src.format = "png"; // matches the static hero images
|
| 381 |
+
$("sampleType").onchange = () => {
|
| 382 |
+
const keep = src.sample.n;
|
| 383 |
+
src.sample = samples.find(x => x.type === $("sampleType").value && x.n === keep) ?? samples.find(x => x.type === $("sampleType").value);
|
| 384 |
+
drawPicker(); previewSample();
|
| 385 |
+
};
|
| 386 |
+
$("openSample").onclick = () => openSample(src.sample, src.format);
|
| 387 |
+
drawPicker();
|
| 388 |
+
}
|
| 389 |
+
// the masked/original pair for the home slider, from whatever the pipeline produced
|
| 390 |
+
function drawBA() {
|
| 391 |
+
if (!res?.spans || !applied) return;
|
| 392 |
+
const O = $("baFrame").querySelector(".ba-o"), M = $("baFrame").querySelector(".ba-m");
|
| 393 |
+
O.textContent = ""; M.textContent = "";
|
| 394 |
+
if (res.kind === "pdf" && res.origPreviews?.[0] && res.maskPreviews?.[0]) {
|
| 395 |
+
O.append(el("img", { src: res.origPreviews[0], alt: t("baOrigAlt") })); M.append(el("img", { src: res.maskPreviews[0], alt: t("baMaskAlt") }));
|
| 396 |
+
} else if (res.kind === "image") {
|
| 397 |
+
O.append(el("img", { src: res.imageUrl, alt: t("baOrigAlt") })); M.append(el("img", { src: res.maskUrl, alt: t("baMaskAlt") }));
|
| 398 |
+
} else { // Word, UDF, text: the same text twice, the masked layer with the chosen values blacked out
|
| 399 |
+
const tx = res.text.slice(0, 2200), on = chosen().filter(x => x.end <= tx.length).sort((a, b) => a.start - b.start);
|
| 400 |
+
const plain = el("div", { className: "ba-txt", textContent: tx }), masked = el("div", { className: "ba-txt" });
|
| 401 |
+
let pos = 0;
|
| 402 |
+
for (const x of on) { if (x.start < pos) continue; masked.append(tx.slice(pos, x.start), el("span", { className: "bm", textContent: tx.slice(x.start, x.end) })); pos = x.end; }
|
| 403 |
+
masked.append(tx.slice(pos)); O.append(plain); M.append(masked);
|
| 404 |
+
}
|
| 405 |
+
$("baFrame").classList.remove("loading");
|
| 406 |
+
}
|
| 407 |
+
function pickFile(f) { if (!f) return; src.file = f; src.mode = "file"; loadSource(); }
|
| 408 |
$("drop").onclick = () => $("file").click();
|
| 409 |
$("drop").onkeydown = e => (e.key === "Enter" || e.key === " ") && $("file").click();
|
| 410 |
$("file").onchange = e => { pickFile(e.target.files[0]); e.target.value = ""; };
|
| 411 |
+
// the whole page accepts a dropped file, not only the box
|
| 412 |
+
addEventListener("dragover", e => { e.preventDefault(); $("drop").classList.add("over"); });
|
| 413 |
+
addEventListener("dragleave", e => { if (!e.relatedTarget) $("drop").classList.remove("over"); });
|
| 414 |
+
addEventListener("drop", e => { e.preventDefault(); $("drop").classList.remove("over"); if (!$("reportDlg").open) pickFile(e.dataTransfer.files[0]); });
|
| 415 |
+
|
| 416 |
+
// before/after slider on the home screen
|
| 417 |
+
$("baRange").oninput = () => $("ba").style.setProperty("--pos", $("baRange").value + "%");
|
| 418 |
+
function showWork(on) { $("home").classList.toggle("hidden", on); $("work").classList.toggle("hidden", !on); if (!on) window.scrollTo(0, 0); }
|
| 419 |
|
| 420 |
// Selecting a document shows it at once; analysis starts as soon as the engine is ready.
|
| 421 |
+
let stayHome = false;
|
| 422 |
+
async function loadSource({ stay = false } = {}) {
|
| 423 |
+
stayHome = stay; if (stay) $("baFrame").classList.add("loading");
|
| 424 |
const my = ++seq, isSample = src.mode === "sample";
|
| 425 |
let name, bytes, gold = null;
|
| 426 |
status(t("preparing"));
|
|
|
|
| 434 |
} catch (e) { if (my === seq) status(t("error") + (e?.message ?? e)); console.error(e); return; }
|
| 435 |
if (my !== seq) return;
|
| 436 |
loaded = { name, bytes, gold, isSample };
|
| 437 |
+
if (!stay) showWork(true);
|
| 438 |
+
$("docName").textContent = isSample ? `${sampleTitle(src.sample)} ${src.sample.n} · ${FORMATS[lang][src.format] ?? src.format}` : name;
|
| 439 |
res = null; applied = false; view = "review"; outBlob = null; busy = false;
|
| 440 |
preview = { seq: my, kind: /\.(png|jpe?g|tiff?|bmp|webp)$/i.test(name) ? "image" : null, text: null, pages: [], imageUrl: null };
|
| 441 |
if (preview.kind === "image") preview.imageUrl = URL.createObjectURL(new Blob([bytes]));
|
| 442 |
renderPreview();
|
| 443 |
if (preview.kind !== "image") send({ type: "preview", name, bytes: bytes.slice(), seq: my });
|
| 444 |
updateButtons();
|
| 445 |
+
if (ready) analyze(); else status(t("waiting"));
|
| 446 |
}
|
| 447 |
|
| 448 |
// ---------------------------------------------------------------- run
|
| 449 |
+
const status = m => { $("status").textContent = m; $("engineState").textContent = loaded && !stayHome ? "" : m; };
|
| 450 |
const progressText = m => { if (lang === "tr") return m; for (const [re, en] of PROGRESS_EN) if (re.test(m)) return m.replace(re, en); return m; };
|
| 451 |
function analyze() {
|
| 452 |
if (!loaded) return;
|
| 453 |
const s = settings(), { name, gold, isSample } = loaded, bytes = loaded.bytes.slice();
|
| 454 |
+
status(t("scanning"));
|
| 455 |
docOpenedAt = performance.now(); netCount = 0; $("netCount").textContent = "0"; $("netPill").classList.remove("bad");
|
| 456 |
res = { name, isSample, gold, settings: s, imageUrl: preview?.imageUrl ?? null, maskUrl: null, seq };
|
| 457 |
off = new Set(); applied = false; view = "review"; busy = true; updateButtons();
|
|
|
|
| 459 |
}
|
| 460 |
function redetect() { if (!res?.spans || busy) return; res.settings = settings(); busy = true; updateButtons(); worker.postMessage({ type: "redetect", settings: res.settings }); }
|
| 461 |
function redactNow() {
|
| 462 |
+
clearTimeout(reapplyTimer); busy = true; updateButtons(); status(t("applying"));
|
| 463 |
worker.postMessage({ type: "redact", spans: chosen(), style: $("style").value, verifyOcr: true, ocrLang: res.settings.ocrLang, color: rgb(maskColor),
|
| 464 |
watermark: $("watermark").checked && CONFIG.watermark ? { text: wmText(), url: CONFIG.watermark.url } : null,
|
| 465 |
mark: $("centerMark").checked });
|
| 466 |
}
|
| 467 |
+
let reapplyTimer = 0, dlAfter = false;
|
| 468 |
+
const scheduleReapply = () => { clearTimeout(reapplyTimer); reapplyTimer = setTimeout(() => res?.spans && (busy ? scheduleReapply() : redactNow()), 500); };
|
| 469 |
$("scan").onclick = () => ready && loaded && !busy && analyze();
|
| 470 |
$("addTerm").onclick = () => addTerm($("term").value);
|
| 471 |
$("term").onkeydown = e => e.key === "Enter" && $("addTerm").click();
|
| 472 |
function addTerm(v) { v = (v ?? "").trim(); if (!v || !res?.spans) return; maskTerms.push(v); $("term").value = ""; redetect(); }
|
| 473 |
+
document.querySelectorAll("#viewSeg [data-view]").forEach(b => (b.onclick = () => {
|
| 474 |
+
if (b.disabled) return; view = b.dataset.view;
|
| 475 |
+
if (view === "result" && dirty() && !busy) redactNow(); render();
|
| 476 |
+
}));
|
| 477 |
|
| 478 |
function updateButtons() {
|
| 479 |
const has = !!res?.spans;
|
| 480 |
+
$("scan").disabled = !ready || !loaded || busy;
|
| 481 |
+
$("download").disabled = !has || busy;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
document.querySelector('#viewSeg [data-view="result"]').disabled = !applied;
|
| 483 |
document.querySelectorAll("#viewSeg [data-view]").forEach(b => b.setAttribute("aria-selected", b.dataset.view === view));
|
| 484 |
+
$("work").classList.toggle("busy", busy);
|
| 485 |
}
|
| 486 |
|
| 487 |
worker.onmessage = async ({ data: m }) => {
|
| 488 |
if (m.type === "alive") { workerAlive = true; for (const [msg, tr] of outbox.splice(0)) worker.postMessage(msg, tr); return; }
|
| 489 |
if (m.type === "ready") {
|
| 490 |
+
ready = true; getTess("best_int"); updateButtons();
|
| 491 |
+
if (loaded && !res) analyze(); else status(t("ready"));
|
| 492 |
} else if (m.type === "progress") status(progressText(m.msg) + "…");
|
| 493 |
else if (m.type === "preview") {
|
| 494 |
if (!preview || m.seq !== preview.seq) return;
|
|
|
|
| 501 |
if (!res || m.seq !== res.seq || m.seq !== seq) return;
|
| 502 |
Object.assign(res, { kind: m.kind, text: m.text, spans: m.spans, pages: m.pages, pageSizes: m.pageSizes, ocrPages: m.ocrPages, timing: m.timing, nerCount: m.nerCount,
|
| 503 |
origPreviews: m.previews.map(p => URL.createObjectURL(new Blob([p], { type: "image/png" }))) });
|
| 504 |
+
off = new Set(m.spans.filter(s => !res.settings.entities.includes(s.entity_type)).map(key));
|
| 505 |
+
busy = false; render(); redactNow(); // show the masked result straight away; the list lets them undo any item
|
| 506 |
} else if (m.type === "redetected") {
|
| 507 |
if (!res?.spans) return;
|
| 508 |
const wasOff = new Set([...off].filter(k => res.spans.some(s => key(s) === k && !s.flag_only)));
|
| 509 |
res.spans = m.spans; Object.assign(res.timing, m.timing); res.nerCount = m.nerCount;
|
| 510 |
+
off = new Set(m.spans.filter(s => wasOff.has(key(s))).map(key));
|
| 511 |
busy = false; if (applied) redactNow(); else { status(t("found", { n: chosen().length }) + "."); render(); }
|
| 512 |
} else if (m.type === "redacted") {
|
| 513 |
if (!res?.spans) return;
|
|
|
|
| 515 |
Object.assign(res, { outText: m.outText, leaks: m.leaks, unchecked: m.unchecked, redactMs: m.ms,
|
| 516 |
maskPreviews: m.previews.map(p => URL.createObjectURL(new Blob([p], { type: "image/png" }))), appliedKeys: new Set(chosen().map(key)) });
|
| 517 |
if (res.kind === "image") res.maskUrl = URL.createObjectURL(outBlob);
|
| 518 |
+
busy = false; applied = true; if (!dirty()) view = "result";
|
| 519 |
+
status(""); render(); if (!$("home").classList.contains("hidden")) drawBA();
|
| 520 |
+
if (dlAfter && !dirty()) { dlAfter = false; saveOut(); } else if (dirty()) scheduleReapply();
|
| 521 |
} else if (m.type === "maskedText") { await navigator.clipboard.writeText(m.text); status(t("copied")); }
|
| 522 |
else if (m.type === "original") { pendingOriginal?.(m); }
|
| 523 |
else if (m.type === "forgotten") {
|
| 524 |
res = null; outBlob = null; loaded = null; preview = null; applied = false; busy = false; seq++;
|
| 525 |
+
$("scorebar").classList.add("hidden"); showWork(false);
|
| 526 |
+
updateButtons(); status(ready ? t("ready") : t("loading"));
|
| 527 |
} else if (m.type === "error") { busy = false; updateButtons(); status(t("error") + m.msg); console.error(m.msg, m.stack); }
|
| 528 |
};
|
| 529 |
worker.onerror = e => { status(t("error") + (e.message || "worker")); console.error(e); };
|
|
|
|
| 539 |
return prev[b.length];
|
| 540 |
}
|
| 541 |
const ocrMatches = (a, b) => { if (exactMatches(a, b)) return true; const x = sq(a), y = sq(b); return y.length >= 5 && editDist(x, y) <= Math.max(1, Math.floor(y.length * 0.15)); };
|
| 542 |
+
function goldEval(spans, gold, ocr = false, streetVisible = false) {
|
| 543 |
+
// image areas and the case-detail types of the stricter profiles are outside the answer key
|
| 544 |
+
spans = spans.filter(s => s.entity_type !== "UNREAD" && !CASE.includes(s.entity_type) && !ANON.includes(s.entity_type));
|
| 545 |
const matches = ocr ? ocrMatches : exactMatches;
|
| 546 |
const g = gold.filter(x => x.entity_type !== "CRIMINAL_RECORD");
|
| 547 |
+
// With "street visible" (the default) only the building/door part of an address is masked by design,
|
| 548 |
+
// so an address counts as found when a masked address span sits inside it.
|
| 549 |
+
const found = g.map(x => spans.some(s => matches(s.text, x.text) || (streetVisible && x.entity_type === "ADDRESS" && s.entity_type === "ADDRESS" && sq(s.text).length >= 2 && sq(x.text).includes(sq(s.text)))));
|
| 550 |
const partial = g.map((x, i) => found[i] ? null : spans.find(s => sq(s.text).length >= 2 && sq(x.text).includes(sq(s.text)))?.text ?? null);
|
| 551 |
+
const fps = spans.filter(s => !g.some(x => matches(s.text, x.text) || matches(x.text, s.text) || (streetVisible && s.entity_type === "ADDRESS" && x.entity_type === "ADDRESS" && sq(x.text).includes(sq(s.text)))));
|
| 552 |
const per = {};
|
| 553 |
g.forEach((x, i) => { const p = per[x.entity_type] ??= { gold: 0, found: 0, fp: 0 }; p.gold++; if (found[i]) p.found++; });
|
| 554 |
for (const s of fps) (per[s.entity_type] ??= { gold: 0, found: 0, fp: 0 }).fp++;
|
|
|
|
| 557 |
}
|
| 558 |
|
| 559 |
// ---------------------------------------------------------------- rendering
|
| 560 |
+
const key = s => s.uid ?? `${s.entity_type}:${s.start}:${s.end}`;
|
| 561 |
const chosen = () => (res?.spans ?? []).filter(s => !off.has(key(s)));
|
| 562 |
const dirty = () => !!(applied && res?.appliedKeys && (res.appliedKeys.size !== chosen().length || chosen().some(s => !res.appliedKeys.has(key(s)))));
|
| 563 |
+
function toggle(s) { off.has(key(s)) ? off.delete(key(s)) : off.add(key(s)); render(); if (view === "result") scheduleReapply(); }
|
| 564 |
|
| 565 |
function renderPreview() {
|
| 566 |
// before analysis: the document as it is, no highlights yet
|
| 567 |
+
$("scorebar").classList.add("hidden");
|
| 568 |
const D = $("docView"); D.textContent = ""; D.className = "docview";
|
| 569 |
if (preview.kind === "image" && preview.imageUrl) D.append(el("div", { className: "pg" }, el("img", { src: preview.imageUrl, alt: t("origAlt") })));
|
| 570 |
else if (preview.pages.length) for (const u of preview.pages) D.append(el("div", { className: "pg" }, el("img", { src: u, alt: t("origAlt") })));
|
|
|
|
| 572 |
else D.append(el("div", { className: "skel" }));
|
| 573 |
$("list").textContent = ""; $("fCount").textContent = "";
|
| 574 |
document.querySelector(".fhead").classList.add("hidden");
|
| 575 |
+
$("list").append(el("div", { className: "skel sm" }));
|
| 576 |
}
|
| 577 |
|
| 578 |
function render() {
|
| 579 |
if (!res?.spans) { if (preview) renderPreview(); updateButtons(); return; }
|
| 580 |
if (!applied && view === "result") view = "review";
|
| 581 |
+
|
| 582 |
+
res.eval = res.gold ? goldEval(chosen(), res.gold, res.kind === "image" || res.ocrPages > 0, res.settings.addressDetail === "street") : null;
|
| 583 |
updateButtons(); renderScore(); renderDoc(); renderList(); renderGold(); renderTiming();
|
| 584 |
}
|
| 585 |
|
| 586 |
function renderScore() {
|
| 587 |
const S = $("scorebar"); S.textContent = "";
|
| 588 |
const ev = res.eval, items = [];
|
| 589 |
+
if (applied && !busy) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 590 |
const n = res.leaks.length, cls = n ? "bad-t" : res.unchecked ? "warn-t" : "ok-t";
|
| 591 |
+
items.push(el("span", { className: "msg " + cls }, el("b", { textContent: n ? "⚠ " : res.unchecked ? "! " : "✓ " }), n ? t("leakBad", { n }) : res.unchecked ? t("leakUnchecked") : t("leakOk")));
|
| 592 |
+
if (n) { // the same value appears again somewhere it was not caught: one click masks every occurrence
|
| 593 |
+
const fix = el("button", { type: "button", className: "primary sm", textContent: t("leakFix") });
|
| 594 |
+
fix.onclick = () => { for (const id of res.leaks) { const sp = res.spans.find(x => x.id === id || x.uid === id); if (sp?.text && !maskTerms.includes(sp.text)) maskTerms.push(sp.text); } redetect(); };
|
| 595 |
+
items.push(fix);
|
| 596 |
+
}
|
| 597 |
}
|
| 598 |
if (dirty()) items.push(el("span", { className: "msg warn-t", textContent: t("sNotApplied") }));
|
| 599 |
+
if (ev) items.push(el("span", { className: "msg sample" }, t("sampleNote") + " ",
|
| 600 |
+
el("b", { textContent: `${ev.found.filter(Boolean).length}/${ev.gold.length}` }), " " + t("sRecall") + " · ",
|
| 601 |
+
el("b", { className: ev.fps.length ? "warn-t" : "", textContent: String(ev.fps.length) }), " " + t("sFp")));
|
| 602 |
S.append(...items); S.classList.toggle("hidden", !items.length);
|
| 603 |
}
|
| 604 |
|
|
|
|
| 639 |
const box = el("div", { className: "txt" });
|
| 640 |
const tx = res.text, ev = res.eval, miss = [];
|
| 641 |
if (ev) ev.gold.forEach((g, i) => { if (!ev.found[i]) { const j = tx.toLocaleLowerCase("tr").indexOf(g.text.toLocaleLowerCase("tr")); if (j >= 0) miss.push({ start: j, end: j + g.text.length, miss: true, g }); } });
|
| 642 |
+
const items = [...res.spans.filter(s => s.end > s.start), ...miss].sort((a, b) => a.start - b.start);
|
| 643 |
let pos = 0;
|
| 644 |
for (const s of items) {
|
| 645 |
if (s.start < pos) continue;
|
|
|
|
| 665 |
const on = spans.filter(s => !off.has(key(s))).length;
|
| 666 |
const all = el("input", { type: "checkbox", checked: on === spans.length, indeterminate: on > 0 && on < spans.length, ariaLabel: L(type) });
|
| 667 |
all.onchange = () => { for (const s of spans) all.checked ? off.delete(key(s)) : off.add(key(s)); render(); };
|
| 668 |
+
// the same value found several times is one row (×n); its tick covers every occurrence
|
| 669 |
+
const same = new Map();
|
| 670 |
+
spans.forEach((s, k) => { const id = s.text ? sq(s.text) : `#${k}`; same.set(id, [...(same.get(id) ?? []), [s, k]]); });
|
| 671 |
+
const rows = [...same.values()].map(occ => {
|
| 672 |
+
const [s, k] = occ[0], all = occ.map(([x]) => x), isOn = all.some(x => !off.has(key(x)));
|
| 673 |
+
const cb = el("input", { type: "checkbox", checked: isOn, ariaLabel: s.text });
|
| 674 |
+
cb.onchange = () => { for (const x of all) cb.checked ? off.delete(key(x)) : off.add(key(x)); render(); if (view === "result") scheduleReapply(); };
|
| 675 |
+
const fp = res.eval && all.some(x => res.eval.fps.some(f => key(f) === key(x)));
|
| 676 |
const flag = el("button", { type: "button", className: "flag", textContent: "⚑", title: t("flag"), ariaLabel: t("flag") });
|
| 677 |
+
flag.onclick = e => { e.preventDefault(); openReport(isOn ? "false_positive" : "missed", s.text); };
|
| 678 |
+
const v = el("span", { className: "v" + (fp ? " fp-t" : ""), textContent: s.text ? s.text.replace(/\s+/g, " ") : t("unreadItem", { p: s.page + 1, i: k + 1 }), title: `${s.recognizer} · ${s.score.toFixed(2)}${s.flag_only ? " · " + t("warnOnly") : ""}` });
|
| 679 |
v.onclick = e => { e.preventDefault(); document.querySelector(`#docView [data-k="${CSS.escape(key(s))}"]`)?.scrollIntoView({ block: "center", behavior: "smooth" }); };
|
| 680 |
+
return el("div", { className: "it" }, cb, v, occ.length > 1 ? el("span", { className: "times", textContent: `×${occ.length}` }) : null, flag);
|
| 681 |
});
|
| 682 |
box.append(el("div", { className: "grp" }, el("label", { className: "gh" }, all, el("b", { textContent: L(type) }), el("span", { textContent: `${on}/${spans.length}` })), ...rows));
|
| 683 |
}
|
|
|
|
| 700 |
el("p", { className: "hint", textContent: t("goldNote") }));
|
| 701 |
}
|
| 702 |
function renderTiming() {
|
| 703 |
+
const tm = res.timing ?? {}, rows = [[t("kModel"), res.settings.engineName], [t("kOcr"), res.ocrPages ? ({ fast: t("ocrFast"), auto: t("ocrAuto"), best_int: t("ocrBest") }[res.settings.ocrLang] ?? res.settings.ocrLang) : t("unused")],
|
| 704 |
[t("kPages"), `${res.pages}${res.ocrPages ? ` (${res.ocrPages} OCR)` : ""}`], [t("kParse"), `${tm.parse} ms`], ...(res.settings.ner ? [["NER", `${tm.ner} ms · ${res.nerCount}`]] : []),
|
| 705 |
[t("kRules"), `${tm.rules} ms`], ...(applied ? [[t("kRedact"), `${res.redactMs} ms`]] : []), [t("kVersion"), version.rules ?? ""]];
|
| 706 |
$("timing").textContent = ""; for (const [a, b] of rows) $("timing").append(el("tr", {}, el("td", { textContent: a }), el("td", { textContent: b })));
|
| 707 |
}
|
| 708 |
+
function saveOut() { if (!outBlob) return; const a = el("a", { href: URL.createObjectURL(outBlob), download: outName }); a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 30000); }
|
| 709 |
+
// one button: if the ticks changed since the last masking, mask again first, then save
|
| 710 |
+
$("download").onclick = () => { if (!res?.spans || busy) return; if (!applied || dirty()) { dlAfter = true; redactNow(); } else saveOut(); };
|
| 711 |
+
$("copy").onclick = () => { res?.spans && worker.postMessage({ type: "maskedText", spans: chosen() }); };
|
| 712 |
+
$("clear").onclick = () => {
|
| 713 |
+
res = null; outBlob = null; loaded = null; preview = null; applied = false; busy = false; seq++; maskTerms = []; dlAfter = false;
|
| 714 |
+
stayHome = false; $("baFrame").classList.remove("loading");
|
| 715 |
+
send({ type: "forget" }); $("scorebar").classList.add("hidden"); $("adv").open = false; showWork(false); updateButtons(); status(ready ? t("ready") : t("loading"));
|
| 716 |
+
};
|
| 717 |
|
| 718 |
// ---------------------------------------------------------------- report a mistake
|
| 719 |
let pendingOriginal = null;
|
| 720 |
+
function openReport(kind = "general", what = "") {
|
| 721 |
+
const has = !!res?.spans;
|
| 722 |
+
document.querySelectorAll("#reportForm .docOnly").forEach(e => e.classList.toggle("hidden", !has));
|
| 723 |
+
if (!has) { kind = "general"; $("rStatus").textContent = ""; $("rHow").textContent = t("rHowGeneral", { to: CONFIG.reportEmail }); $("rSend").classList.add("hidden"); return $("reportDlg").showModal(); }
|
| 724 |
document.querySelector(`#reportForm input[name=kind][value=${kind}]`).checked = true;
|
| 725 |
$("rWhat").value = what; $("rStatus").textContent = "";
|
| 726 |
$("incMasked").checked = applied; $("incMasked").disabled = !applied;
|
|
|
|
| 729 |
$("rSend").classList.toggle("hidden", !CONFIG.reportEndpoint);
|
| 730 |
$("rHow").textContent = CONFIG.reportEndpoint ? t("rHowSend", { host: new URL(CONFIG.reportEndpoint).host }) : t("rHowMail", { to: CONFIG.reportEmail });
|
| 731 |
$("reportDlg").showModal();
|
| 732 |
+
document.querySelector(`#reportForm input[name=kind][value=${kind}]`).dispatchEvent(new Event("change", { bubbles: true }));
|
| 733 |
}
|
| 734 |
$("reportBtn").onclick = () => openReport();
|
| 735 |
+
$("reportForm").onchange = e => {
|
| 736 |
+
if (e.target.name !== "kind" || !res?.spans) return;
|
| 737 |
+
const general = e.target.value === "general";
|
| 738 |
+
for (const id of ["rWhat"]) { $(id).classList.toggle("hidden", general); document.querySelector(`label[for=${id}]`).classList.toggle("hidden", general); }
|
| 739 |
+
document.querySelector("#reportForm details.checks").classList.toggle("hidden", general);
|
| 740 |
+
$("rHow").textContent = general ? t("rHowGeneral", { to: CONFIG.reportEmail }) : CONFIG.reportEndpoint ? t("rHowSend", { host: new URL(CONFIG.reportEndpoint).host }) : t("rHowMail", { to: CONFIG.reportEmail });
|
| 741 |
+
};
|
| 742 |
|
| 743 |
async function buildReport() {
|
| 744 |
const kind = document.querySelector("#reportForm input[name=kind]:checked").value;
|
|
|
|
| 764 |
}
|
| 765 |
return { id, report, blob: await zip.generateAsync({ type: "blob", compression: "DEFLATE" }) };
|
| 766 |
}
|
| 767 |
+
const KIND_TR = { general: "Genel görüş", missed: "Kaçan veri", false_positive: "Gereksiz maskeleme", broken: "Bozuk çıktı", other: "Diğer" };
|
| 768 |
+
const KIND_EN = { general: "General feedback", missed: "Left visible", false_positive: "Masked by mistake", broken: "File won't open", other: "Other" };
|
| 769 |
+
function mailGeneral() {
|
| 770 |
+
const kind = document.querySelector("#reportForm input[name=kind]:checked").value;
|
| 771 |
+
const subject = lang === "en" ? `Katip Mask feedback: ${KIND_EN[kind]}` : `Katip Mask görüş: ${KIND_TR[kind]}`;
|
| 772 |
+
const body = [$("rText").value, "", `${location.origin + location.pathname} · ${version.app} · ${version.rules}`,
|
| 773 |
+
res?.spans ? `${res.kind} · ${res.settings.engineName} · ${res.spans.length}/${chosen().length}` : null].filter(x => x != null).join("\n");
|
| 774 |
+
location.href = `mailto:${CONFIG.reportEmail}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body.slice(0, 1800))}`;
|
| 775 |
+
$("rStatus").textContent = t("rHowGeneral", { to: CONFIG.reportEmail });
|
| 776 |
+
window.__lastMailto = { subject, body, fname: null };
|
| 777 |
+
}
|
| 778 |
$("rMail").onclick = async () => {
|
| 779 |
+
if (!res?.spans || document.querySelector("#reportForm input[name=kind]:checked").value === "general") return mailGeneral();
|
| 780 |
const { id, report, blob } = await buildReport();
|
| 781 |
const fname = `katip-rapor-${id}.zip`;
|
| 782 |
const a = el("a", { href: URL.createObjectURL(blob), download: fname }); a.click();
|
|
|
|
| 785 |
`Belge: ${report.document.name} (${report.document.kind}, ${report.document.pages} sayfa)`, `Model: ${report.settings.engineName} · eşik ${report.settings.threshold} · OCR ${report.settings.ocrLang}`,
|
| 786 |
sc && `Puan: yakalama ${(sc.recall * 100).toFixed(1)}%, kesinlik ${(sc.precision * 100).toFixed(1)}%`, `Sürüm: ${version.rules} (${version.built})`, "",
|
| 787 |
`Lütfen indirilen ${fname} dosyasını bu e-postaya ekleyin. / Please attach ${fname}.`].filter(x => x !== null && x !== undefined && x !== false).join("\n");
|
| 788 |
+
const subject = `Katip Mask hata bildirimi: ${KIND_TR[report.feedback.kind]} (${report.document.kind})`;
|
| 789 |
location.href = `mailto:${CONFIG.reportEmail}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body.slice(0, 1800))}`;
|
| 790 |
$("rStatus").textContent = t("rDownloaded", { f: fname, to: CONFIG.reportEmail });
|
| 791 |
window.__lastMailto = { subject, body, fname };
|
|
|
|
| 795 |
try {
|
| 796 |
const { id, report, blob } = await buildReport();
|
| 797 |
const fd = new FormData();
|
| 798 |
+
fd.append("_subject", `Katip Mask hata bildirimi ${id}`); fd.append("email", CONFIG.reportEmail);
|
| 799 |
fd.append("message", `${KIND_TR[report.feedback.kind]}: ${report.feedback.what}\n\n${report.feedback.text}`);
|
| 800 |
fd.append("report", JSON.stringify(report)); fd.append("attachment", blob, `katip-rapor-${id}.zip`);
|
| 801 |
const r = await fetch(CONFIG.reportEndpoint, { method: "POST", body: fd, headers: { Accept: "application/json" } });
|
|
|
|
| 810 |
[samples, models, version] = await Promise.all(["samples/manifest.json", "models/registry.json", "version.json"].map(async u => {
|
| 811 |
const r = await fetch(u); if (!r.ok) throw new Error(`${u}: HTTP ${r.status}`); return r.json(); }));
|
| 812 |
} catch (e) { status(t("error") + e.message); return; }
|
| 813 |
+
$("version").textContent = `Katip Mask ${version.app} · ${version.rules} · ${version.built}`;
|
| 814 |
if (version.source) $("srcLink").href = version.source;
|
| 815 |
+
buildColorUI(); buildSampleUI(); buildModelUI(); buildEntityUI(); buildProfileUI(); applyLang();
|
| 816 |
+
const q = new URLSearchParams(location.search).get("sample"); // ?sample=dilekce:udf opens a sample directly (handy for sharing)
|
| 817 |
+
if (q) { const [ty, fm] = q.split(":"), sm = samples.find(x => x.type === ty || x.id === ty); if (sm) openSample(sm, sm.formats.includes(fm) ? fm : sm.formats[0]); }
|
| 818 |
})();
|
config.js
CHANGED
|
@@ -9,7 +9,7 @@ export default {
|
|
| 9 |
// Credit line added to redacted PDF, image, Word and text output when "Filigran" is ticked.
|
| 10 |
// Keep the text ASCII: it is drawn with a standard PDF font.
|
| 11 |
watermark: {
|
| 12 |
-
text: { tr: "Katip
|
| 13 |
url: "https://fikrifirat.github.io/katip/",
|
| 14 |
},
|
| 15 |
};
|
|
|
|
| 9 |
// Credit line added to redacted PDF, image, Word and text output when "Filigran" is ticked.
|
| 10 |
// Keep the text ASCII: it is drawn with a standard PDF font.
|
| 11 |
watermark: {
|
| 12 |
+
text: { tr: "Katip Mask ile maskelendi - fikrifirat.github.io/katip", en: "Redacted with Katip Mask - fikrifirat.github.io/katip" },
|
| 13 |
url: "https://fikrifirat.github.io/katip/",
|
| 14 |
},
|
| 15 |
};
|
engine.js
CHANGED
|
@@ -54,7 +54,7 @@ function pdfLines(page) {
|
|
| 54 |
return lines.map(l => l.sort((a, b) => a.bbox[0] - b.bbox[0]));
|
| 55 |
}
|
| 56 |
|
| 57 |
-
// ocr(pngBytes) must resolve to lines of {text, bbox:[x0,y0,x1,y1]} in pixels
|
| 58 |
export async function parsePdf(mupdf, bytes, ocr, { forceOcr = false, onProgress } = {}) {
|
| 59 |
const doc = mupdf.Document.openDocument(bytes, "application/pdf");
|
| 60 |
const n = doc.countPages(), pages = [];
|
|
@@ -63,29 +63,224 @@ export async function parsePdf(mupdf, bytes, ocr, { forceOcr = false, onProgress
|
|
| 63 |
onProgress?.(`Sayfa ${i + 1}/${n}`);
|
| 64 |
const page = doc.loadPage(i);
|
| 65 |
const [x0, y0, x1, y1] = page.getBounds();
|
| 66 |
-
const p = { number: i, width: x1 - x0, height: y1 - y0, words: [], ocr: false, lines: forceOcr ? [] : pdfLines(page) };
|
| 67 |
if (p.lines.flat().reduce((s, w) => s + w.text.length, 0) < MIN_TEXT_CHARS) {
|
| 68 |
if (!ocr) throw new Error("Taranmış sayfa var ama OCR yüklenmedi");
|
| 69 |
onProgress?.(`OCR sayfa ${i + 1}/${n}`);
|
| 70 |
-
const s =
|
| 71 |
const pix = page.toPixmap(mupdf.Matrix.scale(s, s), mupdf.ColorSpace.DeviceRGB, false);
|
| 72 |
-
const
|
| 73 |
-
p.
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
}
|
| 76 |
pages.push(p);
|
| 77 |
}
|
| 78 |
return { kind: "pdf", text: assemble(pages), pages, ocrPages, _doc: doc };
|
| 79 |
}
|
| 80 |
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
return { kind: "image", text: assemble([p]), pages: [p], ocrPages: 1 };
|
| 84 |
}
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
// ---- spans -> boxes (port of redact.attach_boxes; errs towards over-masking)
|
| 87 |
export function attachBoxes(doc, spans) {
|
| 88 |
for (const sp of spans) {
|
|
|
|
| 89 |
const page = doc.pages.find(p => sp.start >= p.start && sp.start < p.end);
|
| 90 |
if (!page) continue;
|
| 91 |
sp.page = page.number;
|
|
@@ -103,6 +298,17 @@ export function attachBoxes(doc, spans) {
|
|
| 103 |
byLine.set(key, [Math.min(box[0], cx0), Math.min(box[1], y0), Math.max(box[2], cx1), Math.max(box[3], y1)]);
|
| 104 |
}
|
| 105 |
sp.boxes = [...byLine.values()];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
}
|
| 107 |
}
|
| 108 |
|
|
@@ -161,6 +367,9 @@ export function redactPdf(mupdf, doc, spans, opts = {}) {
|
|
| 161 |
const pdf = doc._doc, PAD = 1, color = opts.color ?? [0, 0, 0], wm = opts.watermark;
|
| 162 |
const per = new Map();
|
| 163 |
for (const sp of spans) if (sp.page != null) per.set(sp.page, [...(per.get(sp.page) ?? []), ...sp.boxes]);
|
|
|
|
|
|
|
|
|
|
| 164 |
for (const [pno, boxes] of per) {
|
| 165 |
const page = pdf.loadPage(pno);
|
| 166 |
for (const [x0, y0, x1, y1] of boxes) {
|
|
@@ -171,6 +380,10 @@ export function redactPdf(mupdf, doc, spans, opts = {}) {
|
|
| 171 |
page.applyRedactions(false, mupdf.PDFPage.REDACT_IMAGE_PIXELS);
|
| 172 |
paintBoxes(mupdf, pdf, page, boxes.map(([x0, y0, x1, y1]) => [x0 - PAD, y0 - PAD, x1 + PAD, y1 + PAD]), color);
|
| 173 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
if (opts.mark) for (let i = 0; i < pdf.countPages(); i++) stampMark(mupdf, pdf, pdf.loadPage(i), opts.mark);
|
| 175 |
const links = [];
|
| 176 |
if (wm?.text) for (let i = 0; i < pdf.countPages(); i++) {
|
|
@@ -187,6 +400,37 @@ export function redactPdf(mupdf, doc, spans, opts = {}) {
|
|
| 187 |
return pdf.saveToBuffer("garbage=4,compress,clean").asUint8Array().slice();
|
| 188 |
}
|
| 189 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
export function pdfText(mupdf, bytes) {
|
| 191 |
const d = mupdf.Document.openDocument(bytes, "application/pdf"); let t = "";
|
| 192 |
for (let i = 0; i < d.countPages(); i++) t += d.loadPage(i).toStructuredText().asText() + "\n";
|
|
@@ -231,7 +475,65 @@ export function redactImage(mupdf, bytes, spans, mime = "image/png", color = [0,
|
|
| 231 |
}
|
| 232 |
|
| 233 |
// render each page of a PDF for a second OCR pass (leak check on scans)
|
| 234 |
-
export function pdfPagePngs(mupdf, bytes, pages
|
| 235 |
-
const d = mupdf.Document.openDocument(bytes, "application/pdf")
|
| 236 |
-
return pages.map(i =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
}
|
|
|
|
| 54 |
return lines.map(l => l.sort((a, b) => a.bbox[0] - b.bbox[0]));
|
| 55 |
}
|
| 56 |
|
| 57 |
+
// ocr(pngBytes) must resolve to lines of {text, bbox:[x0,y0,x1,y1]} in pixels; ocr.engine names it ("paddle" or Tesseract by default)
|
| 58 |
export async function parsePdf(mupdf, bytes, ocr, { forceOcr = false, onProgress } = {}) {
|
| 59 |
const doc = mupdf.Document.openDocument(bytes, "application/pdf");
|
| 60 |
const n = doc.countPages(), pages = [];
|
|
|
|
| 63 |
onProgress?.(`Sayfa ${i + 1}/${n}`);
|
| 64 |
const page = doc.loadPage(i);
|
| 65 |
const [x0, y0, x1, y1] = page.getBounds();
|
| 66 |
+
const p = { number: i, width: x1 - x0, height: y1 - y0, words: [], ocr: false, engine: null, lines: forceOcr ? [] : pdfLines(page) };
|
| 67 |
if (p.lines.flat().reduce((s, w) => s + w.text.length, 0) < MIN_TEXT_CHARS) {
|
| 68 |
if (!ocr) throw new Error("Taranmış sayfa var ama OCR yüklenmedi");
|
| 69 |
onProgress?.(`OCR sayfa ${i + 1}/${n}`);
|
| 70 |
+
const s = ocrScale(p.width, p.height);
|
| 71 |
const pix = page.toPixmap(mupdf.Matrix.scale(s, s), mupdf.ColorSpace.DeviceRGB, false);
|
| 72 |
+
const { colour, ...prep } = prepareScan(pix) ?? {};
|
| 73 |
+
p.prep = prep;
|
| 74 |
+
const pen = unreadInk(pix, [], { colour }).filter(b => b.pen >= PEN_BLUE);
|
| 75 |
+
const read = pickOcr(ocr, pen, prep);
|
| 76 |
+
let raw = await read(pix.asPNG());
|
| 77 |
+
const blobs = unreadInk(pix, raw.flat().map(w => w.bbox), { colour });
|
| 78 |
+
const again = await reocrBlobs(mupdf, pix, blobs, read);
|
| 79 |
+
raw = mergeLines(raw, again.words);
|
| 80 |
+
p.unread = again.unread.map(b => Object.assign(b.map(v => v / s), { pen: b.pen }));
|
| 81 |
+
p.pen = pen.map(b => Object.assign(b.map(v => v / s), { pen: b.pen }));
|
| 82 |
+
p.reocr = again.words.length;
|
| 83 |
+
pix.destroy?.();
|
| 84 |
+
p.lines = raw.map(l => l.map(w => ({ text: w.text, bbox: w.bbox.map(v => v / s) })));
|
| 85 |
+
p.ocr = true; p.engine = read.engine ?? "tesseract"; ocrPages++;
|
| 86 |
}
|
| 87 |
pages.push(p);
|
| 88 |
}
|
| 89 |
return { kind: "pdf", text: assemble(pages), pages, ocrPages, _doc: doc };
|
| 90 |
}
|
| 91 |
|
| 92 |
+
// "auto" OCR: an ocr function may carry forPage({ pen, cropped }) to pick a reader per page. Katip Yerel
|
| 93 |
+
// uses it to send handwriting and phone photos to PaddleOCR and printed scans to Tesseract.
|
| 94 |
+
// pen = ballpoint-blue ink blobs found before any OCR; cropped = a photographed sheet was cut out of a background.
|
| 95 |
+
export const HANDWRITING_MIN_BLOBS = 3;
|
| 96 |
+
function pickOcr(ocr, pen, prep) {
|
| 97 |
+
return ocr.forPage?.({ pen: pen.length, cropped: !!prep?.cropped, handwriting: pen.length >= HANDWRITING_MIN_BLOBS || !!prep?.cropped }) ?? ocr;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
export async function parseImage(bytes, ocr, size, mupdf = null) {
|
| 101 |
+
let png = bytes, prep = null;
|
| 102 |
+
if (mupdf) { // same clean-up as scanned PDF pages; pixel coordinates stay those of the original image
|
| 103 |
+
const img = new mupdf.Image(bytes), pix = img.toPixmap();
|
| 104 |
+
const rgb = pix.getNumberOfComponents() === 3 && !pix.getAlpha?.() ? pix : pix.convertToColorSpace(mupdf.ColorSpace.DeviceRGB, false);
|
| 105 |
+
const { colour, ...rest } = prepareScan(rgb) ?? {};
|
| 106 |
+
prep = rest; png = rgb.asPNG().slice();
|
| 107 |
+
const pen = unreadInk(rgb, [], { colour }).filter(b => b.pen >= PEN_BLUE), read = pickOcr(ocr, pen, prep);
|
| 108 |
+
let lines = await read(png);
|
| 109 |
+
const again = await reocrBlobs(mupdf, rgb, unreadInk(rgb, lines.flat().map(w => w.bbox), { colour }), read);
|
| 110 |
+
lines = mergeLines(lines, again.words);
|
| 111 |
+
const p = { number: 0, width: size?.width ?? rgb.getWidth(), height: size?.height ?? rgb.getHeight(), words: [], ocr: true, engine: read.engine ?? "tesseract", prep, unread: again.unread, pen, lines };
|
| 112 |
+
return { kind: "image", text: assemble([p]), pages: [p], ocrPages: 1 };
|
| 113 |
+
}
|
| 114 |
+
const p = { number: 0, width: size?.width ?? 0, height: size?.height ?? 0, words: [], ocr: true, prep, unread: [], lines: await ocr(png) };
|
| 115 |
return { kind: "image", text: assemble([p]), pages: [p], ocrPages: 1 };
|
| 116 |
}
|
| 117 |
|
| 118 |
+
// ---- handwriting the OCR could not read
|
| 119 |
+
// After prepareScan: dark strokes that no OCR word covers, with ruled lines (form borders) removed and
|
| 120 |
+
// strokes grouped into word-sized blobs. On a filled-in form these are mostly handwritten values the
|
| 121 |
+
// OCR skipped (an ID number, a name) and signatures; they become UNREAD items so they can be masked
|
| 122 |
+
// even though there is no text to check. Boxes are in pixmap pixels.
|
| 123 |
+
export function unreadInk(pix, wordBoxes, { dark = 150, colour = null } = {}) {
|
| 124 |
+
const w = pix.getWidth(), h = pix.getHeight(), stride = pix.getStride(), n = pix.getNumberOfComponents(), px = pix.getPixels();
|
| 125 |
+
const ink = new Uint8Array(w * h);
|
| 126 |
+
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) ink[y * w + x] = px[y * stride + x * n] < dark ? 1 : 0;
|
| 127 |
+
// ruled lines: long horizontal / vertical runs of ink. A photographed line is slanted and a few pixels
|
| 128 |
+
// thick, so a pixel counts as "on" for the run if any pixel within 2 rows (columns) of it is ink, small
|
| 129 |
+
// gaps are bridged, and the whole band around a found run is cleared.
|
| 130 |
+
const L = Math.max(40, Math.round(Math.max(w, h) / 30)), T = 2, GAP = 3;
|
| 131 |
+
const clearH = (y, a, b) => { for (let yy = Math.max(0, y - T - 1); yy <= Math.min(h - 1, y + T + 1); yy++) for (let k = a; k < b; k++) if (ink[yy * w + k]) ink[yy * w + k] = 2; };
|
| 132 |
+
const clearV = (x, a, b) => { for (let xx = Math.max(0, x - T - 1); xx <= Math.min(w - 1, x + T + 1); xx++) for (let k = a; k < b; k++) if (ink[k * w + xx]) ink[k * w + xx] = 2; };
|
| 133 |
+
for (let y = 0; y < h; y += 2) {
|
| 134 |
+
let a = -1, gap = 0;
|
| 135 |
+
for (let x = 0; x <= w; x++) {
|
| 136 |
+
let on = false;
|
| 137 |
+
if (x < w) for (let yy = Math.max(0, y - T); yy <= Math.min(h - 1, y + T) && !on; yy++) on = ink[yy * w + x] > 0;
|
| 138 |
+
if (on) { if (a < 0) a = x; gap = 0; } else if (a >= 0 && ++gap > GAP) { if (x - gap - a >= L) clearH(y, a, x - gap + 1); a = -1; gap = 0; }
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
for (let x = 0; x < w; x += 2) {
|
| 142 |
+
let a = -1, gap = 0;
|
| 143 |
+
for (let y = 0; y <= h; y++) {
|
| 144 |
+
let on = false;
|
| 145 |
+
if (y < h) for (let xx = Math.max(0, x - T); xx <= Math.min(w - 1, x + T) && !on; xx++) on = ink[y * w + xx] === 1;
|
| 146 |
+
if (on) { if (a < 0) a = y; gap = 0; } else if (a >= 0 && ++gap > GAP) { if (y - gap - a >= L) clearV(x, a, y - gap + 1); a = -1; gap = 0; }
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
// what the OCR read
|
| 150 |
+
const P = 3;
|
| 151 |
+
for (const [x0, y0, x1, y1] of wordBoxes)
|
| 152 |
+
for (let y = Math.max(0, Math.floor(y0) - P); y < Math.min(h, Math.ceil(y1) + P); y++)
|
| 153 |
+
for (let x = Math.max(0, Math.floor(x0) - P); x < Math.min(w, Math.ceil(x1) + P); x++) ink[y * w + x] = 0;
|
| 154 |
+
// coarse grid, joined sideways so the strokes of one handwritten word become one blob
|
| 155 |
+
const C = Math.max(3, Math.round(Math.max(w, h) / 600)), gw = Math.ceil(w / C), gh = Math.ceil(h / C), g = new Uint8Array(gw * gh);
|
| 156 |
+
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (ink[y * w + x] === 1) g[((y / C) | 0) * gw + ((x / C) | 0)]++;
|
| 157 |
+
const on = new Uint8Array(gw * gh), JX = 4, JY = 0;
|
| 158 |
+
for (let y = 0; y < gh; y++) for (let x = 0; x < gw; x++) if (g[y * gw + x] >= 2)
|
| 159 |
+
for (let dy = -JY; dy <= JY; dy++) for (let dx = -JX; dx <= JX; dx++) { const yy = y + dy, xx = x + dx; if (yy >= 0 && yy < gh && xx >= 0 && xx < gw) on[yy * gw + xx] = 1; }
|
| 160 |
+
const seen = new Uint8Array(gw * gh), boxes = [], stack = [];
|
| 161 |
+
for (let i = 0; i < gw * gh; i++) {
|
| 162 |
+
if (!on[i] || seen[i]) continue;
|
| 163 |
+
let bx0 = gw, by0 = gh, bx1 = 0, by1 = 0, mass = 0;
|
| 164 |
+
stack.push(i); seen[i] = 1;
|
| 165 |
+
while (stack.length) {
|
| 166 |
+
const j = stack.pop(), x = j % gw, y = (j / gw) | 0;
|
| 167 |
+
bx0 = Math.min(bx0, x); bx1 = Math.max(bx1, x); by0 = Math.min(by0, y); by1 = Math.max(by1, y); mass += g[j];
|
| 168 |
+
for (const k of [j - 1, j + 1, j - gw, j + gw]) if (k >= 0 && k < gw * gh && on[k] && !seen[k] && Math.abs((k % gw) - x) <= 1) { seen[k] = 1; stack.push(k); }
|
| 169 |
+
}
|
| 170 |
+
// undo the sideways join on the box edges, then keep word-sized blobs with real ink in them
|
| 171 |
+
const x0 = (bx0 + JX) * C, x1 = (bx1 - JX + 1) * C, y0 = (by0 + JY) * C, y1 = (by1 - JY + 1) * C;
|
| 172 |
+
const bw = x1 - x0, bh = y1 - y0, minH = Math.max(h, w) / 160;
|
| 173 |
+
if (bw < 2 * minH || bh < minH || bh > h / 16 || bw > w * 0.5 || mass < 4 * minH) continue; // thin = left-over ruling
|
| 174 |
+
// mean blueness (blue minus red) of the blob's ink: ballpoint ink is bluer than printed black
|
| 175 |
+
let inkN = 0, colN = 0;
|
| 176 |
+
if (colour) for (let y = Math.max(0, y0); y < Math.min(h, y1); y++) for (let x = Math.max(0, x0); x < Math.min(w, x1); x++)
|
| 177 |
+
if (ink[y * w + x] === 1) { inkN++; colN += colour[y * w + x] - 128; }
|
| 178 |
+
const box = [x0, y0, x1, y1];
|
| 179 |
+
box.pen = inkN ? colN / inkN : 0;
|
| 180 |
+
boxes.push(box);
|
| 181 |
+
}
|
| 182 |
+
return boxes;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
// Second look at what the page OCR skipped: Tesseract's page layout analysis often drops whole rows of
|
| 186 |
+
// a ruled form (two-line label cells, handwriting between lines), but reads the same strokes when given
|
| 187 |
+
// them alone. Each blob (with a margin) is cut out and read again; blobs that now give text become words
|
| 188 |
+
// of the page, the rest stay "unread". Coordinates are pixmap pixels.
|
| 189 |
+
export async function reocrBlobs(mupdf, pix, blobs, ocr, { pad = 10, maxBlobs = 80 } = {}) {
|
| 190 |
+
const w = pix.getWidth(), h = pix.getHeight(), stride = pix.getStride(), n = pix.getNumberOfComponents(), px = pix.getPixels();
|
| 191 |
+
const words = [], unread = [];
|
| 192 |
+
for (const [k, b] of blobs.entries()) {
|
| 193 |
+
if (k >= maxBlobs) { unread.push(b); continue; }
|
| 194 |
+
const x0 = Math.max(0, Math.floor(b[0] - pad)), y0 = Math.max(0, Math.floor(b[1] - pad)), x1 = Math.min(w, Math.ceil(b[2] + pad)), y1 = Math.min(h, Math.ceil(b[3] + pad));
|
| 195 |
+
// white frame around the crop helps Tesseract find the line
|
| 196 |
+
const F = 20, cw = x1 - x0 + 2 * F, ch = y1 - y0 + 2 * F;
|
| 197 |
+
const crop = new mupdf.Pixmap(mupdf.ColorSpace.DeviceRGB, [0, 0, cw, ch], false);
|
| 198 |
+
const cp = crop.getPixels(), cs = crop.getStride();
|
| 199 |
+
cp.fill(255);
|
| 200 |
+
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) for (let c = 0; c < 3; c++) cp[(y - y0 + F) * cs + (x - x0 + F) * 3 + c] = px[y * stride + x * n + c];
|
| 201 |
+
let got = [];
|
| 202 |
+
try { got = (await ocr(crop.asPNG())).flat(); } catch { got = []; }
|
| 203 |
+
crop.destroy?.();
|
| 204 |
+
const good = got.filter(t => (t.text.match(/[\p{L}\p{N}]/gu) ?? []).length >= 2);
|
| 205 |
+
if (!good.length) { unread.push(b); continue; }
|
| 206 |
+
for (const t of good) words.push({ text: t.text, bbox: [t.bbox[0] - F + x0, t.bbox[1] - F + y0, t.bbox[2] - F + x0, t.bbox[3] - F + y0], reocr: true });
|
| 207 |
+
}
|
| 208 |
+
return { words, unread };
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
// Put re-read words into the page's lines by vertical overlap (so "Kiraya verenin T.C. Kimlik No" and the
|
| 212 |
+
// number written next to it end up on one text line), then order lines top to bottom, words left to right.
|
| 213 |
+
export function mergeLines(lines, words) {
|
| 214 |
+
if (!words.length) return lines;
|
| 215 |
+
const out = lines.map(l => [...l]);
|
| 216 |
+
const mid = b => (b[1] + b[3]) / 2;
|
| 217 |
+
for (const wd of words) {
|
| 218 |
+
let best = null, bestD = Infinity;
|
| 219 |
+
for (const l of out) {
|
| 220 |
+
const y0 = Math.min(...l.map(x => x.bbox[1])), y1 = Math.max(...l.map(x => x.bbox[3]));
|
| 221 |
+
const m = mid(wd.bbox);
|
| 222 |
+
if (m >= y0 && m <= y1) { const d = Math.abs(m - (y0 + y1) / 2); if (d < bestD) { bestD = d; best = l; } }
|
| 223 |
+
}
|
| 224 |
+
if (best) best.push(wd); else out.push([wd]);
|
| 225 |
+
}
|
| 226 |
+
for (const l of out) l.sort((a, b) => a.bbox[0] - b.bbox[0]);
|
| 227 |
+
out.sort((a, b) => Math.min(...a.map(x => x.bbox[1])) - Math.min(...b.map(x => x.bbox[1])));
|
| 228 |
+
return out;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
// Blueness above which an unread blob is taken as pen (handwriting). On the one real photographed lease we
|
| 232 |
+
// have, handwritten values measured +7 to +9 and printed labels -3 to -11; to be checked on the synthetic
|
| 233 |
+
// photo set.
|
| 234 |
+
export const PEN_BLUE = 6;
|
| 235 |
+
// A row label that asks for personal data: "Kiraya verenin", "Kiracının", "T.C. Kimlik No", "Adı Soyadı" ...
|
| 236 |
+
const PII_LABEL = /kimlik|t\.?c\.?$|tckn|soyad|ad soyad|kiraya veren|kirac[ıi]|kefil|mal sahibi|telefon|^tel\b|gsm|iban|hesap no|vergi no|ikamet|adres|imza|vekil|davac[ıi]|daval[ıi]|borçlu|alacakl[ıi]/i;
|
| 237 |
+
// Labels whose handwritten value is always masked, read or not: ID numbers and names. Not addresses (the
|
| 238 |
+
// street stays readable; the text rules mask the building and door) and not party words alone.
|
| 239 |
+
const ID_NAME_LABEL = /k[iı]ml[iı]k|kımlık|tckn|t\.\s?c\.|\btc\b|soy\s?ad|ad[ıi]\s+soyad/i;
|
| 240 |
+
const median = a => { const b = [...a].sort((x, y) => x - y); return b.length ? b[b.length >> 1] : 0; };
|
| 241 |
+
const rowWords = (p, [x0, y0, x1, y1]) => {
|
| 242 |
+
const m = (y1 - y0) * 0.5;
|
| 243 |
+
return p.words.filter(w => w.bbox[2] <= x0 + 2 && w.bbox[3] > y0 - m && w.bbox[1] < y1 + m);
|
| 244 |
+
};
|
| 245 |
+
|
| 246 |
+
// Handwriting items for the review list, one per ink blob, each carrying its own box (no text offsets):
|
| 247 |
+
// * pen ink in a row labelled T.C. Kimlik No or Adı soyadı: masked, whatever the OCR made of it
|
| 248 |
+
// (handwritten digits often come out as letters, so the text rules cannot be relied on there);
|
| 249 |
+
// * pen ink the OCR could not read at all, next to any personal-data label: masked;
|
| 250 |
+
// * other unreadable marks: listed for review, unmasked.
|
| 251 |
+
export function unreadSpans(doc) {
|
| 252 |
+
const out = [];
|
| 253 |
+
for (const p of doc.pages ?? []) {
|
| 254 |
+
const add = (b, i, kind, masked, why) => out.push({
|
| 255 |
+
id: `u${p.number}_${kind}${i}`, uid: `UNREAD:${p.number}:${kind}${i}`, entity_type: "UNREAD", start: p.end, end: p.end, text: "", score: 0.5,
|
| 256 |
+
recognizer: kind === "p" ? "pen_in_id_row" : "unread_ink", explanation: why, flag_only: !masked, page: p.number, boxes: [[b[0], b[1], b[2], b[3]]] });
|
| 257 |
+
const penBoxes = [];
|
| 258 |
+
(p.pen ?? []).forEach((b, i) => {
|
| 259 |
+
// strict row match: the blob's middle must sit inside the label line, so a tall blob
|
| 260 |
+
// that spills over from a neighbouring (street) row does not count
|
| 261 |
+
// (two-line label cells: the ink sits between "Kiraya verenin" and "T.C. Kimlik No.", so allow one line of slack)
|
| 262 |
+
const lh = p.lineH ??= median(p.words.map(w => w.bbox[3] - w.bbox[1])) || 10;
|
| 263 |
+
// errs towards masking: a TC number left readable is worse than a street hidden by mistake
|
| 264 |
+
// PaddleOCR boxes are about a third taller than Tesseract's, so a full line of slack reaches the
|
| 265 |
+
// street row below a TC row; there the blob's middle must sit within 0.6 line of the label instead
|
| 266 |
+
const cy = (b[1] + b[3]) / 2;
|
| 267 |
+
const words = p.engine === "paddle" ? p.words.filter(w => w.bbox[2] <= b[0] + 2 && cy > w.bbox[1] - 0.6 * lh && cy < w.bbox[3] + 0.6 * lh)
|
| 268 |
+
: p.words.filter(w => w.bbox[2] <= b[0] + 2 && w.bbox[3] > b[1] - lh && w.bbox[1] < b[3] + lh);
|
| 269 |
+
if (b[3] - b[1] <= 3.5 * lh && ID_NAME_LABEL.test(words.map(w => w.text).join(" "))) { penBoxes.push(b); add(b, i, "p", true, "handwriting in a row labelled T.C. Kimlik No / Adı soyadı"); }
|
| 270 |
+
});
|
| 271 |
+
(p.unread ?? []).forEach((b, i) => {
|
| 272 |
+
if (penBoxes.some(q => q[0] < b[2] && q[2] > b[0] && q[1] < b[3] && q[3] > b[1])) return;
|
| 273 |
+
const pen = b.pen >= PEN_BLUE, label = PII_LABEL.test(rowWords(p, b).map(w => w.text).join(" ").toLocaleLowerCase("tr"));
|
| 274 |
+
add(b, i, "u", pen && label, pen ? (label ? "handwriting next to a personal-data label, not readable by OCR" : "handwriting the OCR could not read") : "marks the OCR could not read");
|
| 275 |
+
});
|
| 276 |
+
}
|
| 277 |
+
return out;
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
// ---- spans -> boxes (port of redact.attach_boxes; errs towards over-masking)
|
| 281 |
export function attachBoxes(doc, spans) {
|
| 282 |
for (const sp of spans) {
|
| 283 |
+
if (sp.entity_type === "UNREAD") continue; // boxes come from the page image, not from text
|
| 284 |
const page = doc.pages.find(p => sp.start >= p.start && sp.start < p.end);
|
| 285 |
if (!page) continue;
|
| 286 |
sp.page = page.number;
|
|
|
|
| 298 |
byLine.set(key, [Math.min(box[0], cx0), Math.min(box[1], y0), Math.max(box[2], cx1), Math.max(box[3], y1)]);
|
| 299 |
}
|
| 300 |
sp.boxes = [...byLine.values()];
|
| 301 |
+
// handwriting: OCR often drops the first or last letter of a word, so grow the box over pen ink
|
| 302 |
+
// that touches it on the same line (not over tall blobs such as signatures)
|
| 303 |
+
if (page.ocr && page.pen?.length && sp.entity_type !== "ADDRESS") sp.boxes = sp.boxes.map(b => {
|
| 304 |
+
let [x0, y0, x1, y1] = b; const h = y1 - y0;
|
| 305 |
+
for (const q of page.pen) {
|
| 306 |
+
const vo = Math.min(y1, q[3]) - Math.max(y0, q[1]), gap = Math.max(x0, q[0]) - Math.min(x1, q[2]);
|
| 307 |
+
if (q[3] - q[1] <= 2 * h && vo > 0.5 * Math.min(h, q[3] - q[1]) && gap < 0.8 * h)
|
| 308 |
+
[x0, y0, x1, y1] = [Math.min(x0, q[0]), Math.min(y0, q[1]), Math.max(x1, q[2]), Math.max(y1, q[3])];
|
| 309 |
+
}
|
| 310 |
+
return [x0, y0, x1, y1];
|
| 311 |
+
});
|
| 312 |
}
|
| 313 |
}
|
| 314 |
|
|
|
|
| 367 |
const pdf = doc._doc, PAD = 1, color = opts.color ?? [0, 0, 0], wm = opts.watermark;
|
| 368 |
const per = new Map();
|
| 369 |
for (const sp of spans) if (sp.page != null) per.set(sp.page, [...(per.get(sp.page) ?? []), ...sp.boxes]);
|
| 370 |
+
// OCR pages: close small gaps between boxes on one line, so letters between two masked words
|
| 371 |
+
// of a handwritten name do not stay readable
|
| 372 |
+
for (const [pno, boxes] of per) if (doc.pages?.[pno]?.ocr) per.set(pno, closeGaps(boxes));
|
| 373 |
for (const [pno, boxes] of per) {
|
| 374 |
const page = pdf.loadPage(pno);
|
| 375 |
for (const [x0, y0, x1, y1] of boxes) {
|
|
|
|
| 380 |
page.applyRedactions(false, mupdf.PDFPage.REDACT_IMAGE_PIXELS);
|
| 381 |
paintBoxes(mupdf, pdf, page, boxes.map(([x0, y0, x1, y1]) => [x0 - PAD, y0 - PAD, x1 + PAD, y1 + PAD]), color);
|
| 382 |
}
|
| 383 |
+
// Photographed or scanned pages: replace the page by its cleaned image (sheet only, shadows flattened,
|
| 384 |
+
// show-through from the back of the sheet wiped). Ink from the other side can carry the names and
|
| 385 |
+
// numbers we just masked on that page, mirrored and faint but readable with a contrast tweak.
|
| 386 |
+
if (opts.cleanScans !== false) for (const p of doc.pages ?? []) if (p.ocr) cleanScanPage(mupdf, pdf, p.number, per.get(p.number) ?? [], color);
|
| 387 |
if (opts.mark) for (let i = 0; i < pdf.countPages(); i++) stampMark(mupdf, pdf, pdf.loadPage(i), opts.mark);
|
| 388 |
const links = [];
|
| 389 |
if (wm?.text) for (let i = 0; i < pdf.countPages(); i++) {
|
|
|
|
| 400 |
return pdf.saveToBuffer("garbage=4,compress,clean").asUint8Array().slice();
|
| 401 |
}
|
| 402 |
|
| 403 |
+
export function closeGaps(boxes) {
|
| 404 |
+
const out = boxes.map(b => [...b]).sort((a, b) => a[1] - b[1] || a[0] - b[0]);
|
| 405 |
+
for (let merged = true; merged;) {
|
| 406 |
+
merged = false;
|
| 407 |
+
for (let i = 0; i < out.length && !merged; i++) for (let j = i + 1; j < out.length && !merged; j++) {
|
| 408 |
+
const a = out[i], b = out[j], h = Math.min(a[3] - a[1], b[3] - b[1]);
|
| 409 |
+
const vo = Math.min(a[3], b[3]) - Math.max(a[1], b[1]), gap = Math.max(a[0], b[0]) - Math.min(a[2], b[2]);
|
| 410 |
+
if (vo > 0.5 * h && gap < 1.5 * h) {
|
| 411 |
+
out[i] = [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[2], b[2]), Math.max(a[3], b[3])];
|
| 412 |
+
out.splice(j, 1); merged = true;
|
| 413 |
+
}
|
| 414 |
+
}
|
| 415 |
+
}
|
| 416 |
+
return out;
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
function cleanScanPage(mupdf, pdf, i, boxes, color) {
|
| 420 |
+
const page = pdf.loadPage(i);
|
| 421 |
+
if (page.getObject().get("Rotate")?.asNumber?.()) return; // rotated pages keep the plain redaction
|
| 422 |
+
const [x0, y0, x1, y1] = page.getBounds(), w = x1 - x0, h = y1 - y0, s = ocrScale(w, h);
|
| 423 |
+
const pix = page.toPixmap(mupdf.Matrix.scale(s, s), mupdf.ColorSpace.DeviceRGB, false);
|
| 424 |
+
if (!prepareScan(pix)) return;
|
| 425 |
+
const res = pdf.newDictionary(), xo = pdf.newDictionary();
|
| 426 |
+
xo.put("Im0", pdf.addImage(new mupdf.Image(pix)));
|
| 427 |
+
res.put("XObject", xo);
|
| 428 |
+
const np = pdf.addPage([0, 0, w, h], 0, res, `q ${w.toFixed(3)} 0 0 ${h.toFixed(3)} 0 0 cm /Im0 Do Q`);
|
| 429 |
+
pdf.insertPage(i, np); pdf.deletePage(i + 1);
|
| 430 |
+
// the masks went grey in the clean-up; paint them again, full colour, on top
|
| 431 |
+
if (boxes.length) paintBoxes(mupdf, pdf, pdf.loadPage(i), boxes.map(([a, b, c, d]) => [a - x0 - 1, b - y0 - 1, c - x0 + 1, d - y0 + 1]), color);
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
export function pdfText(mupdf, bytes) {
|
| 435 |
const d = mupdf.Document.openDocument(bytes, "application/pdf"); let t = "";
|
| 436 |
for (let i = 0; i < d.countPages(); i++) t += d.loadPage(i).toStructuredText().asText() + "\n";
|
|
|
|
| 475 |
}
|
| 476 |
|
| 477 |
// render each page of a PDF for a second OCR pass (leak check on scans)
|
| 478 |
+
export function pdfPagePngs(mupdf, bytes, pages) {
|
| 479 |
+
const d = mupdf.Document.openDocument(bytes, "application/pdf");
|
| 480 |
+
return pages.map(i => {
|
| 481 |
+
const page = d.loadPage(i), [x0, y0, x1, y1] = page.getBounds(), s = ocrScale(x1 - x0, y1 - y0);
|
| 482 |
+
const pix = page.toPixmap(mupdf.Matrix.scale(s, s), mupdf.ColorSpace.DeviceRGB, false);
|
| 483 |
+
prepareScan(pix);
|
| 484 |
+
return pix.asPNG().slice();
|
| 485 |
+
});
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
// ---- scans and phone photos: what the OCR sees
|
| 489 |
+
// Render at 300 DPI for normal page sizes, but never wider than an A4 page at 300 DPI (3508 px on the
|
| 490 |
+
// long side): a phone photo wrapped in a PDF is often a 1152x2048 pt page holding a 72 ppi image, and
|
| 491 |
+
// 300 DPI would upscale it 4x, which makes Tesseract read handwriting and the wood grain under the paper
|
| 492 |
+
// as text.
|
| 493 |
+
const MAX_OCR_SIDE = 3508;
|
| 494 |
+
export function ocrScale(width, height) {
|
| 495 |
+
return Math.min(OCR_DPI / 72, MAX_OCR_SIDE / Math.max(1, width, height));
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
// In place, on an RGB pixmap: (1) find the sheet of paper (bright, unsaturated) and whiten everything
|
| 499 |
+
// around it, so the floor or desk is not read as words; (2) divide by a blurred copy to flatten shadows
|
| 500 |
+
// and uneven light; (3) wipe faint grey (text showing through from the back of the sheet). Clean flatbed
|
| 501 |
+
// scans come out almost unchanged. Returns what it did, for the report.
|
| 502 |
+
export function prepareScan(pix) {
|
| 503 |
+
const w = pix.getWidth(), h = pix.getHeight(), stride = pix.getStride(), n = pix.getNumberOfComponents();
|
| 504 |
+
if (n < 3 || w < 64 || h < 64) return null;
|
| 505 |
+
const px = pix.getPixels(), g = new Float32Array(w * h), paper = new Uint8Array(w * h), colour = new Uint8Array(w * h);
|
| 506 |
+
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
| 507 |
+
const o = y * stride + x * n, r = px[o], gg = px[o + 1], b = px[o + 2];
|
| 508 |
+
const v = 0.299 * r + 0.587 * gg + 0.114 * b, sat = Math.max(r, gg, b) - Math.min(r, gg, b);
|
| 509 |
+
g[y * w + x] = v;
|
| 510 |
+
paper[y * w + x] = v > 165 && sat < 45 ? 1 : 0;
|
| 511 |
+
colour[y * w + x] = Math.max(0, Math.min(255, 128 + b - r)); // blueness: ballpoint ink is bluer than printed black
|
| 512 |
+
}
|
| 513 |
+
// paper box: rows and columns that are at least 30% paper
|
| 514 |
+
const rowHit = new Array(h).fill(0), colHit = new Array(w).fill(0);
|
| 515 |
+
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (paper[y * w + x]) { rowHit[y]++; colHit[x]++; }
|
| 516 |
+
const rows = rowHit.map((c, y) => c > 0.3 * w ? y : -1).filter(y => y >= 0);
|
| 517 |
+
const cols = colHit.map((c, x) => c > 0.3 * h ? x : -1).filter(x => x >= 0);
|
| 518 |
+
let box = [0, 0, w, h];
|
| 519 |
+
if (rows.length && cols.length) {
|
| 520 |
+
const b = [cols[0], rows[0], cols.at(-1) + 1, rows.at(-1) + 1];
|
| 521 |
+
if ((b[2] - b[0]) * (b[3] - b[1]) >= 0.3 * w * h) box = b; // a small "paper" is a guess gone wrong
|
| 522 |
+
}
|
| 523 |
+
// background estimate: box blur (integral image), radius ~2% of the long side
|
| 524 |
+
const R = Math.max(8, Math.round(Math.max(w, h) / 50)), I = new Float64Array((w + 1) * (h + 1));
|
| 525 |
+
for (let y = 0; y < h; y++) { let row = 0; for (let x = 0; x < w; x++) { row += g[y * w + x]; I[(y + 1) * (w + 1) + x + 1] = I[y * (w + 1) + x + 1] + row; } }
|
| 526 |
+
let flattened = 0;
|
| 527 |
+
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
| 528 |
+
let v = 255;
|
| 529 |
+
if (x >= box[0] && x < box[2] && y >= box[1] && y < box[3]) {
|
| 530 |
+
const ya = Math.max(0, y - R), yb = Math.min(h, y + R + 1), xa = Math.max(0, x - R), xb = Math.min(w, x + R + 1);
|
| 531 |
+
const bg = (I[yb * (w + 1) + xb] - I[ya * (w + 1) + xb] - I[yb * (w + 1) + xa] + I[ya * (w + 1) + xa]) / ((yb - ya) * (xb - xa));
|
| 532 |
+
v = Math.min(255, (g[y * w + x] / Math.max(1, bg)) * 255);
|
| 533 |
+
if (v > 200) v = 255; else flattened++;
|
| 534 |
+
}
|
| 535 |
+
const o = y * stride + x * n;
|
| 536 |
+
px[o] = px[o + 1] = px[o + 2] = v;
|
| 537 |
+
}
|
| 538 |
+
return { paper: box, cropped: box[2] - box[0] < w || box[3] - box[1] < h, ink: flattened / (w * h), colour };
|
| 539 |
}
|
hero/dilekce_maskeli.png
ADDED
|
Git LFS Details
|
hero/dilekce_orijinal.png
ADDED
|
Git LFS Details
|
index.html
CHANGED
|
@@ -7,155 +7,164 @@
|
|
| 7 |
<meta name="referrer" content="no-referrer">
|
| 8 |
<meta name="description" content="Türkçe KVKK kişisel veri maskeleme, tamamen tarayıcıda: PDF, taranmış belge, Word, UYAP UDF. Belge hiçbir sunucuya yüklenmez. Turkish PII redaction that runs fully in your browser.">
|
| 9 |
<meta name="keywords" content="KVKK, kişisel veri maskeleme, anonimleştirme, karartma, PII redaction, Turkish, hukuk, UYAP, PDF">
|
| 10 |
-
<meta property="og:title" content="Katip
|
| 11 |
<meta property="og:description" content="Türkçe hukuk belgelerinde kişisel verileri tarayıcıda bulup maskeler; belge hiçbir sunucuya yüklenmez. Turkish legal document redaction that never uploads the file.">
|
| 12 |
<meta property="og:type" content="website">
|
| 13 |
<link rel="alternate" hreflang="tr" href="./?lang=tr">
|
| 14 |
<link rel="alternate" hreflang="en" href="./?lang=en">
|
| 15 |
-
<title>Katip
|
| 16 |
<link rel="icon" href="mark.svg" type="image/svg+xml">
|
| 17 |
<link rel="preload" href="fonts/space-grotesk-latin-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
|
| 18 |
<link rel="stylesheet" href="style.css">
|
| 19 |
</head>
|
| 20 |
<body>
|
| 21 |
<header class="top">
|
| 22 |
-
<
|
| 23 |
-
<
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
</
|
| 31 |
-
<div class="trust">
|
| 32 |
-
<span class="pill" id="netPill" tabindex="0" data-i18n-tip="netTip" data-tip=""><span data-i18n="netLbl">Belge sonrası ağ isteği</span>: <b id="netCount">0</b></span>
|
| 33 |
-
<span class="pill" tabindex="0" data-i18n-tip="cspTip" data-tip=""><span data-i18n="cspLbl">Dışarı gönderim</span>: <b data-i18n="blocked">engelli</b></span>
|
| 34 |
-
<span class="pill" tabindex="0" data-i18n-tip="storeTip" data-tip=""><span data-i18n="storeLbl">Kayıt</span>: <b data-i18n="none">yok</b></span>
|
| 35 |
-
</div>
|
| 36 |
<div class="seg lang" role="group" aria-label="Dil / Language">
|
| 37 |
<button type="button" data-lang="tr" aria-pressed="true">TR</button>
|
| 38 |
<button type="button" data-lang="en" aria-pressed="false">EN</button>
|
| 39 |
</div>
|
| 40 |
</header>
|
| 41 |
|
| 42 |
-
<main
|
| 43 |
-
<
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
</
|
| 50 |
-
<
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
</
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
<
|
| 72 |
-
<
|
| 73 |
-
<
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
</section>
|
| 78 |
-
|
| 79 |
-
<section class="card">
|
| 80 |
-
<h2 data-i18n="maskH">Maske</h2>
|
| 81 |
-
<div class="lbl" data-i18n="colorH">Renk</div>
|
| 82 |
-
<div class="swatches" id="colors" role="radiogroup"></div>
|
| 83 |
-
<label class="lbl" for="style"><span data-i18n="styleH">Word ve metinde</span> <span class="tip" tabindex="0" data-i18n-tip="styleTip" data-tip="">?</span></label>
|
| 84 |
-
<select id="style">
|
| 85 |
-
<option value="box" data-i18n="styleBox">██████ blok</option>
|
| 86 |
-
<option value="label" data-i18n="styleLabel">[AD SOYAD-1] etiket</option>
|
| 87 |
-
</select>
|
| 88 |
-
<label class="check"><input type="checkbox" id="watermark" checked> <span data-i18n="wmH">Filigran ekle</span> <span class="tip" tabindex="0" data-i18n-tip="wmTip" data-tip="">?</span></label>
|
| 89 |
-
<label class="check"><input type="checkbox" id="centerMark"> <span data-i18n="markH">Ortaya Katip logosu</span> <span class="tip" tabindex="0" data-i18n-tip="markTip" data-tip="">?</span></label>
|
| 90 |
-
</section>
|
| 91 |
-
</aside>
|
| 92 |
|
| 93 |
-
<section class="
|
| 94 |
<div class="toolbar">
|
|
|
|
|
|
|
| 95 |
<div class="seg" role="tablist" id="viewSeg">
|
| 96 |
-
<button role="tab" data-view="
|
| 97 |
-
<button role="tab" data-view="
|
| 98 |
</div>
|
| 99 |
-
<p class="status" id="status" aria-live="polite"></p>
|
| 100 |
<span class="spacer"></span>
|
| 101 |
-
<button id="
|
| 102 |
-
<button id="apply" class="primary hidden" disabled data-i18n="apply">Maskeleri uygula</button>
|
| 103 |
-
<button id="download" class="hidden" disabled data-i18n="download">İndir</button>
|
| 104 |
-
<button id="reportBtn" class="ghost" disabled data-i18n="report">Hata bildir</button>
|
| 105 |
-
<details class="menu"><summary aria-label="Diğer">⋯</summary>
|
| 106 |
-
<div class="menu-body">
|
| 107 |
-
<button id="copy" data-i18n="copy">Etiketli metni kopyala</button>
|
| 108 |
-
<button id="clear" data-i18n="forget">Belgeyi unut</button>
|
| 109 |
-
</div>
|
| 110 |
-
</details>
|
| 111 |
</div>
|
|
|
|
| 112 |
<div class="scorebar hidden" id="scorebar"></div>
|
| 113 |
-
<div
|
| 114 |
-
<div class="rv hidden" id="rv">
|
| 115 |
<div class="docview" id="docView"></div>
|
| 116 |
-
<
|
| 117 |
-
<div class="
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
<div id="list"></div>
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
</div>
|
| 122 |
-
<details class="more hidden" id="more"><summary data-i18n="details">Ayrıntılar</summary><div id="gold"></div><table class="tbl kv" id="timing"></table></details>
|
| 123 |
</section>
|
| 124 |
</main>
|
| 125 |
|
| 126 |
<dialog id="reportDlg">
|
| 127 |
<form method="dialog" id="reportForm">
|
| 128 |
-
<h2 data-i18n="
|
| 129 |
-
<fieldset class="radios"><legend data-i18n="rWhatWrong">
|
| 130 |
-
<label><input type="radio" name="kind" value="
|
| 131 |
-
<label><input type="radio" name="kind" value="
|
| 132 |
-
<label><input type="radio" name="kind" value="
|
| 133 |
-
<label><input type="radio" name="kind" value="
|
| 134 |
</fieldset>
|
| 135 |
-
<label class="lbl" for="rWhat" data-i18n="rWhere">Hangi değer veya yer? (isteğe bağlı)</label>
|
| 136 |
-
<input type="text" id="rWhat">
|
| 137 |
-
<label class="lbl" for="rText" data-i18n="rDesc">
|
| 138 |
-
<textarea id="rText" rows="4" data-i18n-ph="rDescPh" placeholder="Ne
|
| 139 |
-
<
|
| 140 |
<label><input type="checkbox" checked disabled> <span data-i18n="rIncSettings">Ayarlar, sürüm, süreler, puanlar</span></label>
|
| 141 |
<label><input type="checkbox" id="incMasked" checked> <span data-i18n="rIncMasked">Maskeli çıktı</span></label>
|
| 142 |
<label><input type="checkbox" id="incDetections"> <span data-i18n="rIncDet">Bulunan değerler</span> <span class="warn" id="detWarn" data-i18n="rPersonal">(kişisel veri içerir)</span></label>
|
| 143 |
<label><input type="checkbox" id="incOriginal"> <span data-i18n="rIncOrig">Orijinal belge</span> <span class="warn" id="origWarn" data-i18n="rPersonalOk">(kişisel veri içerir; yalnızca izniniz varsa)</span></label>
|
| 144 |
-
</
|
| 145 |
<p class="hint" id="rHow"></p>
|
| 146 |
<div class="row end">
|
| 147 |
<button value="cancel" formnovalidate data-i18n="cancel">Vazgeç</button>
|
| 148 |
<button type="button" id="rSend" class="hidden" data-i18n="rSend">Doğrudan gönder</button>
|
| 149 |
-
<button type="button" id="rMail" class="primary" data-i18n="rMail">
|
| 150 |
</div>
|
| 151 |
<p class="status" id="rStatus"></p>
|
| 152 |
</form>
|
| 153 |
</dialog>
|
| 154 |
|
| 155 |
<footer class="foot">
|
| 156 |
-
<span id="version"></span> · <span data-i18n="foot">
|
| 157 |
· <a href="https://github.com/fikrifirat/katip" id="srcLink" data-i18n="source">Kaynak kod</a>
|
| 158 |
-
· <a href="https://github.com/fikrifirat/katip/issues/new/choose" data-i18n="issues">Soru ve öneriler</a>
|
| 159 |
</footer>
|
| 160 |
<script src="vendor/jszip/dist/jszip.min.js"></script>
|
| 161 |
<script type="module" src="app.js"></script>
|
|
|
|
| 7 |
<meta name="referrer" content="no-referrer">
|
| 8 |
<meta name="description" content="Türkçe KVKK kişisel veri maskeleme, tamamen tarayıcıda: PDF, taranmış belge, Word, UYAP UDF. Belge hiçbir sunucuya yüklenmez. Turkish PII redaction that runs fully in your browser.">
|
| 9 |
<meta name="keywords" content="KVKK, kişisel veri maskeleme, anonimleştirme, karartma, PII redaction, Turkish, hukuk, UYAP, PDF">
|
| 10 |
+
<meta property="og:title" content="Katip Mask: cihazınızda KVKK maskeleme / on-device Turkish PII redaction">
|
| 11 |
<meta property="og:description" content="Türkçe hukuk belgelerinde kişisel verileri tarayıcıda bulup maskeler; belge hiçbir sunucuya yüklenmez. Turkish legal document redaction that never uploads the file.">
|
| 12 |
<meta property="og:type" content="website">
|
| 13 |
<link rel="alternate" hreflang="tr" href="./?lang=tr">
|
| 14 |
<link rel="alternate" hreflang="en" href="./?lang=en">
|
| 15 |
+
<title>Katip Mask: cihazınızda KVKK maskeleme</title>
|
| 16 |
<link rel="icon" href="mark.svg" type="image/svg+xml">
|
| 17 |
<link rel="preload" href="fonts/space-grotesk-latin-wght-normal.woff2" as="font" type="font/woff2" crossorigin>
|
| 18 |
<link rel="stylesheet" href="style.css">
|
| 19 |
</head>
|
| 20 |
<body>
|
| 21 |
<header class="top">
|
| 22 |
+
<h1 class="logo"><svg class="mark" viewBox="0 0 32 32" width="30" height="30" aria-hidden="true" focusable="false">
|
| 23 |
+
<rect width="32" height="15.4" rx="6" fill="#1F242B"/><rect y="7.4" width="32" height="8" fill="#1F242B"/>
|
| 24 |
+
<rect y="16.6" width="32" height="15.4" rx="6" fill="#191D23"/><rect y="16.6" width="32" height="8" fill="#191D23"/>
|
| 25 |
+
<path d="M11 7V25M21.5 7L11.6 16.6M15.2 13.2L21.5 25" fill="none" stroke="#D4FF3A" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round"/>
|
| 26 |
+
<rect y="15.4" width="32" height="1.2" fill="#0B0D10"/>
|
| 27 |
+
</svg><span class="word">Katip <span class="word2">Mask</span></span></h1>
|
| 28 |
+
<span class="pill trustpill" id="netPill" tabindex="0" data-i18n-tip="netTip" data-tip=""><svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M4.5 7V5a3.5 3.5 0 0 1 7 0v2" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="3" y="7" width="10" height="7" rx="1.6" fill="currentColor"/></svg> <span data-i18n="trust">Cihazınızda çalışır · belgeniz bilgisayarınızdan çıkmaz</span></span>
|
| 29 |
+
<span class="spacer"></span>
|
| 30 |
+
<button type="button" id="reportBtn" class="fb" data-i18n="feedback">Görüş bildir</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
<div class="seg lang" role="group" aria-label="Dil / Language">
|
| 32 |
<button type="button" data-lang="tr" aria-pressed="true">TR</button>
|
| 33 |
<button type="button" data-lang="en" aria-pressed="false">EN</button>
|
| 34 |
</div>
|
| 35 |
</header>
|
| 36 |
|
| 37 |
+
<main>
|
| 38 |
+
<section id="home" class="home">
|
| 39 |
+
<div class="home-main">
|
| 40 |
+
<h2 class="hero" data-i18n="hero">Belgedeki kişisel verileri saniyeler içinde maskeleyin.</h2>
|
| 41 |
+
<p class="lead" data-i18n="lead">Ad, T.C. kimlik, adres, telefon, IBAN, sağlık bilgisi… Katip bulur ve karartır; siz kontrol edip indirirsiniz.</p>
|
| 42 |
+
<div id="drop" tabindex="0" role="button">
|
| 43 |
+
<svg viewBox="0 0 24 24" width="30" height="30" aria-hidden="true"><path d="M12 16V4m0 0-4.5 4.5M12 4l4.5 4.5M4 15v3.5A1.5 1.5 0 0 0 5.5 20h13a1.5 1.5 0 0 0 1.5-1.5V15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
| 44 |
+
<strong data-i18n="dropH">Belgeyi buraya bırakın veya seçin</strong>
|
| 45 |
+
<span data-i18n="dropFormats">PDF · fotoğraf · Word · UYAP .udf · metin</span>
|
| 46 |
+
</div>
|
| 47 |
+
<input type="file" id="file" class="hidden" accept=".pdf,.docx,.udf,.txt,.md,.png,.jpg,.jpeg,.tif,.tiff,.bmp,.webp">
|
| 48 |
+
</div>
|
| 49 |
+
<figure class="ba" id="ba">
|
| 50 |
+
<div class="ba-pick">
|
| 51 |
+
<select id="sampleType" aria-label="Örnek belge türü" data-i18n-aria="pickType"></select>
|
| 52 |
+
<div class="seg vars" id="variantBtns" role="group" aria-label="Örnek" data-i18n-aria="pickVariant"></div>
|
| 53 |
+
</div>
|
| 54 |
+
<div class="chips fmts" id="formatBtns" role="group"></div>
|
| 55 |
+
<div class="ba-frame" id="baFrame">
|
| 56 |
+
<div class="ba-o"><img src="hero/dilekce_orijinal.png" alt="Örnek dilekçe, orijinal" width="1000" height="544" data-i18n-alt="baOrigAlt"></div>
|
| 57 |
+
<div class="ba-m"><img src="hero/dilekce_maskeli.png" alt="Aynı dilekçe, Katip ile maskelenmiş" width="1000" height="544" data-i18n-alt="baMaskAlt"></div>
|
| 58 |
+
<span class="ba-tag l" data-i18n="viewReview">Orijinal</span><span class="ba-tag r" data-i18n="viewResult">Maskeli</span>
|
| 59 |
+
<span class="ba-line" aria-hidden="true"></span>
|
| 60 |
+
<span class="ba-busy" data-i18n="applying">Karartılıyor…</span>
|
| 61 |
+
<input type="range" id="baRange" min="0" max="100" value="50" aria-label="Orijinal / maskeli" data-i18n-aria="baAria">
|
| 62 |
+
</div>
|
| 63 |
+
<figcaption><span id="baCap"></span> <button type="button" class="primary sm" id="openSample" data-i18n="baOpen">Bu örneği aç →</button></figcaption>
|
| 64 |
+
</figure>
|
| 65 |
+
<ol class="steps">
|
| 66 |
+
<li><b>1</b><span data-i18n="step1">Belgeyi bırakın</span></li>
|
| 67 |
+
<li><b>2</b><span data-i18n="step2">Karartılanları kontrol edin</span></li>
|
| 68 |
+
<li><b>3</b><span data-i18n="step3">Maskeli halini indirin</span></li>
|
| 69 |
+
</ol>
|
| 70 |
+
<p class="hint center" id="engineState"></p>
|
| 71 |
+
</section>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
+
<section id="work" class="work hidden" aria-label="Belge">
|
| 74 |
<div class="toolbar">
|
| 75 |
+
<button type="button" id="clear" class="ghost" data-i18n="newDoc">← Yeni belge</button>
|
| 76 |
+
<span class="docname" id="docName"></span>
|
| 77 |
<div class="seg" role="tablist" id="viewSeg">
|
| 78 |
+
<button role="tab" data-view="result" aria-selected="true" data-i18n="viewResult" disabled>Maskeli</button>
|
| 79 |
+
<button role="tab" data-view="review" aria-selected="false" data-i18n="viewReview">Orijinal</button>
|
| 80 |
</div>
|
|
|
|
| 81 |
<span class="spacer"></span>
|
| 82 |
+
<button id="download" class="primary" disabled data-i18n="download">Maskeli belgeyi indir</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
</div>
|
| 84 |
+
<p class="status" id="status" aria-live="polite"></p>
|
| 85 |
<div class="scorebar hidden" id="scorebar"></div>
|
| 86 |
+
<div class="rv" id="rv">
|
|
|
|
| 87 |
<div class="docview" id="docView"></div>
|
| 88 |
+
<aside class="findings">
|
| 89 |
+
<div class="profiles">
|
| 90 |
+
<div class="lbl" data-i18n="profH">Ne için maskeliyorsunuz?</div>
|
| 91 |
+
<div class="seg prof" id="profiles" role="radiogroup"></div>
|
| 92 |
+
<p class="hint" id="profHint"></p>
|
| 93 |
+
</div>
|
| 94 |
+
<div class="fhead"><b id="fCount"></b></div>
|
| 95 |
+
<p class="hint" data-i18n="listHint2">Tik kaldırdığınız değer açık kalır. Belgedeki işarete tıklamak da aynı işi görür.</p>
|
| 96 |
<div id="list"></div>
|
| 97 |
+
<div class="row addrow"><input type="text" id="term" data-i18n-ph="termPh" placeholder="Kaçan bir şey mi var? Buraya yazın"><button id="addTerm" data-i18n="add">Ekle</button></div>
|
| 98 |
+
<details class="adv" id="adv">
|
| 99 |
+
<summary data-i18n="advH">Gelişmiş ayarlar</summary>
|
| 100 |
+
<div class="lbl"><span data-i18n="modelH">Algılama modeli</span></div>
|
| 101 |
+
<div class="opts" id="models" role="radiogroup"></div>
|
| 102 |
+
<label class="lbl" for="threshold"><span data-i18n="thrH">Güven eşiği</span> <b id="thresholdVal">0.70</b> <span class="tip" tabindex="0" data-i18n-tip="thrTip" data-tip="">?</span></label>
|
| 103 |
+
<input type="range" id="threshold" min="0.3" max="0.95" step="0.05" value="0.7">
|
| 104 |
+
<label class="lbl" for="addrDetail"><span data-i18n="addrH">Adresler</span> <span class="tip" tabindex="0" data-i18n-tip="addrTip" data-tip="">?</span></label>
|
| 105 |
+
<select id="addrDetail">
|
| 106 |
+
<option value="street" data-i18n="addrStreet">Sokak görünür, bina/daire gizli</option>
|
| 107 |
+
<option value="full" data-i18n="addrFull">Adresin tamamı gizli</option>
|
| 108 |
+
</select>
|
| 109 |
+
<details class="ents">
|
| 110 |
+
<summary><span data-i18n="typesH">Veri türleri</span> <span id="entCount"></span></summary>
|
| 111 |
+
<div id="entities" class="entgrid"></div>
|
| 112 |
+
</details>
|
| 113 |
+
<div class="lbl"><span data-i18n="ocrH">OCR (tarama ve fotoğraf)</span> <span class="tip" tabindex="0" data-i18n-tip="ocrTip" data-tip="">?</span></div>
|
| 114 |
+
<div class="opts" id="ocrModels" role="radiogroup"></div>
|
| 115 |
+
<div class="lbl" data-i18n="colorH">Maske rengi</div>
|
| 116 |
+
<div class="swatches" id="colors" role="radiogroup"></div>
|
| 117 |
+
<label class="lbl" for="style"><span data-i18n="styleH">Word ve metinde</span> <span class="tip" tabindex="0" data-i18n-tip="styleTip" data-tip="">?</span></label>
|
| 118 |
+
<select id="style">
|
| 119 |
+
<option value="box" data-i18n="styleBox">██████ blok</option>
|
| 120 |
+
<option value="label" data-i18n="styleLabel">[AD SOYAD-1] etiket</option>
|
| 121 |
+
</select>
|
| 122 |
+
<label class="check"><input type="checkbox" id="watermark" checked> <span data-i18n="wmH">Filigran ekle</span> <span class="tip" tabindex="0" data-i18n-tip="wmTip" data-tip="">?</span></label>
|
| 123 |
+
<label class="check"><input type="checkbox" id="centerMark"> <span data-i18n="markH">Ortaya Katip logosu</span> <span class="tip" tabindex="0" data-i18n-tip="markTip" data-tip="">?</span></label>
|
| 124 |
+
<p class="hint"><span data-i18n="netLbl">Belge açıldıktan sonra ağ isteği</span>: <b id="netCount">0</b></p>
|
| 125 |
+
<div class="row">
|
| 126 |
+
<button type="button" id="scan" data-i18n="rescan">Yeniden tara</button>
|
| 127 |
+
<button type="button" id="copy" data-i18n="copy">Etiketli metni kopyala</button>
|
| 128 |
+
</div>
|
| 129 |
+
<details class="more" id="more"><summary data-i18n="details">Ayrıntılar</summary><div id="gold"></div><table class="tbl kv" id="timing"></table></details>
|
| 130 |
+
</details>
|
| 131 |
+
</aside>
|
| 132 |
</div>
|
|
|
|
| 133 |
</section>
|
| 134 |
</main>
|
| 135 |
|
| 136 |
<dialog id="reportDlg">
|
| 137 |
<form method="dialog" id="reportForm">
|
| 138 |
+
<h2 data-i18n="feedbackH">Görüşünüz bizim için çok değerli</h2>
|
| 139 |
+
<fieldset class="radios kinds"><legend class="sr" data-i18n="rWhatWrong">Konu</legend>
|
| 140 |
+
<label><input type="radio" name="kind" value="general" checked> <span data-i18n="rGeneral">Genel görüş</span></label>
|
| 141 |
+
<label class="docOnly"><input type="radio" name="kind" value="missed"> <span data-i18n="rMissed">Bir şey açık kaldı</span></label>
|
| 142 |
+
<label class="docOnly"><input type="radio" name="kind" value="false_positive"> <span data-i18n="rFp">Gereksiz karartıldı</span></label>
|
| 143 |
+
<label class="docOnly"><input type="radio" name="kind" value="broken"> <span data-i18n="rBroken">Dosya açılmıyor</span></label>
|
| 144 |
</fieldset>
|
| 145 |
+
<label class="lbl docOnly" for="rWhat" data-i18n="rWhere">Hangi değer veya yer? (isteğe bağlı)</label>
|
| 146 |
+
<input type="text" id="rWhat" class="docOnly">
|
| 147 |
+
<label class="lbl" for="rText" data-i18n="rDesc">Mesajınız</label>
|
| 148 |
+
<textarea id="rText" rows="4" data-i18n-ph="rDescPh" placeholder="Ne işe yaradı, ne eksik, neyi farklı yapardınız?"></textarea>
|
| 149 |
+
<details class="checks docOnly"><summary data-i18n="rInclude">Rapora eklenecekler</summary>
|
| 150 |
<label><input type="checkbox" checked disabled> <span data-i18n="rIncSettings">Ayarlar, sürüm, süreler, puanlar</span></label>
|
| 151 |
<label><input type="checkbox" id="incMasked" checked> <span data-i18n="rIncMasked">Maskeli çıktı</span></label>
|
| 152 |
<label><input type="checkbox" id="incDetections"> <span data-i18n="rIncDet">Bulunan değerler</span> <span class="warn" id="detWarn" data-i18n="rPersonal">(kişisel veri içerir)</span></label>
|
| 153 |
<label><input type="checkbox" id="incOriginal"> <span data-i18n="rIncOrig">Orijinal belge</span> <span class="warn" id="origWarn" data-i18n="rPersonalOk">(kişisel veri içerir; yalnızca izniniz varsa)</span></label>
|
| 154 |
+
</details>
|
| 155 |
<p class="hint" id="rHow"></p>
|
| 156 |
<div class="row end">
|
| 157 |
<button value="cancel" formnovalidate data-i18n="cancel">Vazgeç</button>
|
| 158 |
<button type="button" id="rSend" class="hidden" data-i18n="rSend">Doğrudan gönder</button>
|
| 159 |
+
<button type="button" id="rMail" class="primary" data-i18n="rMail">E-postayla gönder</button>
|
| 160 |
</div>
|
| 161 |
<p class="status" id="rStatus"></p>
|
| 162 |
</form>
|
| 163 |
</dialog>
|
| 164 |
|
| 165 |
<footer class="foot">
|
| 166 |
+
<span id="version"></span> · <span data-i18n="foot">Açık kaynak · tamamen tarayıcıda çalışır</span>
|
| 167 |
· <a href="https://github.com/fikrifirat/katip" id="srcLink" data-i18n="source">Kaynak kod</a>
|
|
|
|
| 168 |
</footer>
|
| 169 |
<script src="vendor/jszip/dist/jszip.min.js"></script>
|
| 170 |
<script type="module" src="app.js"></script>
|
katip_py.zip
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2c3edc248a95f35889b7013e598828fb6acc76566f0b43900f7072172d75c7d5
|
| 3 |
+
size 188607
|
models/registry.json
CHANGED
|
@@ -2,8 +2,8 @@
|
|
| 2 |
{
|
| 3 |
"id": "rules+tagger",
|
| 4 |
"name": {
|
| 5 |
-
"tr": "Katip
|
| 6 |
-
"en": "Katip
|
| 7 |
},
|
| 8 |
"badge": {
|
| 9 |
"tr": "önerilen",
|
|
@@ -58,8 +58,8 @@
|
|
| 58 |
{
|
| 59 |
"id": "berturk",
|
| 60 |
"name": {
|
| 61 |
-
"tr": "Katip
|
| 62 |
-
"en": "Katip
|
| 63 |
},
|
| 64 |
"badge": {
|
| 65 |
"tr": "en doğru",
|
|
@@ -70,8 +70,8 @@
|
|
| 70 |
"en": "Rules + 110 MB BERTurk · 100% on blind set, 90% on real text"
|
| 71 |
},
|
| 72 |
"info": {
|
| 73 |
-
"tr": "En doğru model: kör testte 757 değerin hepsini, gerçek Türkçe metinde isimlerin %90'ını buldu. İlk açılışta 110 MB indirir ve
|
| 74 |
-
"en": "The most accurate model: found all 757 values on the blind test and 90% of names in real Turkish text. Downloads 110 MB the first time and is slower than
|
| 75 |
},
|
| 76 |
"ner": "katip-tr-pii-berturk",
|
| 77 |
"dtype": "q8",
|
|
|
|
| 2 |
{
|
| 3 |
"id": "rules+tagger",
|
| 4 |
"name": {
|
| 5 |
+
"tr": "Katip Mask Nano",
|
| 6 |
+
"en": "Katip Mask Nano"
|
| 7 |
},
|
| 8 |
"badge": {
|
| 9 |
"tr": "önerilen",
|
|
|
|
| 58 |
{
|
| 59 |
"id": "berturk",
|
| 60 |
"name": {
|
| 61 |
+
"tr": "Katip Mask Base",
|
| 62 |
+
"en": "Katip Mask Base"
|
| 63 |
},
|
| 64 |
"badge": {
|
| 65 |
"tr": "en doğru",
|
|
|
|
| 70 |
"en": "Rules + 110 MB BERTurk · 100% on blind set, 90% on real text"
|
| 71 |
},
|
| 72 |
"info": {
|
| 73 |
+
"tr": "En doğru model: kör testte 757 değerin hepsini, gerçek Türkçe metinde isimlerin %90'ını buldu. İlk açılışta 110 MB indirir ve Nano'dan yavaştır.||Katip Kural'a ek olarak BERTurk'ten ince ayarlanmış model (katip-tr-pii-berturk v2): isim, adres ve sağlık terimlerini birlikte bulur. Sentetik belgeler ve 17.967 gerçek Türkçe cümleyle eğitildi. Kör sette 757'de 757, ayrılmış sette 618'de 618; gerçek Yargıtay metninde isimlerin %99,3'ü, Vikipedi cümlelerinde %89,8'i (v1: %40,7). İsim içermeyen metinde 3 yanlış alarm (Nano ile aynı).",
|
| 74 |
+
"en": "The most accurate model: found all 757 values on the blind test and 90% of names in real Turkish text. Downloads 110 MB the first time and is slower than Nano.||Katip Rules plus a model fine-tuned from BERTurk (katip-tr-pii-berturk v2) that finds names, addresses and health terms together. Trained on synthetic documents and 17,967 real Turkish sentences. Blind set 757 of 757, held-out 618 of 618; 99.3% of names in real Yargıtay prose and 89.8% in Wikipedia sentences (v1: 40.7%). 3 false alarms on name-free text (same as Nano)."
|
| 75 |
},
|
| 76 |
"ner": "katip-tr-pii-berturk",
|
| 77 |
"dtype": "q8",
|
paddle_ocr.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// PaddleOCR (PP-OCRv5, Apache-2.0) on onnxruntime: text-line detection (DB) + Latin recogniser (CTC).
|
| 2 |
+
// Used for phone photos and handwriting, where Tesseract misses handwritten digits and names.
|
| 3 |
+
// Same output as the Tesseract path: lines of words {text, bbox:[x0,y0,x1,y1], conf} in image pixels.
|
| 4 |
+
//
|
| 5 |
+
// const ocr = await createPaddleOcr(ort, { det: detBytesOrUrl, rec: recBytesOrUrl, dict: [...chars] });
|
| 6 |
+
// const lines = await ocr.recognize({ width, height, rgb }); // rgb: Uint8Array, 3 bytes per pixel
|
| 7 |
+
//
|
| 8 |
+
// Runs fully offline: the caller hands in the model bytes (or this site's own URLs).
|
| 9 |
+
|
| 10 |
+
const MEAN = [0.485, 0.456, 0.406], STD = [0.229, 0.224, 0.225];
|
| 11 |
+
|
| 12 |
+
export async function createPaddleOcr(ort, { det, rec, dict, detLimit = 1600, detThresh = 0.3, boxThresh = 0.5,
|
| 13 |
+
unclip = 1.6, recHeight = 48, minScore = 0.5, opts = {} } = {}) {
|
| 14 |
+
const sess = m => ort.InferenceSession.create(m, { executionProviders: ["wasm"], graphOptimizationLevel: "all", logSeverityLevel: 3, ...opts });
|
| 15 |
+
const [detS, recS] = await Promise.all([sess(det), sess(rec)]);
|
| 16 |
+
const chars = ["", ...dict, " "]; // CTC blank first; PaddleOCR appends the space class last
|
| 17 |
+
|
| 18 |
+
async function detect(img) {
|
| 19 |
+
const { width: W, height: H, rgb } = img;
|
| 20 |
+
const r = Math.min(1, detLimit / Math.max(W, H));
|
| 21 |
+
const w = Math.max(32, Math.round(W * r / 32) * 32), h = Math.max(32, Math.round(H * r / 32) * 32);
|
| 22 |
+
const sx = W / w, sy = H / h, x = new Float32Array(3 * w * h);
|
| 23 |
+
for (let yy = 0; yy < h; yy++) {
|
| 24 |
+
const y0 = Math.min(H - 1, Math.floor((yy + 0.5) * sy));
|
| 25 |
+
for (let xx = 0; xx < w; xx++) {
|
| 26 |
+
const s = (y0 * W + Math.min(W - 1, Math.floor((xx + 0.5) * sx))) * 3, o = yy * w + xx;
|
| 27 |
+
// PaddleOCR feeds BGR: channel 0 = blue
|
| 28 |
+
x[o] = (rgb[s + 2] / 255 - MEAN[0]) / STD[0];
|
| 29 |
+
x[w * h + o] = (rgb[s + 1] / 255 - MEAN[1]) / STD[1];
|
| 30 |
+
x[2 * w * h + o] = (rgb[s] / 255 - MEAN[2]) / STD[2];
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
const out = await detS.run({ [detS.inputNames[0]]: new ort.Tensor("float32", x, [1, 3, h, w]) });
|
| 34 |
+
const prob = out[detS.outputNames[0]].data;
|
| 35 |
+
// connected components of the thresholded map; one axis-aligned box per component
|
| 36 |
+
const lab = new Int32Array(w * h), boxes = [], stack = [];
|
| 37 |
+
for (let i = 0; i < w * h; i++) {
|
| 38 |
+
if (lab[i] || prob[i] < detThresh) continue;
|
| 39 |
+
let x0 = w, y0 = h, x1 = 0, y1 = 0, n = 0, sum = 0;
|
| 40 |
+
lab[i] = 1; stack.push(i);
|
| 41 |
+
while (stack.length) {
|
| 42 |
+
const j = stack.pop(), jx = j % w, jy = (j / w) | 0;
|
| 43 |
+
n++; sum += prob[j];
|
| 44 |
+
if (jx < x0) x0 = jx; if (jx > x1) x1 = jx; if (jy < y0) y0 = jy; if (jy > y1) y1 = jy;
|
| 45 |
+
for (const k of [j - 1, j + 1, j - w, j + w]) {
|
| 46 |
+
if (k < 0 || k >= w * h || lab[k] || prob[k] < detThresh) continue;
|
| 47 |
+
if ((k === j - 1 && jx === 0) || (k === j + 1 && jx === w - 1)) continue;
|
| 48 |
+
lab[k] = 1; stack.push(k);
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
const bw = x1 - x0 + 1, bh = y1 - y0 + 1;
|
| 52 |
+
if (n < 6 || Math.min(bw, bh) < 3 || sum / n < boxThresh) continue;
|
| 53 |
+
// DB shrinks text regions when training; grow back by area * unclip / perimeter
|
| 54 |
+
const d = n * unclip / (2 * (bw + bh));
|
| 55 |
+
boxes.push([Math.max(0, (x0 - d) * sx), Math.max(0, (y0 - d) * sy), Math.min(W, (x1 + 1 + d) * sx), Math.min(H, (y1 + 1 + d) * sy)]);
|
| 56 |
+
}
|
| 57 |
+
return boxes;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
// crop a box, resize to recHeight (bilinear), normalise to [-1, 1] in BGR
|
| 61 |
+
function crop(img, [bx0, by0, bx1, by1]) {
|
| 62 |
+
const { width: W, rgb } = img, cw = bx1 - bx0, ch = by1 - by0;
|
| 63 |
+
const h = recHeight, w = Math.max(8, Math.min(3200, Math.round(cw * h / ch)));
|
| 64 |
+
const x = new Float32Array(3 * w * h);
|
| 65 |
+
for (let yy = 0; yy < h; yy++) for (let xx = 0; xx < w; xx++) {
|
| 66 |
+
const fx = bx0 + (xx + 0.5) * cw / w - 0.5, fy = by0 + (yy + 0.5) * ch / h - 0.5;
|
| 67 |
+
const ix = Math.max(0, Math.floor(fx)), iy = Math.max(0, Math.floor(fy)), ax = Math.min(1, Math.max(0, fx - ix)), ay = Math.min(1, Math.max(0, fy - iy));
|
| 68 |
+
const ix1 = Math.min(W - 1, ix + 1), iy1 = Math.min(img.height - 1, iy + 1);
|
| 69 |
+
for (let c = 0; c < 3; c++) {
|
| 70 |
+
const p = (yy0, xx0) => rgb[(yy0 * W + xx0) * 3 + c];
|
| 71 |
+
const v = (p(iy, ix) * (1 - ax) + p(iy, ix1) * ax) * (1 - ay) + (p(iy1, ix) * (1 - ax) + p(iy1, ix1) * ax) * ay;
|
| 72 |
+
x[(2 - c) * w * h + yy * w + xx] = (v / 255 - 0.5) / 0.5;
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
return { x, w };
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
async function read(img, box) {
|
| 79 |
+
const { x, w } = crop(img, box);
|
| 80 |
+
const out = await recS.run({ [recS.inputNames[0]]: new ort.Tensor("float32", x, [1, 3, recHeight, w]) });
|
| 81 |
+
const t = out[recS.outputNames[0]], [, T, C] = t.dims, p = t.data;
|
| 82 |
+
// greedy CTC; keep the time step of every character so words get their own boxes
|
| 83 |
+
const seq = [];
|
| 84 |
+
let prev = 0;
|
| 85 |
+
for (let i = 0; i < T; i++) {
|
| 86 |
+
let best = 0, bp = -1;
|
| 87 |
+
for (let c = 0; c < C; c++) if (p[i * C + c] > bp) { bp = p[i * C + c]; best = c; }
|
| 88 |
+
if (best !== 0 && best !== prev) seq.push({ ch: chars[best] ?? "", t: i, p: bp });
|
| 89 |
+
prev = best;
|
| 90 |
+
}
|
| 91 |
+
const step = (box[2] - box[0]) / T, words = [];
|
| 92 |
+
let cur = null;
|
| 93 |
+
for (const s of seq) {
|
| 94 |
+
if (s.ch === " ") { cur = null; continue; }
|
| 95 |
+
if (!cur) words.push(cur = { text: "", t0: s.t, t1: s.t, ps: [] });
|
| 96 |
+
cur.text += s.ch; cur.t1 = s.t; cur.ps.push(s.p);
|
| 97 |
+
}
|
| 98 |
+
return words.map(wd => ({
|
| 99 |
+
text: fixCase(wd.text),
|
| 100 |
+
conf: wd.ps.reduce((a, b) => a + b, 0) / wd.ps.length,
|
| 101 |
+
bbox: [Math.max(box[0], box[0] + (wd.t0 - 0.5) * step), box[1], Math.min(box[2], box[0] + (wd.t1 + 1.5) * step), box[3]],
|
| 102 |
+
}));
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
async function recognize(img) {
|
| 106 |
+
const boxes = await detect(img);
|
| 107 |
+
// reading order: top to bottom, then left to right within a row
|
| 108 |
+
boxes.sort((a, b) => (a[1] + a[3]) / 2 - (b[1] + b[3]) / 2);
|
| 109 |
+
const lines = [];
|
| 110 |
+
for (const b of boxes) {
|
| 111 |
+
if ((b[3] - b[1]) > 1.6 * (b[2] - b[0])) continue; // vertical text: not on these forms
|
| 112 |
+
const words = (await read(img, b)).filter(wd => wd.conf >= minScore && wd.text.trim());
|
| 113 |
+
if (words.length) lines.push(words);
|
| 114 |
+
}
|
| 115 |
+
// join boxes on the same row (Paddle splits a row at wide gaps: label | value)
|
| 116 |
+
const rows = [];
|
| 117 |
+
for (const l of lines) {
|
| 118 |
+
const cy = (l[0].bbox[1] + l[0].bbox[3]) / 2, lh = l[0].bbox[3] - l[0].bbox[1];
|
| 119 |
+
const row = rows.find(r => Math.abs(r.cy - cy) < 0.5 * Math.min(lh, r.lh));
|
| 120 |
+
if (row) row.words.push(...l); else rows.push({ cy, lh, words: [...l] });
|
| 121 |
+
}
|
| 122 |
+
return rows.map(r => r.words.sort((a, b) => a.bbox[0] - b.bbox[0]));
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
return { recognize, detect, read };
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
// The Latin recogniser mixes cases inside Turkish capitals ("SözERi", "HACısALiHOĞLU"): a word that is
|
| 129 |
+
// mostly capitals is upper-cased the Turkish way (i -> İ, ı -> I), and its suffix after an apostrophe is kept.
|
| 130 |
+
export function fixCase(w) {
|
| 131 |
+
const [stem, ...rest] = w.split(/(?=['’])/);
|
| 132 |
+
const letters = [...stem].filter(c => /\p{L}/u.test(c)), up = letters.filter(c => c !== c.toLocaleLowerCase("tr")).length;
|
| 133 |
+
if (letters.length < 3 || up === letters.length || up * 2 < letters.length) return w;
|
| 134 |
+
return stem.toLocaleUpperCase("tr") + rest.join("");
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
// PNG/JPEG bytes -> {width, height, rgb} via MuPDF (already loaded for PDFs; works in the browser and in Node)
|
| 138 |
+
export function decodeImage(mupdf, bytes) {
|
| 139 |
+
const pix = new mupdf.Image(bytes).toPixmap();
|
| 140 |
+
const rgbPix = pix.getColorSpace()?.getName?.() === "DeviceRGB" && !pix.getAlpha() ? pix : pix.convertToColorSpace(mupdf.ColorSpace.DeviceRGB, false);
|
| 141 |
+
const W = rgbPix.getWidth(), H = rgbPix.getHeight(), n = rgbPix.getNumberOfComponents() + (rgbPix.getAlpha() ? 1 : 0), stride = rgbPix.getStride(), px = rgbPix.getPixels();
|
| 142 |
+
const rgb = new Uint8Array(W * H * 3);
|
| 143 |
+
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) for (let c = 0; c < 3; c++) rgb[(y * W + x) * 3 + c] = px[y * stride + x * n + c];
|
| 144 |
+
return { width: W, height: H, rgb };
|
| 145 |
+
}
|
samples/aydinlatma_01.pdf
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:043388dae3e83c47eed56a26d5b23ebb226701364004427af4cd598484b167bf
|
| 3 |
+
size 43370
|
samples/aydinlatma_01.scan.pdf
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:de9e2ea8e6d712fd60c677d9bc566795cb6dd9879235e97731dd1dc0a1903c32
|
| 3 |
+
size 217762
|
samples/aydinlatma_02.docx
ADDED
|
Binary file (37.2 kB). View file
|
|
|
samples/aydinlatma_02.pdf
ADDED
|
Binary file (43.2 kB). View file
|
|
|
samples/aydinlatma_02.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
KİŞİSEL VERİLERİN KORUNMASI KANUNU KAPSAMINDA AYDINLATMA METNİ VE AÇIK RIZA BEYANI
|
| 2 |
+
|
| 3 |
+
Veri Sorumlusu: Boğaziçi Yazılım Teknolojileri A.Ş.
|
| 4 |
+
6698 sayılı Kişisel Verilerin Korunması Kanunu'nun 10. maddesi uyarınca, kimlik, iletişim, sağlık ve finans verileriniz hizmetin ifası amacıyla işlenmektedir. Kanun'un 11. maddesindeki haklarınızı kvkk@sirket.com.tr adresine başvurarak kullanabilirsiniz.
|
| 5 |
+
|
| 6 |
+
İLGİLİ KİŞİ BEYANI
|
| 7 |
+
Adı Soyadı: Rüzgar Uzunöz
|
| 8 |
+
T.C. Kimlik No: 18338758586
|
| 9 |
+
Doğum Tarihi: 26.11.1975
|
| 10 |
+
Adres: Hürriyet Mah. Cumhuriyet Cad. No:31 Daire:17 Çankaya/ANKARA
|
| 11 |
+
Cep Telefonu: 0 (312) 434 39 51
|
| 12 |
+
E-posta: ruzgar.uzunoz@outlook.com
|
| 13 |
+
Kan grubu ve epilepsi bilgimin işlenmesine açık rıza veriyorum.
|
| 14 |
+
Tarih: 04.10.2023 İmza: ________
|
samples/aydinlatma_02.udf
ADDED
|
Binary file (1.06 kB). View file
|
|
|
samples/aydinlatma_03.docx
ADDED
|
Binary file (37.2 kB). View file
|
|
|
samples/aydinlatma_03.pdf
ADDED
|
Binary file (43 kB). View file
|
|
|
samples/aydinlatma_03.png
ADDED
|
Git LFS Details
|
samples/aydinlatma_03.scan.pdf
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5844902b5d587ee0d99c3dbc5f274b33fdf3bfbc7b51fa00df486551e47913e5
|
| 3 |
+
size 218680
|
samples/aydinlatma_03.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
KİŞİSEL VERİLERİN KORUNMASI KANUNU KAPSAMINDA AYDINLATMA METNİ VE AÇIK RIZA BEYANI
|
| 2 |
+
|
| 3 |
+
Veri Sorumlusu: Marmara Lojistik A.Ş.
|
| 4 |
+
6698 sayılı Kişisel Verilerin Korunması Kanunu'nun 10. maddesi uyarınca, kimlik, iletişim, sağlık ve finans verileriniz hizmetin ifası amacıyla işlenmektedir. Kanun'un 11. maddesindeki haklarınızı kvkk@sirket.com.tr adresine başvurarak kullanabilirsiniz.
|
| 5 |
+
|
| 6 |
+
İLGİLİ KİŞİ BEYANI
|
| 7 |
+
Adı Soyadı: EFLİN ATASEVER
|
| 8 |
+
T.C. Kimlik No: 99649828892
|
| 9 |
+
Doğum Tarihi: 04.09.1972
|
| 10 |
+
Adres: Fenerbahçe Mah. Tunalı Hilmi Cad. No:43 Daire:13 Muratpaşa/ANTALYA
|
| 11 |
+
Cep Telefonu: +90 232 254 77 48
|
| 12 |
+
E-posta: eflin.atasever@gmail.com
|
| 13 |
+
Kan grubu ve epilepsi bilgimin işlenmesine açık rıza veriyorum.
|
| 14 |
+
Tarih: 16.01.2023 İmza: ________
|
samples/aydinlatma_03.udf
ADDED
|
Binary file (1.05 kB). View file
|
|
|
samples/aydinlatma_04.docx
ADDED
|
Binary file (37.3 kB). View file
|
|
|
samples/aydinlatma_04.pdf
ADDED
|
Binary file (43.1 kB). View file
|
|
|
samples/aydinlatma_04.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
KİŞİSEL VERİLERİN KORUNMASI KANUNU KAPSAMINDA AYDINLATMA METNİ VE AÇIK RIZA BEYANI
|
| 2 |
+
|
| 3 |
+
Veri Sorumlusu: Anadolu Gıda Sanayi ve Ticaret A.Ş.
|
| 4 |
+
6698 sayılı Kişisel Verilerin Korunması Kanunu'nun 10. maddesi uyarınca, kimlik, iletişim, sağlık ve finans verileriniz hizmetin ifası amacıyla işlenmektedir. Kanun'un 11. maddesindeki haklarınızı kvkk@sirket.com.tr adresine başvurarak kullanabilirsiniz.
|
| 5 |
+
|
| 6 |
+
İLGİLİ KİŞİ BEYANI
|
| 7 |
+
Adı Soyadı: Sadiye SEZGİN
|
| 8 |
+
T.C. Kimlik No: 54482322594
|
| 9 |
+
Doğum Tarihi: 02.07.1969
|
| 10 |
+
Adres: Alsancak Mh. Halaskargazi Cd. Yasemin Apt. No:126 D:4 58921 Seyhan/Adana
|
| 11 |
+
Cep Telefonu: 0542 610 30 26
|
| 12 |
+
E-posta: sadiye.sezgin@yandex.com.tr
|
| 13 |
+
Kan grubu ve hamilelik bilgimin işlenmesine açık rıza veriyorum.
|
| 14 |
+
Tarih: 25.09.2024 İmza: ________
|
samples/aydinlatma_04.udf
ADDED
|
Binary file (1.05 kB). View file
|
|
|
samples/aydinlatma_05.docx
ADDED
|
Binary file (37.2 kB). View file
|
|
|
samples/aydinlatma_05.pdf
ADDED
|
Binary file (43.2 kB). View file
|
|
|
samples/aydinlatma_05.png
ADDED
|
Git LFS Details
|
samples/aydinlatma_05.scan.pdf
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:cb6490d4dacf81cd9c609733ed6b66955b4069fcc1799b757eaac82fcedcd1da
|
| 3 |
+
size 216042
|
samples/aydinlatma_05.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
KİŞİSEL VERİLERİN KORUNMASI KANUNU KAPSAMINDA AYDINLATMA METNİ VE AÇIK RIZA BEYANI
|
| 2 |
+
|
| 3 |
+
Veri Sorumlusu: Kuzey Enerji Ltd. Şti.
|
| 4 |
+
6698 sayılı Kişisel Verilerin Korunması Kanunu'nun 10. maddesi uyarınca, kimlik, iletişim, sağlık ve finans verileriniz hizmetin ifası amacıyla işlenmektedir. Kanun'un 11. maddesindeki haklarınızı kvkk@sirket.com.tr adresine başvurarak kullanabilirsiniz.
|
| 5 |
+
|
| 6 |
+
İLGİLİ KİŞİ BEYANI
|
| 7 |
+
Adı Soyadı: Akay Atasever
|
| 8 |
+
T.C. Kimlik No: 81351468770
|
| 9 |
+
Doğum Tarihi: 01.10.1983
|
| 10 |
+
Adres: Kızılay Mahallesi, Cinnah Caddesi No: 116 Kat: 1 Nilüfer/BURSA
|
| 11 |
+
Cep Telefonu: +90 (216) 229 9917
|
| 12 |
+
E-posta: akay.atasever@gmail.com
|
| 13 |
+
Kan grubu ve diyabet bilgimin işlenmesine açık rıza veriyorum.
|
| 14 |
+
Tarih: 20.04.2021 İmza: ________
|
samples/aydinlatma_05.udf
ADDED
|
Binary file (1.04 kB). View file
|
|
|
samples/bilirkisi_01.pdf
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:e285901891f11fbf3e802c5bc371840ae2b3221aa650d763e3cc9af05b42af8d
|
| 3 |
+
size 40197
|
samples/bilirkisi_01.scan.pdf
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6488afda439c6ab06bb663d938a7b2a09a96c0ea41d7afba2ce13c5b97720caf
|
| 3 |
+
size 193712
|
samples/bilirkisi_02.docx
ADDED
|
Binary file (37 kB). View file
|
|
|
samples/bilirkisi_02.pdf
ADDED
|
Binary file (40.3 kB). View file
|
|
|
samples/bilirkisi_02.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
BİLİRKİŞİ RAPORU
|
| 2 |
+
|
| 3 |
+
Dosya kapsamındaki belgeler incelendiğinde, DURACAN TARHAN'ın 15.12.1975 tarihinde doğduğu, olay tarihinde Ilgın ATASEVER adına kayıtlı 33 LY 4593 plakalı araçla seyir halindeyken kazaya karıştığı anlaşılmıştır. Kazadan sonra Duracan yoğun bakım ünitesinde tedavi görmüştür. Sigorta şirketi ödemeyi TR59 0006 2088 5152 8974 8868 64 hesabına yapmıştır. Ilgın ATASEVER ise ifadesinde aracı ödünç verdiğini beyan etmiştir.
|
samples/bilirkisi_02.udf
ADDED
|
Binary file (763 Bytes). View file
|
|
|
samples/bilirkisi_03.docx
ADDED
|
Binary file (37 kB). View file
|
|
|
samples/bilirkisi_03.pdf
ADDED
|
Binary file (40.4 kB). View file
|
|
|
samples/bilirkisi_03.png
ADDED
|
Git LFS Details
|
samples/bilirkisi_03.scan.pdf
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f1a544e74ee7e8f9800a397c984fecb0ca1023c4726a881c35734a2b3093adba
|
| 3 |
+
size 195313
|
samples/bilirkisi_03.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
BİLİRKİŞİ RAPORU
|
| 2 |
+
|
| 3 |
+
Dosya kapsamındaki belgeler incelendiğinde, Ginyas Korutürk'ın 24.08.1975 tarihinde doğduğu, olay tarihinde SEMAT KESKİNKILIÇ adına kayıtlı 80 FJT 795 plakalı araçla seyir halindeyken kazaya karıştığı anlaşılmıştır. Kazadan sonra Ginyas yoğun bakım ünitesinde tedavi görmüştür. Sigorta şirketi ödemeyi TR19 0006 7024 6096 6331 3208 93 hesabına yapmıştır. SEMAT KESKİNKILIÇ ise ifadesinde aracı ödünç verdiğini beyan etmiştir.
|
samples/bilirkisi_03.udf
ADDED
|
Binary file (766 Bytes). View file
|
|
|
samples/bilirkisi_04.docx
ADDED
|
Binary file (37 kB). View file
|
|
|
samples/bilirkisi_04.pdf
ADDED
|
Binary file (39.9 kB). View file
|
|
|
samples/bilirkisi_04.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
BİLİRKİŞİ RAPORU
|
| 2 |
+
|
| 3 |
+
Dosya kapsamındaki belgeler incelendiğinde, Elvin Uzunöz'ın 10.11.1974 tarihinde doğduğu, olay tarihinde ADA KESKİNKILIÇ adına kayıtlı 37 IKK 934 plakalı araçla seyir halindeyken kazaya karıştığı anlaşılmıştır. Kazadan sonra Elvin yoğun bakım ünitesinde tedavi görmüştür. Sigorta şirketi ödemeyi TR72 0001 0046 8453 0373 7758 46 hesabına yapmıştır. ADA KESKİNKILIÇ ise ifadesinde aracı ödünç verdiğini beyan etmiştir.
|
samples/bilirkisi_04.udf
ADDED
|
Binary file (760 Bytes). View file
|
|
|
samples/bilirkisi_05.docx
ADDED
|
Binary file (37 kB). View file
|
|
|