f1k commited on
Commit
7a026bc
·
verified ·
1 Parent(s): 7141819

Katip Yerel demo

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +35 -0
  2. README.md +4 -3
  3. app.js +262 -124
  4. config.js +1 -1
  5. engine.js +313 -11
  6. hero/dilekce_maskeli.png +3 -0
  7. hero/dilekce_orijinal.png +3 -0
  8. index.html +111 -102
  9. katip_py.zip +2 -2
  10. models/registry.json +6 -6
  11. paddle_ocr.js +145 -0
  12. samples/aydinlatma_01.pdf +2 -2
  13. samples/aydinlatma_01.scan.pdf +2 -2
  14. samples/aydinlatma_02.docx +0 -0
  15. samples/aydinlatma_02.pdf +0 -0
  16. samples/aydinlatma_02.txt +14 -0
  17. samples/aydinlatma_02.udf +0 -0
  18. samples/aydinlatma_03.docx +0 -0
  19. samples/aydinlatma_03.pdf +0 -0
  20. samples/aydinlatma_03.png +3 -0
  21. samples/aydinlatma_03.scan.pdf +3 -0
  22. samples/aydinlatma_03.txt +14 -0
  23. samples/aydinlatma_03.udf +0 -0
  24. samples/aydinlatma_04.docx +0 -0
  25. samples/aydinlatma_04.pdf +0 -0
  26. samples/aydinlatma_04.txt +14 -0
  27. samples/aydinlatma_04.udf +0 -0
  28. samples/aydinlatma_05.docx +0 -0
  29. samples/aydinlatma_05.pdf +0 -0
  30. samples/aydinlatma_05.png +3 -0
  31. samples/aydinlatma_05.scan.pdf +3 -0
  32. samples/aydinlatma_05.txt +14 -0
  33. samples/aydinlatma_05.udf +0 -0
  34. samples/bilirkisi_01.pdf +2 -2
  35. samples/bilirkisi_01.scan.pdf +2 -2
  36. samples/bilirkisi_02.docx +0 -0
  37. samples/bilirkisi_02.pdf +0 -0
  38. samples/bilirkisi_02.txt +3 -0
  39. samples/bilirkisi_02.udf +0 -0
  40. samples/bilirkisi_03.docx +0 -0
  41. samples/bilirkisi_03.pdf +0 -0
  42. samples/bilirkisi_03.png +3 -0
  43. samples/bilirkisi_03.scan.pdf +3 -0
  44. samples/bilirkisi_03.txt +3 -0
  45. samples/bilirkisi_03.udf +0 -0
  46. samples/bilirkisi_04.docx +0 -0
  47. samples/bilirkisi_04.pdf +0 -0
  48. samples/bilirkisi_04.txt +3 -0
  49. samples/bilirkisi_04.udf +0 -0
  50. 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
- - 16 synthetic sample documents with answer keys: see recall, precision, misses and false alarms live.
36
- - Switch the detection model, OCR model, confidence threshold and data types.
37
- - Before/after view, leak check on the output, report a mistake by e-mail.
 
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 sonrası ağ isteği", netTip: "Belgeyi açtıktan sonra bu sayfanın internete kaç kez bağlandığı. 0 kalmalı.||Motor ve model dosyaları sayılmaz; bunlar belgeyi açmadan önce indirilir.",
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 tıklayın", 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.||T.C. kimlik, VKN, IBAN gibi sağlama kontrollü numaralar her zaman yakalanır.",
26
- typesH: "Veri türleri", maskH: "Maske", colorH: "Renk", 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
  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 Yerel ile maskelendi\" satırı ekler.||Tam metin: \"{wm}\". PDF'te bağlantılıdır. UYAP .udf dosyalarına eklenmez.",
32
- viewReview: "İnceleme", viewResult: "Sonuç", apply: "Maskeleri uygula", reapply: "Yeniden uygula", download: "İndir", report: "Hata bildir",
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 terim ekle", add: "Ekle",
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} değer maskelenecek", missedH: "Kaçanlar", addMissed: "Maskeye ekle", flag: "Hata bildirimine ekle", warnOnly: "uyarı",
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. Göndermeyin.", leakUnchecked: "Taranmış sayfalar tekrar okunmadı; çıktıyı gözle kontrol edin.",
42
- leakOk: "Sızıntı kontrolü geçti: maskelenen değerlerin hiçbiri çıktıda okunamıyor.",
43
- loading: "Motor yükleniyor…", ready: "Motor hazır ({s} sn).", preparing: "Hazırlanıyor…", done: "{n} değer maskelendi.", copied: "Etiketli metin kopyalandı.", forgotten: "Belge bellekten silindi.",
44
- waiting: "Motor yükleniyor; sonra Kişisel verileri bul'a basın.", 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",
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: "Ne yanlış?", rMissed: "Bir şey maskelenmedi", rFp: "Gereksiz bir şey maskelendi", rBroken: "Çıktı bozuk veya açılmıyor", rOther: "Diğer",
50
- rWhere: "Hangi değer veya yer? (isteğe bağlı)", rDesc: "Açıklama", rDescPh: "Ne bekliyordunuz, ne oldu?", rInclude: "Rapora eklenecekler",
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: "Raporu indir ve e-posta aç",
53
- rHowMail: "Rapor bilgisayarınıza indirilir ve e-posta uygulamanız {to} adresine açılır. İndirilen .zip dosyasını ekleyip gönderin. Siz göndermeden hiçbir şey çıkmaz.",
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: "Kurallar Katip sunucusundakilerle aynı (Pyodide) · PDF: MuPDF · OCR: Tesseract", source: "Kaynak kod", issues: "Soru ve öneriler",
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: "Requests after opening", netTip: "How many times this page went online after you opened the document. It should stay 0.||Engine and model files are not counted; they download before you open a document.",
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 file or click", fileHint: "The file stays in this tab's memory only.",
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: "Colour", styleH: "In Word and text",
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 Yerel\" line at the bottom.||Full text: \"{wm}\". Linked in PDFs. Not added to UYAP .udf files.",
80
- viewReview: "Review", viewResult: "Result", apply: "Apply masks", reapply: "Apply again", download: "Download", report: "Report a mistake",
81
  copy: "Copy labelled text", forget: "Forget document",
82
  empty: "Pick a sample on the left or open your own file.",
83
- termPh: "Add a missed term", add: "Add",
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} values to mask", missedH: "Missed", addMissed: "Add to masks", flag: "Add to mistake report", warnOnly: "flag",
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 it.", leakUnchecked: "Scanned pages were not re-read; check the output by eye.",
90
- leakOk: "Leak check passed: none of the masked values can be read in the output.",
91
- loading: "Loading engine…", ready: "Engine ready ({s} s).", preparing: "Preparing…", done: "{n} values masked.", copied: "Labelled text copied.", forgotten: "Document removed from memory.",
92
- waiting: "Engine loading; then press Find personal data.", 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",
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: "What went wrong?", rMissed: "Something was not masked", rFp: "Something was masked by mistake", rBroken: "The output is broken or won't open", rOther: "Other",
98
- rWhere: "Which value or place? (optional)", rDesc: "Description", rDescPh: "What did you expect, what happened?", rInclude: "Include in the report",
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: "Download report and open e-mail",
101
- rHowMail: "The report is downloaded to your computer and your e-mail app opens a message to {to}. Attach the downloaded .zip and send it. Nothing leaves until you send it.",
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: "Same rules as the Katip server (Pyodide) · PDF: MuPDF · OCR: Tesseract", source: "Source code", issues: "Questions and ideas",
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", CUSTOM: "Eklenen terim" },
 
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", CUSTOM: "Added term" },
 
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 Yerel: tarayıcıda KVKK maskeleme" : "Katip Yerel: in-browser Turkish PII redaction";
216
- if (samples.length) { buildSampleOptions(); drawFormats(); buildModelUI(); buildEntityUI(); }
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 ?? "best_int";
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", "best_int", t("ocrBest"), t("ocrBestMeta"), t("ocrTip"), curOcr === "best_int", lang === "tr" ? "önerilen" : "recommended"),
 
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 !== "CRIMINAL_RECORD" });
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 s of samples) $("sampleType").append(el("option", { value: s.id, textContent: lang === "en" ? SAMPLE_EN[s.type] ?? s.title : s.title }));
296
- if (cur) $("sampleType").value = cur;
297
- }
298
- function buildSampleUI() {
299
- buildSampleOptions();
300
- $("sampleType").onchange = () => { src.sample = samples.find(s => s.id === $("sampleType").value); drawFormats(); loadSource(); };
301
- src.sample = samples[0]; drawFormats();
302
- $("srcSampleBtn").onclick = () => { if (src.mode === "sample") return; setMode("sample"); loadSource(); };
303
- $("srcFileBtn").onclick = () => { if (src.mode === "file") return; setMode("file"); if (src.file) loadSource(); };
 
 
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(); loadSource(); };
312
  f.append(b);
313
  }
314
  }
315
- function setMode(m) {
316
- src.mode = m;
317
- $("srcSampleBtn").setAttribute("aria-selected", m === "sample"); $("srcFileBtn").setAttribute("aria-selected", m === "file");
318
- $("srcSample").classList.toggle("hidden", m !== "sample"); $("srcFile").classList.toggle("hidden", m !== "file");
 
 
 
319
  }
320
- function pickFile(f) { if (!f) return; src.file = f; $("fileName").textContent = f.name; setMode("file"); loadSource(); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- $("drop").ondragover = e => { e.preventDefault(); $("drop").classList.add("over"); };
325
- $("drop").ondragleave = () => $("drop").classList.remove("over");
326
- $("drop").ondrop = e => { e.preventDefault(); $("drop").classList.remove("over"); pickFile(e.dataTransfer.files[0]); };
 
 
 
 
 
327
 
328
  // Selecting a document shows it at once; analysis starts as soon as the engine is ready.
329
- async function loadSource() {
 
 
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
- status(ready ? t("toScan") : t("waiting"));
350
  }
351
 
352
  // ---------------------------------------------------------------- run
353
- const status = m => ($("status").textContent = 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("preparing"));
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(progressText("Maskeleniyor") + "…");
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
- $("apply").onclick = () => res?.spans && !busy && redactNow();
 
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 = () => { if (b.disabled) return; view = b.dataset.view; render(); }));
 
 
 
377
 
378
  function updateButtons() {
379
  const has = !!res?.spans;
380
- $("scan").disabled = !ready || !loaded || busy; $("scan").textContent = has ? t("rescan") : t("scan"); $("scan").classList.toggle("primary", !has);
381
- $("apply").classList.toggle("hidden", !has); $("download").classList.toggle("hidden", !has);
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; status(loaded && !res ? t("toScan") : t("ready", { s: (m.ms / 1000).toFixed(1) })); getTess("best_int"); updateButtons();
 
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 => s.flag_only || !res.settings.entities.includes(s.entity_type)).map(key));
407
- busy = false; status(t("found", { n: chosen().length }) + "."); render();
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 => s.flag_only || wasOff.has(key(s))).map(key));
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(t("done", { n: chosen().length })); render();
 
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
- $("rv").classList.add("hidden"); $("more").classList.add("hidden"); $("scorebar").classList.add("hidden"); $("empty").hidden = false;
427
- updateButtons(); status(t("forgotten"));
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
- const found = g.map(x => spans.some(s => matches(s.text, x.text)));
 
 
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") view = "review"; render(); }
461
 
462
  function renderPreview() {
463
  // before analysis: the document as it is, no highlights yet
464
- $("empty").hidden = true; $("rv").classList.remove("hidden"); $("scorebar").classList.add("hidden"); $("more").classList.add("hidden");
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("p", { className: "hint", textContent: t("listHint") }));
473
  }
474
 
475
  function render() {
476
  if (!res?.spans) { if (preview) renderPreview(); updateButtons(); return; }
477
  if (!applied && view === "result") view = "review";
478
- $("empty").hidden = true; $("rv").classList.remove("hidden"); $("more").classList.remove("hidden");
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 (ev) {
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: "sc " + cls }, el("b", { textContent: String(n) }), " " + t("sLeak")));
493
- items.push(el("span", { className: "msg " + cls, textContent: n ? t("leakBad", { n }) : res.unchecked ? t("leakUnchecked") : t("leakOk") }));
 
 
 
 
494
  }
495
  if (dirty()) items.push(el("span", { className: "msg warn-t", textContent: t("sNotApplied") }));
496
- else if (!applied) items.push(el("span", { className: "msg", textContent: t("sPreview") }));
 
 
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
- const rows = spans.map(s => {
564
- const cb = el("input", { type: "checkbox", checked: !off.has(key(s)), ariaLabel: s.text }); cb.onchange = () => toggle(s);
565
- const fp = res.eval && res.eval.fps.some(f => key(f) === key(s));
 
 
 
 
 
566
  const flag = el("button", { type: "button", className: "flag", textContent: "⚑", title: t("flag"), ariaLabel: t("flag") });
567
- flag.onclick = e => { e.preventDefault(); openReport(off.has(key(s)) ? "missed" : "false_positive", s.text); };
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 ? (res.settings.ocrLang === "fast" ? t("ocrFast") : t("ocrBest")) : t("unused")],
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
- $("download").onclick = () => { if (!outBlob) return; const a = el("a", { href: URL.createObjectURL(outBlob), download: outName }); a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 30000); };
599
- $("copy").onclick = () => { res?.spans && worker.postMessage({ type: "maskedText", spans: chosen() }); $("copy").closest("details").open = false; };
600
- $("clear").onclick = () => { worker.postMessage({ type: "forget" }); $("clear").closest("details").open = false; };
 
 
 
 
 
 
601
 
602
  // ---------------------------------------------------------------- report a mistake
603
  let pendingOriginal = null;
604
- function openReport(kind = "missed", what = "") {
605
- if (!res?.spans) return;
 
 
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 Yerel hata bildirimi: ${KIND_TR[report.feedback.kind]} (${report.document.kind})`;
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 Yerel hata bildirimi ${id}`); fd.append("email", CONFIG.reportEmail);
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 Yerel ${version.app} · ${version.rules} · ${version.built}`;
677
  if (version.source) $("srcLink").href = version.source;
678
- buildColorUI(); buildSampleUI(); buildModelUI(); buildEntityUI(); applyLang();
679
- loadSource();
 
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 Yerel ile maskelendi - fikrifirat.github.io/katip", en: "Redacted with Katip Yerel - fikrifirat.github.io/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 = OCR_DPI / 72;
71
  const pix = page.toPixmap(mupdf.Matrix.scale(s, s), mupdf.ColorSpace.DeviceRGB, false);
72
- const png = pix.asPNG(); pix.destroy?.();
73
- p.lines = (await ocr(png)).map(l => l.map(w => ({ text: w.text, bbox: w.bbox.map(v => v / s) })));
74
- p.ocr = true; ocrPages++;
 
 
 
 
 
 
 
 
 
 
 
75
  }
76
  pages.push(p);
77
  }
78
  return { kind: "pdf", text: assemble(pages), pages, ocrPages, _doc: doc };
79
  }
80
 
81
- export async function parseImage(bytes, ocr, size) {
82
- const p = { number: 0, width: size?.width ?? 0, height: size?.height ?? 0, words: [], ocr: true, lines: await ocr(bytes) };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, dpi = OCR_DPI) {
235
- const d = mupdf.Document.openDocument(bytes, "application/pdf"), s = dpi / 72;
236
- return pages.map(i => d.loadPage(i).toPixmap(mupdf.Matrix.scale(s, s), mupdf.ColorSpace.DeviceRGB, false).asPNG().slice());
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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

  • SHA256: b086252c6eed5322b4afa9e3ccda47473362c92769174358fe7bcab327703e62
  • Pointer size: 131 Bytes
  • Size of remote file: 102 kB
hero/dilekce_orijinal.png ADDED

Git LFS Details

  • SHA256: 7f2351d94407a89dd03d11b7963755cf3e1ce5e465d203234804562bb8599029
  • Pointer size: 131 Bytes
  • Size of remote file: 130 kB
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 Yerel: tarayıcıda KVKK maskeleme / in-browser 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 Yerel: tarayıcıda 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
- <div class="brand">
23
- <h1 class="logo"><svg class="mark" viewBox="0 0 32 32" width="30" height="30" aria-hidden="true" focusable="false">
24
- <rect width="32" height="15.4" rx="6" fill="#1F242B"/><rect y="7.4" width="32" height="8" fill="#1F242B"/>
25
- <rect y="16.6" width="32" height="15.4" rx="6" fill="#191D23"/><rect y="16.6" width="32" height="8" fill="#191D23"/>
26
- <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"/>
27
- <rect y="15.4" width="32" height="1.2" fill="#0B0D10"/>
28
- </svg><span class="word">Katip <span class="word2">Yerel</span></span></h1>
29
- <p class="sub" data-i18n="tagline">Türkçe KVKK maskeleme, tamamen tarayıcınızda. Belge hiçbir sunucuya yüklenmez.</p>
30
- </div>
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 class="grid">
43
- <aside class="side">
44
- <section class="card">
45
- <h2 data-i18n="docH">Belge</h2>
46
- <div class="seg" role="tablist">
47
- <button role="tab" id="srcSampleBtn" aria-selected="true" data-i18n="sampleTab">Örnek belge</button>
48
- <button role="tab" id="srcFileBtn" aria-selected="false" data-i18n="fileTab">Kendi belgem</button>
49
- </div>
50
- <div id="srcSample">
51
- <select id="sampleType" aria-label="Örnek belge"></select>
52
- <div class="chips" id="formatBtns" role="group"></div>
53
- <p class="hint"><span data-i18n="sampleHint">Sentetik belgeler, cevap anahtarıyla puanlanır.</span> <span class="tip" tabindex="0" data-i18n-tip="sampleTip" data-tip="">?</span></p>
54
- </div>
55
- <div id="srcFile" class="hidden">
56
- <div id="drop" tabindex="0" role="button">
57
- <strong data-i18n="dropH">Belgeyi bırakın veya tıklayın</strong>
58
- <span>PDF, .docx, UYAP .udf, .txt, PNG/JPG</span>
59
- <span id="fileName" class="fname"></span>
60
- </div>
61
- <input type="file" id="file" class="hidden" accept=".pdf,.docx,.udf,.txt,.md,.png,.jpg,.jpeg,.tif,.tiff,.bmp,.webp">
62
- <p class="hint"><span data-i18n="fileHint">Dosya yalnızca bu sekmenin belleğinde kalır.</span> <span class="tip" tabindex="0" data-i18n-tip="fileTip" data-tip="">?</span></p>
63
- </div>
64
- </section>
65
-
66
- <section class="card">
67
- <h2 data-i18n="detectH">Algılama</h2>
68
- <div class="opts" id="models" role="radiogroup"></div>
69
- <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>
70
- <div class="opts" id="ocrModels" role="radiogroup"></div>
71
- <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>
72
- <input type="range" id="threshold" min="0.3" max="0.95" step="0.05" value="0.7">
73
- <details class="ents">
74
- <summary><span data-i18n="typesH">Veri türleri</span> <span id="entCount"></span></summary>
75
- <div id="entities" class="entgrid"></div>
76
- </details>
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="review card" aria-label="İnceleme">
94
  <div class="toolbar">
 
 
95
  <div class="seg" role="tablist" id="viewSeg">
96
- <button role="tab" data-view="review" aria-selected="true" data-i18n="viewReview">İnceleme</button>
97
- <button role="tab" data-view="result" aria-selected="false" data-i18n="viewResult" disabled>Sonuç</button>
98
  </div>
99
- <p class="status" id="status" aria-live="polite"></p>
100
  <span class="spacer"></span>
101
- <button id="scan" class="primary" disabled data-i18n="scan">Kişisel verileri bul</button>
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 id="empty" class="empty" data-i18n="empty">Soldan bir örnek seçin veya kendi belgenizi açın.</div>
114
- <div class="rv hidden" id="rv">
115
  <div class="docview" id="docView"></div>
116
- <div class="findings">
117
- <div class="row"><input type="text" id="term" data-i18n-ph="termPh" placeholder="Kaçan bir terim ekle"><button id="addTerm" data-i18n="add">Ekle</button></div>
118
- <div class="fhead"><b id="fCount"></b> <span class="tip" tabindex="0" data-i18n-tip="findTip" data-tip="">?</span></div>
 
 
 
 
 
119
  <div id="list"></div>
120
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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="report">Hata bildir</h2>
129
- <fieldset class="radios"><legend data-i18n="rWhatWrong">Ne yanlış?</legend>
130
- <label><input type="radio" name="kind" value="missed" checked> <span data-i18n="rMissed">Bir şey maskelenmedi</span></label>
131
- <label><input type="radio" name="kind" value="false_positive"> <span data-i18n="rFp">Gereksiz bir şey maskelendi</span></label>
132
- <label><input type="radio" name="kind" value="broken"> <span data-i18n="rBroken">Çıktı bozuk veya açılmıyor</span></label>
133
- <label><input type="radio" name="kind" value="other"> <span data-i18n="rOther">Diğer</span></label>
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">Açıklama</label>
138
- <textarea id="rText" rows="4" data-i18n-ph="rDescPh" placeholder="Ne bekliyordunuz, ne oldu?"></textarea>
139
- <fieldset class="checks"><legend data-i18n="rInclude">Rapora eklenecekler</legend>
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
- </fieldset>
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">Raporu indir ve e-posta aç</button>
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">Kurallar Katip sunucusundakilerle aynı (Pyodide) · PDF: MuPDF · OCR: Tesseract</span>
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:ea2f306c211abdfce7ae6dc87d251e7220c6d10a3a8f71a5803934ea01c4339f
3
- size 178793
 
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 Tiny",
6
- "en": "Katip Tiny"
7
  },
8
  "badge": {
9
  "tr": "önerilen",
@@ -58,8 +58,8 @@
58
  {
59
  "id": "berturk",
60
  "name": {
61
- "tr": "Katip BERTurk",
62
- "en": "Katip BERTurk"
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 Tiny'den 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 (Tiny 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 Tiny.||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 Tiny)."
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:aa558cf0c07fdfcdea8af24801bc278b916e577594d8074072a508e92eb2a754
3
- size 791691
 
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:4e7ddf1ca8607bfe2430d4b214f7aeb55bd5db10725af945899254d92dae9554
3
- size 217901
 
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

  • SHA256: 1a8d1d0193c45108c3d830384c0d650073a87093283971bf24ecb17d1e78134d
  • Pointer size: 131 Bytes
  • Size of remote file: 162 kB
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

  • SHA256: 9dafd56fdb0a104d2f0f3546826e5d434e54d927c5cbd47f68fbbe844a2f4b3a
  • Pointer size: 131 Bytes
  • Size of remote file: 157 kB
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:2cbe3d61c66bd38995101b32bdc8ee220a68c46802ee37e0eed15b0769bfecc5
3
- size 789953
 
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:f6689cea1f421a3490c773977529141e059644ad702de6ed1382aea33ebb05b2
3
- size 193851
 
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

  • SHA256: b4101487625cf2e0e4bece737dcb5810f74a465758ed2d1c0ff1af583cdab950
  • Pointer size: 131 Bytes
  • Size of remote file: 130 kB
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